feat(auth-adapter,bff-kit,client-shell): 마지막 추출 배치

@yakcloud/auth-adapter: core 인터페이스(프레임워크 무관, ds-sdk type-only)
  + dev-bypass + next-auth 격리 계층(peer). exports 서브패스(./core·./dev-bypass).
@yakcloud/bff-kit: respond(web 표준)·serialize(Prisma-free)·ratelimit·session(AuthAdapter DI).
  auth-adapter=devDependency(type-only), 죽은 next peer 제거.
@yakcloud/client-shell: ServiceClientShell 셸 크롬(헤더/탭/pane/가드)+패널 슬롯+BackupPanel 스텁.
  (FileBrowser 는 Phase 1)

FIX-FIRST 반영: client-shell tsconfig jsx:react-jsx + @types/react(-dom),
  bff-kit/auth-adapter += @types/node. 7개 패키지 전부 tsc --noEmit 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 13:12:34 +09:00
parent e34a325b52
commit 362e3168a7
37 changed files with 2583 additions and 14 deletions

View File

@ -31,5 +31,9 @@
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}

View File

@ -0,0 +1,301 @@
"use client";
// @yakcloud/client-shell — 9종 데이터소스 공용 셸 크롬(메인 오케스트레이터).
// 헤더(컨텍스트/뒤로/상태) + 고정 세그먼트 탭(.sheet-seg) + 2-pane(독립 스크롤) + 로딩/미준비 가드.
// v1 범위 = 셸 크롬 + 데모. FileBrowser 는 Phase 1(미포함). 소스별 Data 패널은 SourcePanelRegistry 로 주입.
// vh 금지(px/rem, 900/620). 패널 전환 애니메이션은 usePanelSheets(280ms) 보존.
// ui/api-client 런타임, ds-sdk type-only. ⛔ 서버/prisma/next 금지.
import { useMemo, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { SheetHelpButton } from "@yakcloud/ui";
import type { ServiceDTO, ServiceTypeDTO } from "@yakcloud/ds-sdk";
import { ServiceClientProvider, useServiceClientContext } from "./context/ServiceClientContext";
import { ServiceClientHeader } from "./components/ServiceClientHeader";
import type { TabDef } from "./components/TabNavigation";
import { TwoPaneLayout, LeftPane, MainPane } from "./layout/TwoPaneLayout";
import { LoadingGuard } from "./states/LoadingGuard";
import { useServiceClient } from "./hooks/useServiceClient";
import type { LoadingGuardState } from "./hooks/useServiceClient";
import { OverviewPanel } from "./panels/OverviewPanel";
import { BackupPanel } from "./panels/BackupPanel";
import type { BackupEntry } from "./panels/BackupPanel";
import { PlaceholderPanel } from "./panels/PlaceholderPanel";
import { ConnectionHelpSheet } from "./sheets/ConnectionHelpSheet";
import { resolvePanel } from "./panels";
import type { SourcePanelRegistry } from "./panels";
// 셸 컨테이너 고정 높이 캡(px — vh 금지). 데스크톱 900, 컴팩트 620 참조값.
const SHELL_HEIGHT_MAX = 900;
const SHELL_HEIGHT_COMPACT = 620;
export interface ServiceClientShellProps {
serviceId: string;
// 폴링 경로(예: `/services/${serviceId}`). api-client apiGet 기준(BASE 상대).
servicePath: string;
clusterId: string;
// 앱 바인딩 별칭(env prefix 도출용). 없으면 서비스명 기반 기본 처리.
alias?: string;
// 소스별 Data/추가 패널 레지스트리(앱 주입). 미지정 시 Data 탭은 PlaceholderPanel.
registry?: SourcePanelRegistry;
logoBase?: string;
onBack?: () => void;
// 백업 데이터/핸들러(UI 전용 — 실제 job 은 yakcloud-api). 미지정 시 표시만.
backups?: BackupEntry[];
backupProgressPct?: number | null;
onBackupNow?: () => void;
onRestore?: (backupId: string) => void;
backupLogLines?: string[];
// 좌 사이드바 콘텐츠(선택) — 앱이 소스 트리/네비를 채움. 미지정 시 최소 요약.
sidebar?: ReactNode;
// 셸 컨테이너 높이(px). 기본 SHELL_HEIGHT_MAX.
heightPx?: number;
compact?: boolean;
docsHref?: string;
}
// v1 기본 탭. 'data' 는 registry 에 소스별 패널이 있으면 그 컴포넌트, 없으면 PlaceholderPanel.
function buildTabs(): TabDef[] {
return [
{ key: "overview", label: "개요" },
{ key: "data", label: "데이터" },
{ key: "backup", label: "백업" },
];
}
export function ServiceClientShell(props: ServiceClientShellProps) {
const { servicePath, compact } = props;
const client = useServiceClient({ path: servicePath });
const heightPx = props.heightPx ?? (compact ? SHELL_HEIGHT_COMPACT : SHELL_HEIGHT_MAX);
const containerStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
height: `${heightPx}px`,
minHeight: 0,
background: "var(--surface-2, #f6f7f9)",
borderRadius: 12,
overflow: "hidden",
border: "1px solid var(--line, rgba(0,0,0,.08))",
};
// 서비스 타입 미확정(로딩/오류/미존재) — 헤더 없이 가드만(컨텍스트는 타입 필수라 준비 후 제공).
if (!client.service || !client.sourceType) {
return (
<div className="cs-shell cs-shell-guard" style={containerStyle}>
<LoadingGuard
state={client.status === "ready" ? "loading" : client.status}
serviceStatus={client.serviceStatus}
error={client.error}
coldStartHint={client.coldStartHint}
onRetry={client.retry}
>
{null}
</LoadingGuard>
</div>
);
}
return (
<div className="cs-shell" style={containerStyle}>
<ShellReady
props={props}
service={client.service}
sourceType={client.sourceType}
clusterName={client.clusterName}
guardState={client.status}
onRetry={client.retry}
coldStartHint={client.coldStartHint}
error={client.error}
/>
</div>
);
}
// 서비스 로드 완료 후의 셸 — 컨텍스트 프로바이더로 감싸고 내부 크롬 렌더.
function ShellReady({
props,
service,
sourceType,
clusterName,
guardState,
onRetry,
coldStartHint,
error,
}: {
props: ServiceClientShellProps;
service: ServiceDTO;
sourceType: ServiceTypeDTO;
clusterName: string;
guardState: LoadingGuardState;
onRetry: () => void;
coldStartHint: string | null;
error: string | null;
}) {
const [activeTab, setActiveTab] = useState<string>("overview");
const tabs = useMemo(() => buildTabs(), []);
const effectiveAlias = props.alias ?? service.name;
return (
<ServiceClientProvider
value={{
serviceId: props.serviceId,
name: service.name,
sourceType,
clusterId: props.clusterId,
clusterName,
status: service.status,
guardState,
activeTab,
setActiveTab,
tabs: tabs.map((t) => t.key),
scrollContainer: null,
logoBase: props.logoBase ?? "/logos",
}}
>
<ShellInner
props={props}
service={service}
sourceType={sourceType}
alias={effectiveAlias}
tabs={tabs}
guardState={guardState}
onRetry={onRetry}
coldStartHint={coldStartHint}
error={error}
/>
</ServiceClientProvider>
);
}
// 컨텍스트 내부 — 시트 컨트롤·활성 탭에 접근하는 실제 크롬.
function ShellInner({
props,
service,
sourceType,
alias,
tabs,
guardState,
onRetry,
coldStartHint,
error,
}: {
props: ServiceClientShellProps;
service: ServiceDTO;
sourceType: ServiceTypeDTO;
alias: string;
tabs: TabDef[];
guardState: LoadingGuardState;
onRetry: () => void;
coldStartHint: string | null;
error: string | null;
}) {
const ctx = useServiceClientContext();
const { registry = {}, logoBase = "/logos", onBack, docsHref, sidebar } = props;
const HELP_SHEET = "connection-help";
const helpOpen = ctx.sheets.current?.id === "shell" && ctx.sheets.current.mode === HELP_SHEET;
const openHelp = () => ctx.sheets.open("shell", HELP_SHEET);
// registry 의 소스별 Data 패널 — 없으면 PlaceholderPanel(FileBrowser Phase 1 defer).
const dataSlot = resolvePanel(registry, sourceType, "data");
let panel: ReactNode;
if (ctx.activeTab === "overview") {
panel = (
<OverviewPanel
service={service}
alias={alias}
logoBase={logoBase}
onOpenConnectionHelp={openHelp}
/>
);
} else if (ctx.activeTab === "backup") {
panel = (
<BackupPanel
sourceType={sourceType}
backups={props.backups}
progressPct={props.backupProgressPct}
onBackupNow={props.onBackupNow}
onRestore={props.onRestore}
logLines={props.backupLogLines}
/>
);
} else if (ctx.activeTab === "data") {
panel = dataSlot ? (
<dataSlot.Component
serviceId={ctx.serviceId}
sourceType={sourceType}
clusterId={ctx.clusterId}
clusterName={ctx.clusterName}
/>
) : (
<PlaceholderPanel sourceType={sourceType} docsHref={docsHref} onOpenConnectionHelp={openHelp} />
);
} else {
// 앱이 등록한 커스텀 탭 슬롯.
const custom = resolvePanel(registry, sourceType, ctx.activeTab);
panel = custom ? (
<custom.Component
serviceId={ctx.serviceId}
sourceType={sourceType}
clusterId={ctx.clusterId}
clusterName={ctx.clusterName}
/>
) : null;
}
return (
<>
<ServiceClientHeader
tabs={tabs}
onBack={onBack}
actions={<SheetHelpButton onOpen={openHelp} label="연결 방법" plain />}
/>
<TwoPaneLayout
sidebar={<LeftPane>{sidebar ?? <DefaultSidebar />}</LeftPane>}
main={
// sheet-host: position:relative 컨텍스트 — CardSheet 가 헤더↔본문 사이로 슬라이드(280ms).
// usePanelSheets(ctx.sheets) 가 열림/토글/전환을 오케스트레이션(같은 것 재클릭=닫기).
<MainPane className="sheet-host">
<div className="cs-panel-scroll">
<LoadingGuard
state={guardState}
serviceStatus={ctx.status}
error={error}
coldStartHint={coldStartHint}
onRetry={onRetry}
>
{panel}
</LoadingGuard>
</div>
{/* 연결 도움말 시트(자체 CardSheet). ctx.sheets 상태로 열림 제어(280ms 전환·Esc 닫기). */}
<ConnectionHelpSheet
open={helpOpen}
onClose={ctx.sheets.close}
sourceType={sourceType}
alias={alias}
scrollContainer={ctx.scrollContainer}
/>
</MainPane>
}
/>
</>
);
}
// 좌 사이드바 기본 콘텐츠 — 앱이 sidebar prop 을 안 주면 최소 컨텍스트 요약.
function DefaultSidebar() {
const ctx = useServiceClientContext();
return (
<nav className="cs-sidebar" aria-label="소스 컨텍스트">
<div className="cs-sidebar-item">
<span className="cs-muted cs-small"></span>
<b>{ctx.clusterName || "—"}</b>
</div>
<div className="cs-sidebar-item">
<span className="cs-muted cs-small"></span>
<b>{ctx.name}</b>
</div>
</nav>
);
}

View File

@ -0,0 +1,70 @@
"use client";
// @yakcloud/client-shell — 고정 헤더(스크롤 없음).
// 로고/인스턴스명 배지 + 상태 배지(serviceStatusMeta) + 소스 타입/버전 + 클러스터 컨텍스트 칩 + 뒤로.
// getServiceMeta(type, logoBase) 로 브랜딩. 긴 인스턴스명 responsive truncation.
// ui(Badge, serviceStatusMeta, getServiceMeta, icons) 런타임 소비, ds-sdk type-only.
import type { CSSProperties, ReactNode } from "react";
import { Badge, serviceStatusMeta, getServiceMeta, ChevronLeft, K8s } from "@yakcloud/ui";
import type { TabDef } from "./TabNavigation";
import { TabNavigation } from "./TabNavigation";
import { useServiceClientContext } from "../context/ServiceClientContext";
const truncate: CSSProperties = {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 280,
};
export function ServiceClientHeader({
tabs,
onBack,
actions,
}: {
tabs: readonly TabDef[];
onBack?: () => void;
actions?: ReactNode; // 헤더 우측 슬롯(도움말 버튼 등)
}) {
const { name, sourceType, clusterName, status, logoBase } = useServiceClientContext();
const meta = getServiceMeta(sourceType, logoBase);
const st = status ? serviceStatusMeta(status) : null;
return (
<header className="cs-header">
<div className="cs-header-top">
{onBack && (
<button
type="button"
className="btn-icon cs-back"
onClick={onBack}
aria-label="뒤로"
title="뒤로"
>
<ChevronLeft size={18} />
</button>
)}
<img className="cs-logo" src={meta.logo} alt="" width={22} height={22} aria-hidden />
<span className="cs-instance-name" style={truncate} title={name}>
{name}
</span>
<span className="cs-source-type">{meta.label}</span>
{st && <Badge kind={st.kind}>{st.label}</Badge>}
{/* 클러스터 컨텍스트 칩 — 사용자가 어느 클러스터의 인스턴스인지 반드시 인지(설계 §4.1). */}
<span className="cs-cluster-chip" title={`클러스터: ${clusterName}`}>
<K8s size={13} />
<span style={truncate}>{clusterName || "—"}</span>
</span>
{actions && <span className="cs-header-actions">{actions}</span>}
</div>
<TabNavigation tabs={tabs} />
</header>
);
}

View File

@ -0,0 +1,96 @@
"use client";
// @yakcloud/client-shell — 세그먼트 탭 바(.sheet-seg 관례).
// 등록된 탭 렌더 + activeTab 상태(ServiceClientContext) 관리 + 키보드(방향키·Enter/Space).
// ARIA: tablist/tab, aria-selected, roving tabindex.
// ⛔ 서버/prisma/next 금지.
import { useCallback, useRef } from "react";
import type { KeyboardEvent } from "react";
import { useServiceClientContext } from "../context/ServiceClientContext";
export interface TabDef {
key: string;
label: string;
// v1 에서 소스별로 특정 탭 숨김 지원(예: 'Data' 미지원 소스).
hidden?: boolean;
}
export function TabNavigation({ tabs }: { tabs: readonly TabDef[] }) {
const { activeTab, setActiveTab } = useServiceClientContext();
const visible = tabs.filter((t) => !t.hidden);
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const focusTab = useCallback((key: string) => {
const el = btnRefs.current[key];
if (el) el.focus();
}, []);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
const count = visible.length;
if (count === 0) return;
let nextIdx: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
nextIdx = (idx + 1) % count;
break;
case "ArrowLeft":
case "ArrowUp":
nextIdx = (idx - 1 + count) % count;
break;
case "Home":
nextIdx = 0;
break;
case "End":
nextIdx = count - 1;
break;
case "Enter":
case " ": {
const cur = visible[idx];
if (cur) setActiveTab(cur.key);
e.preventDefault();
return;
}
default:
return;
}
if (nextIdx !== null) {
e.preventDefault();
const target = visible[nextIdx];
if (target) {
setActiveTab(target.key);
focusTab(target.key);
}
}
},
[visible, setActiveTab, focusTab],
);
return (
<div className="sheet-seg cs-tabnav" role="tablist" aria-label="데이터 소스 클라이언트 탭">
{visible.map((t, i) => {
const on = t.key === activeTab;
return (
<button
key={t.key}
ref={(el) => {
btnRefs.current[t.key] = el;
}}
type="button"
role="tab"
id={`cs-tab-${t.key}`}
aria-selected={on}
aria-controls={`cs-tabpanel-${t.key}`}
tabIndex={on ? 0 : -1}
className={on ? "on" : undefined}
onClick={() => setActiveTab(t.key)}
onKeyDown={(e) => onKeyDown(e, i)}
>
{t.label}
</button>
);
})}
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
// @yakcloud/client-shell — 서비스 클라이언트 셸 컨텍스트.
// 서비스 메타(id/type/cluster/status) + 탭 상태(activeTab) + 패널 시트 컨트롤(usePanelSheets) 공유.
// ⛔ DOM 가정 금지(main.main querySelector 폴백 없음) — scrollContainer 는 명시 주입만.
// ds-sdk 는 type-only, ui(usePanelSheets)는 런타임 소비.
import { createContext, useContext } from "react";
import type { ReactNode } from "react";
import { usePanelSheets } from "@yakcloud/ui";
import type { PanelSheetsCtl } from "@yakcloud/ui";
import type { ServiceStatusDTO, ServiceTypeDTO } from "@yakcloud/ds-sdk";
import type { LoadingGuardState } from "../hooks/useServiceClient";
export interface ServiceClientContextValue {
// 서비스 메타
serviceId: string;
name: string;
sourceType: ServiceTypeDTO;
clusterId: string;
clusterName: string;
status: ServiceStatusDTO | undefined;
guardState: LoadingGuardState;
// 탭 상태
activeTab: string;
setActiveTab: (tab: string) => void;
tabs: readonly string[];
// 패널 시트 컨트롤(헤더↔본문 슬라이드 시트)
sheets: PanelSheetsCtl;
// 스크롤 잠금 대상 컨테이너(선택) — 시트 열림 동안 잠금. 미지정 시 no-op.
scrollContainer: HTMLElement | null;
// 로고 URL 조합 베이스(getServiceMeta logoBase).
logoBase: string;
}
const Ctx = createContext<ServiceClientContextValue | null>(null);
export function ServiceClientProvider({
value,
children,
}: {
value: Omit<ServiceClientContextValue, "sheets">;
children: ReactNode;
}) {
const sheets = usePanelSheets();
return <Ctx.Provider value={{ ...value, sheets }}>{children}</Ctx.Provider>;
}
export function useServiceClientContext(): ServiceClientContextValue {
const v = useContext(Ctx);
if (!v) {
throw new Error(
"useServiceClientContext must be used within <ServiceClientProvider> (ServiceClientShell).",
);
}
return v;
}

View File

@ -0,0 +1,99 @@
"use client";
// @yakcloud/client-shell — 서비스 로딩/준비 상태 훅.
// READY 폴링 + LoadingGuard 상태 산출 + scale-to-zero 콜드스타트(첫 요청 지연) 배경 메시지.
// api-client(usePoll) 런타임 소비, ds-sdk 는 type-only.
// ⛔ next/prisma/서버 금지. scale-to-zero 오케스트레이션은 yakcloud-api 책임(여기는 상태 표시만).
import { useCallback, useMemo, useRef } from "react";
import { usePoll } from "@yakcloud/api-client";
import type { ServiceDTO, ServiceStatusDTO, ServiceTypeDTO } from "@yakcloud/ds-sdk";
// LoadingGuard 가 렌더 분기에 쓰는 4상태 유니온.
export type LoadingGuardState = "ready" | "loading" | "error" | "not-found";
export interface UseServiceClientOptions {
// 폴링 주소(예: `/services/${id}`). null 이면 폴링 비활성.
path: string | null;
// 준비 폴링 간격(ms). READY 도달 후에도 상태 변화를 잡기 위해 저빈도 유지.
pollMs?: number;
// 콜드스타트(scale-to-zero) 안내를 시작하기까지의 로딩 지속 임계(ms).
coldStartHintMs?: number;
}
export interface UseServiceClientResult {
status: LoadingGuardState;
serviceStatus: ServiceStatusDTO | undefined;
service: ServiceDTO | undefined;
isReady: boolean;
clusterName: string;
sourceType: ServiceTypeDTO | undefined;
error: string | null;
// 콜드스타트 배경 안내 문구(예: scale-to-zero 웨이크업). 없으면 null.
coldStartHint: string | null;
retry: () => void;
}
const DEFAULT_POLL_MS = 4000;
// 서비스 status → LoadingGuard 상태 매핑.
function toGuardState(s: ServiceStatusDTO): LoadingGuardState {
switch (s) {
case "READY":
return "ready";
case "ERROR":
return "error";
case "DELETED":
return "not-found";
default:
// REQUESTED / PROVISIONING / DELETING → 아직 클라이언트 사용 불가.
return "loading";
}
}
export function useServiceClient(opts: UseServiceClientOptions): UseServiceClientResult {
const { path, pollMs = DEFAULT_POLL_MS } = opts;
const poll = usePoll<ServiceDTO>(path, pollMs);
const { data, error, loading, refresh } = poll;
// 로딩 시작 시각 — 콜드스타트 안내 임계 비교용(마운트/경로 변화에 안정적으로 갱신).
const loadStartRef = useRef<number>(Date.now());
if (!loading && data) loadStartRef.current = Date.now();
const status: LoadingGuardState = useMemo(() => {
if (error) {
// 404 계열은 not-found, 그 외는 error.
return error.status === 404 ? "not-found" : "error";
}
if (loading || !data) return "loading";
return toGuardState(data.status);
}, [error, loading, data]);
const coldStartHint = useMemo<string | null>(() => {
if (status !== "loading") return null;
// PROVISIONING/REQUESTED 는 프로비저닝 안내, 그 외 로딩 지속은 웨이크업 안내.
const svcStatus = data?.status;
if (svcStatus === "PROVISIONING" || svcStatus === "REQUESTED") {
return "인스턴스를 준비하고 있습니다. 잠시만 기다려 주세요…";
}
return "유휴 상태에서 깨우는 중입니다(최초 요청은 몇 초 걸릴 수 있어요)…";
}, [status, data?.status]);
const retry = useCallback(() => {
loadStartRef.current = Date.now();
refresh();
}, [refresh]);
const errMsg = error ? error.message : status === "error" && data ? data.statusMessage : null;
return {
status,
serviceStatus: data?.status,
service: data,
isReady: status === "ready",
clusterName: data?.clusterName ?? "",
sourceType: data?.type,
error: errMsg ?? null,
coldStartHint,
retry,
};
}

View File

@ -1,4 +1,43 @@
// @yakcloud/client-shell
// 9종 데이터소스 웹 클라 셸(신규): 헤더/탭/pane/가드. FileBrowser 는 Phase 1.
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조).
export const __package = "@yakcloud/client-shell";
// @yakcloud/client-shell — 공개 배럴.
// 9종 데이터소스 웹 클라 '셸 크롬': 헤더(클러스터/소스 컨텍스트+뒤로) + 세그먼트 탭(.sheet-seg) +
// 2-pane 레이아웃 + 로딩/미준비 가드. 소스별 Data 패널은 SourcePanelRegistry 로 앱이 주입.
// 내부 전용 컴포넌트(ConnectionHelpSheet/PlaceholderPanel)는 재export 하지 않는다 — 앱은 registry 로 등록.
// react peer. ui/api-client 런타임 소비, ds-sdk type-only. ⛔ 서버/prisma/next 금지.
// ── 메인 셸 ──
export { ServiceClientShell } from "./ServiceClientShell";
export type { ServiceClientShellProps } from "./ServiceClientShell";
// ── 컨텍스트 ──
export { ServiceClientProvider, useServiceClientContext } from "./context/ServiceClientContext";
export type { ServiceClientContextValue } from "./context/ServiceClientContext";
// ── 상태 훅 + LoadingGuard 유니온 ──
export { useServiceClient } from "./hooks/useServiceClient";
export type {
LoadingGuardState,
UseServiceClientOptions,
UseServiceClientResult,
} from "./hooks/useServiceClient";
export { LoadingGuard } from "./states/LoadingGuard";
// ── 레이아웃 프리미티브(앱 사이드바 구성용) ──
export { TwoPaneLayout, LeftPane, MainPane } from "./layout/TwoPaneLayout";
export { TabNavigation } from "./components/TabNavigation";
export type { TabDef } from "./components/TabNavigation";
export { ServiceClientHeader } from "./components/ServiceClientHeader";
// ── 패널 레지스트리 타입 + 헬퍼(앱이 소스별 Data 패널 등록) ──
export type {
PanelSlot,
PanelSlotName,
PanelComponentProps,
SourcePanelRegistry,
} from "./panels";
export { registerPanel, resolvePanel } from "./panels";
// ── 백업 패널 데이터 타입(앱이 백업 목록/로그 주입) ──
export type { BackupEntry } from "./panels/BackupPanel";
// ── 순수 포매터(앱 재사용) ──
export { fmtDate, fmtRelative, fmtBytes } from "./utils/formatters";

View File

@ -0,0 +1,70 @@
"use client";
// @yakcloud/client-shell — 2-pane 레이아웃(좌 사이드바 + 우 메인, 각각 독립 스크롤).
// vh 금지: 높이는 100%/px 만. 좁은 뷰포트(<900px)는 세로 스택(부모 컨테이너 클래스로 제어).
// ⛔ 서버/prisma/next 금지. 순수 프레젠테이션.
import type { CSSProperties, ReactNode } from "react";
const MIN_SIDEBAR = 200;
const MAX_SIDEBAR = 600;
const DEFAULT_SIDEBAR = 300;
export function TwoPaneLayout({
sidebar,
main,
sidebarWidth = DEFAULT_SIDEBAR,
}: {
sidebar: ReactNode;
main: ReactNode;
sidebarWidth?: number;
}) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, sidebarWidth));
const gridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: `${w}px minmax(0, 1fr)`,
minHeight: 0,
height: "100%",
};
return (
<div className="cs-two-pane" style={gridStyle}>
{sidebar}
{main}
</div>
);
}
// 좌 사이드바 — 독립 스크롤(overflow:auto). vh 미사용.
export function LeftPane({ children }: { children: ReactNode }) {
const style: CSSProperties = {
overflow: "auto",
minHeight: 0,
height: "100%",
borderRight: "1px solid var(--line, rgba(0,0,0,.08))",
background: "var(--surface-1, #fff)",
};
return (
<aside className="cs-left-pane" style={style}>
{children}
</aside>
);
}
// 우 메인 — 독립 스크롤(overflow:auto). sheet-host 관례(부모 relative)와 함께 쓰이도록 클래스 노출.
export function MainPane({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const style: CSSProperties = {
overflow: "auto",
minHeight: 0,
height: "100%",
};
return (
<main className={`cs-main-pane${className ? " " + className : ""}`} style={style}>
{children}
</main>
);
}

View File

@ -0,0 +1,196 @@
"use client";
// @yakcloud/client-shell — 백업 탭(UI 전용; 실제 job/restore 로직은 backup-core·yakcloud-api).
// 3 세그먼트: BackupNow(온디맨드) · Schedule(cron 스텁) · Logs(ds-log tail).
// Gauge=진행률, 백업 목록+마지막 실행, restore=type-to-confirm 게이트(쓰기/삭제는 명시 토글 후).
// restore 엔드포인트는 M3 스텁(501). 소스별 백업 한계 경고 배지(RabbitMQ/Redis).
// role=backup egress allowlist 설명 표시. ui(Badge/Gauge/Timeline/icons) 런타임, ds-sdk type-only.
import { useState } from "react";
import { Badge, Gauge, Timeline, Warn } from "@yakcloud/ui";
import type { TimelineItem } from "@yakcloud/ui";
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
import { fmtDate, fmtBytes, fmtRelative } from "../utils/formatters";
type Segment = "now" | "schedule" | "logs";
export interface BackupEntry {
id: string;
ts: string; // ISO
sizeBytes?: number;
status: "done" | "running" | "failed";
}
// 소스별 알려진 백업 한계 — 경고 배지 문구(설계 요구).
const BACKUP_WARNINGS: Partial<Record<ServiceTypeDTO, string>> = {
RABBITMQ: "RabbitMQ 는 노드 간 복제 검증이 없습니다. 백업은 정의/메시지 스냅샷 기준입니다.",
REDIS: "Redis 는 scale-to-zero 시 RDB 저장을 보장하지 않습니다. 최신 쓰기가 누락될 수 있습니다.",
};
export function BackupPanel({
sourceType,
backups = [],
progressPct,
onBackupNow,
onRestore,
logLines = [],
}: {
sourceType: ServiceTypeDTO;
backups?: BackupEntry[];
progressPct?: number | null; // 진행 중 백업 진행률(0-100). null/undefined 면 미표시.
onBackupNow?: () => void;
// restore 는 M3 스텁(501). 게이트 통과 시에만 호출.
onRestore?: (backupId: string) => void;
logLines?: string[];
}) {
const [seg, setSeg] = useState<Segment>("now");
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
const [confirmText, setConfirmText] = useState("");
const warning = BACKUP_WARNINGS[sourceType];
const CONFIRM_WORD = "RESTORE";
const timelineItems: TimelineItem[] = backups.map((b) => ({
title:
b.status === "failed"
? "백업 실패"
: b.status === "running"
? "백업 진행 중"
: `백업 완료${b.sizeBytes !== undefined ? ` · ${fmtBytes(b.sizeBytes)}` : ""}`,
ts: `${fmtDate(b.ts)} (${fmtRelative(b.ts)})`,
state: b.status === "running" ? "now" : b.status === "failed" ? "todo" : "done",
}));
return (
<div className="cs-backup" id="cs-tabpanel-backup" role="tabpanel" aria-labelledby="cs-tab-backup">
{warning && (
<div className="cs-warn-banner" role="note">
<Warn size={16} />
<span>
<Badge kind="pending"></Badge> {warning}
</span>
</div>
)}
<div className="sheet-seg cs-backup-seg" role="tablist" aria-label="백업 세그먼트">
{(["now", "schedule", "logs"] as const).map((s) => (
<button
key={s}
type="button"
role="tab"
aria-selected={seg === s}
className={seg === s ? "on" : undefined}
onClick={() => setSeg(s)}
>
{s === "now" ? "지금 백업" : s === "schedule" ? "스케줄" : "로그"}
</button>
))}
</div>
{seg === "now" && (
<section className="cs-card">
{progressPct !== null && progressPct !== undefined && (
<div className="cs-progress">
<span className="cs-small cs-muted"> {Math.round(progressPct)}%</span>
<Gauge pct={Math.max(0, Math.min(100, progressPct))} />
</div>
)}
<button type="button" className="btn primary" onClick={onBackupNow} disabled={!onBackupNow}>
</button>
<p className="cs-small cs-muted">
role=backup egress allowlist (
).
</p>
<h4> </h4>
{backups.length === 0 ? (
<p className="cs-muted"> .</p>
) : (
<>
<Timeline items={timelineItems} />
<ul className="cs-backup-list">
{backups.map((b) => (
<li key={b.id}>
<span>{fmtDate(b.ts)}</span>
<span className="cs-muted">{fmtBytes(b.sizeBytes)}</span>
<button
type="button"
className="btn danger"
onClick={() => {
setRestoreTarget(b.id);
setConfirmText("");
}}
disabled={b.status !== "done"}
>
</button>
</li>
))}
</ul>
</>
)}
{/* restore type-to-confirm 게이트 — 쓰기/삭제 액션은 명시 확인 후에만. onRestore 는 M3 스텁(501). */}
{restoreTarget && (
<div className="cs-restore-gate" role="alertdialog" aria-label="복원 확인">
<p>
<Warn size={16} /> . {" "}
<code>{CONFIRM_WORD}</code> .
</p>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
aria-label={`확인 문구 ${CONFIRM_WORD} 입력`}
placeholder={CONFIRM_WORD}
/>
<div className="cs-restore-actions">
<button
type="button"
className="btn danger"
disabled={confirmText !== CONFIRM_WORD || !onRestore}
onClick={() => {
if (onRestore && restoreTarget) onRestore(restoreTarget);
setRestoreTarget(null);
setConfirmText("");
}}
>
</button>
<button type="button" className="btn" onClick={() => setRestoreTarget(null)}>
</button>
</div>
</div>
)}
</section>
)}
{seg === "schedule" && (
<section className="cs-card">
<h4> </h4>
<p className="cs-muted">
cron UI . (yakcloud-api)
.
</p>
<input type="text" placeholder="0 3 * * *" aria-label="cron 표현식" disabled />
</section>
)}
{seg === "logs" && (
<section className="cs-card">
<h4> </h4>
{logLines.length === 0 ? (
<p className="cs-muted"> .</p>
) : (
<pre className="cs-log-tail">
{logLines.map((line, i) => (
<div key={i}>{line}</div>
))}
</pre>
)}
</section>
)}
</div>
);
}

View File

@ -0,0 +1,121 @@
"use client";
// @yakcloud/client-shell — 개요 탭(읽기 전용).
// 인스턴스 메타(name/type/version/status) + 접속 메타(host/port/db, 비밀 제외) + <ALIAS>_* env 힌트 +
// 연결 도움말 시트 토글. ⛔ connSecretRef·자격증명 절대 미노출 — connInfo(host/port/db/bucket)만.
// ui(Badge, serviceStatusMeta, getServiceMeta, SheetHelpButton) 런타임, ds-sdk(BIND_ENV_VARS) 값 import.
import {
Badge,
serviceStatusMeta,
getServiceMeta,
SheetHelpButton,
} from "@yakcloud/ui";
import { BIND_ENV_VARS, bindEnvPrefix } from "@yakcloud/ds-sdk";
import type { ServiceDTO } from "@yakcloud/ds-sdk";
import { fmtDate } from "../utils/formatters";
export function OverviewPanel({
service,
alias,
logoBase,
onOpenConnectionHelp,
}: {
service: ServiceDTO;
alias: string;
logoBase?: string;
onOpenConnectionHelp?: () => void;
}) {
const meta = getServiceMeta(service.type, logoBase);
const st = serviceStatusMeta(service.status);
const conn = service.connInfo;
const prefix = bindEnvPrefix(alias);
const envHints = BIND_ENV_VARS[service.type];
return (
<div
className="cs-overview"
id="cs-tabpanel-overview"
role="tabpanel"
aria-labelledby="cs-tab-overview"
>
<section className="cs-card">
<div className="cs-card-head">
<h3></h3>
{onOpenConnectionHelp && <SheetHelpButton onOpen={onOpenConnectionHelp} label="연결 방법" />}
</div>
<dl className="cs-meta-grid">
<dt></dt>
<dd>{service.name}</dd>
<dt></dt>
<dd>{meta.label}</dd>
<dt></dt>
<dd>{service.mode === "LOCAL" ? "관리형(클러스터 내)" : "외부 연결"}</dd>
<dt></dt>
<dd>
<Badge kind={st.kind}>{st.label}</Badge>
</dd>
<dt></dt>
<dd>{service.size}</dd>
<dt></dt>
<dd>{service.persist ? `${service.storageGb} GB` : "비영속"}</dd>
<dt></dt>
<dd>{fmtDate(service.createdAt)}</dd>
</dl>
</section>
<section className="cs-card">
<h3> </h3>
{conn ? (
<dl className="cs-meta-grid">
{conn.host !== undefined && (
<>
<dt></dt>
<dd>{conn.host}</dd>
</>
)}
{conn.port !== undefined && (
<>
<dt></dt>
<dd>{conn.port}</dd>
</>
)}
{conn.db !== undefined && (
<>
<dt>DB</dt>
<dd>{String(conn.db)}</dd>
</>
)}
{conn.bucket !== undefined && (
<>
<dt></dt>
<dd>{conn.bucket}</dd>
</>
)}
</dl>
) : (
<p className="cs-muted"> .</p>
)}
<p className="cs-muted cs-small">
(·) . .
</p>
</section>
<section className="cs-card">
<h3>
<span className="cs-muted cs-small">(prefix: {prefix})</span>
</h3>
<ul className="cs-env-hints">
{envHints.map((v) => (
<li key={v.suffix}>
<code>
{prefix}_{v.suffix}
</code>
<span className="cs-env-desc">{v.desc}</span>
<span className="cs-env-example">: {v.example || "(빈값)"}</span>
</li>
))}
</ul>
</section>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
// @yakcloud/client-shell — Data 탭 스텁(Phase 1 대기, FileBrowser defer).
// "데이터 브라우저는 Phase 1 에 제공됩니다 — 이 소스는 연결되어 있고 백업이 활성입니다."
// 소스별 메시지 조정(sourceType) + 오프라인 문서 링크(동적 fetch 없음). 비차단.
// ui(EmptyState, getServiceMeta, icons) 런타임 소비, ds-sdk type-only.
import { EmptyState, getServiceMeta, Database, Book } from "@yakcloud/ui";
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
export function PlaceholderPanel({
sourceType,
docsHref,
onOpenConnectionHelp,
}: {
sourceType: ServiceTypeDTO;
docsHref?: string; // 오프라인/정적 문서 링크(동적 fetch 아님)
onOpenConnectionHelp?: () => void;
}) {
const label = getServiceMeta(sourceType).label;
return (
<div className="cs-placeholder" id={`cs-tabpanel-data`} role="tabpanel" aria-labelledby="cs-tab-data">
<EmptyState
art={<Database size={40} />}
title={`${label} 데이터 브라우저는 Phase 1 에 제공됩니다`}
desc={
<>
<b> </b>. · .
</>
}
cta={
<div className="cs-placeholder-cta">
{onOpenConnectionHelp && (
<button type="button" className="btn" onClick={onOpenConnectionHelp}>
</button>
)}
{docsHref && (
<a className="btn" href={docsHref} target="_blank" rel="noreferrer">
<Book size={14} />
</a>
)}
</div>
}
/>
</div>
);
}

View File

@ -0,0 +1,48 @@
// @yakcloud/client-shell — 소스 타입별 패널 슬롯 레지스트리.
// 셸(ServiceClientShell)이 9종 소스를 하드코딩하지 않고, 앱이 SourcePanelRegistry 로 자신의
// Data/Backup 패널을 주입한다(v1 은 셸 크롬+데모; 소스별 Data 패널은 9개 앱 repo 책임).
// ⛔ 서버/prisma/next 금지. ds-sdk 는 type-only.
import type { ComponentType } from "react";
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
// 슬롯 이름 — 탭 네비게이션의 탭 키와 정렬. 'overview'/'backup' 은 셸 내장, 나머지는 앱 등록.
export type PanelSlotName = "overview" | "data" | "backup" | (string & {});
// 패널 컴포넌트에 셸이 주입하는 공통 props. 소스별 패널은 이 계약을 소비.
export interface PanelComponentProps {
serviceId: string;
sourceType: ServiceTypeDTO;
clusterId: string;
clusterName: string;
}
// 하나의 패널 슬롯 정의 — 소스 타입 × 슬롯 이름 × 컴포넌트(+표시 라벨).
export interface PanelSlot<P extends PanelComponentProps = PanelComponentProps> {
sourceType: ServiceTypeDTO;
slotName: PanelSlotName;
label: string;
Component: ComponentType<P>;
}
// 소스 타입 → 등록 패널 배열. 부분(Partial): 앱이 자기 타입만 채워도 됨.
export type SourcePanelRegistry = Partial<Record<ServiceTypeDTO, PanelSlot[]>>;
// 앱이 동적으로 패널을 추가하는 팩토리(불변 병합 — 입력 레지스트리는 변형하지 않음).
export function registerPanel(
registry: SourcePanelRegistry,
slot: PanelSlot,
): SourcePanelRegistry {
const existing = registry[slot.sourceType] ?? [];
// 같은 slotName 은 교체(중복 방지), 아니면 추가.
const next = existing.filter((s) => s.slotName !== slot.slotName).concat(slot);
return { ...registry, [slot.sourceType]: next };
}
// 특정 소스 타입 + 슬롯의 패널 조회(없으면 undefined).
export function resolvePanel(
registry: SourcePanelRegistry,
sourceType: ServiceTypeDTO,
slotName: PanelSlotName,
): PanelSlot | undefined {
return registry[sourceType]?.find((s) => s.slotName === slotName);
}

View File

@ -0,0 +1,76 @@
"use client";
// @yakcloud/client-shell — 연결 도움말 사이드 시트(usePanelSheets 로 열림).
// 자격증명 없이 접속 방법만: host/port(예시 placeholder), <ALIAS>_* env 키 이름, 로컬 Docker 사용 안내.
// ⛔ /api/internal/credentials 등 비밀 fetch 금지(그런 엔드포인트는 존재하면 안 됨). 값은 전부 placeholder.
// ui(CardSheet, CodeBlock, C) 런타임 소비, ds-sdk(BIND_ENV_VARS, bindEnvKeys) type-only 아님 — 상수/헬퍼는 값 import.
import { CardSheet, CodeBlock, C } from "@yakcloud/ui";
import { BIND_ENV_VARS, bindEnvKeys } from "@yakcloud/ds-sdk";
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
export function ConnectionHelpSheet({
open,
onClose,
sourceType,
alias,
scrollContainer,
}: {
open: boolean;
onClose: () => void;
sourceType: ServiceTypeDTO;
alias: string; // 앱 바인딩 별칭(예: "mydb") → PREFIX 도출
scrollContainer?: HTMLElement | null;
}) {
const vars = BIND_ENV_VARS[sourceType];
const keys = bindEnvKeys(alias, sourceType);
return (
<CardSheet
open={open}
onClose={onClose}
scrollContainer={scrollContainer}
title="연결 방법"
subtitle="자격증명은 노출되지 않습니다. 앱 파드에는 아래 환경변수가 자동 주입됩니다."
>
<div className="cs-help">
<h4> </h4>
<ul className="cs-env-list">
{vars.map((v, i) => {
const key = keys[i];
return (
<li key={v.suffix}>
<code>{key ?? `${alias.toUpperCase()}_${v.suffix}`}</code>
<span className="cs-env-desc">{v.desc}</span>
<span className="cs-env-example">
: <em>{v.example || "(빈값)"}</em>
</span>
</li>
);
})}
</ul>
<h4> Docker ( placeholder)</h4>
<CodeBlock>
<C># . .</C>
<br />
docker run \
<br />
{vars.slice(0, 4).map((v, i) => {
const key = keys[i] ?? `${alias.toUpperCase()}_${v.suffix}`;
return (
<span key={v.suffix}>
{" "}-e {key}=&lt;{v.suffix.toLowerCase()}&gt; \
<br />
</span>
);
})}
{" "}your-app:latest
</CodeBlock>
<p className="cs-help-note">
· , .
</p>
</div>
</CardSheet>
);
}

View File

@ -0,0 +1,59 @@
"use client";
// @yakcloud/client-shell — 로딩/오류/미존재 가드.
// useServiceClient() 상태 → 렌더 분기: loading→Skeleton+콜드스타트 안내(202/503 프레이밍),
// error→ErrorState(재시도), not-found→EmptyState. 로딩 중에도 상태 배지 노출(투명성).
// ui(Skeleton/ErrorState/EmptyState/Badge/serviceStatusMeta) 런타임 소비.
import type { ReactNode } from "react";
import { Skeleton, ErrorState, EmptyState, Badge, serviceStatusMeta } from "@yakcloud/ui";
import type { ServiceStatusDTO } from "@yakcloud/ds-sdk";
import type { LoadingGuardState } from "../hooks/useServiceClient";
export function LoadingGuard({
state,
serviceStatus,
error,
coldStartHint,
onRetry,
children,
}: {
state: LoadingGuardState;
serviceStatus?: ServiceStatusDTO;
error?: string | null;
coldStartHint?: string | null;
onRetry: () => void;
children: ReactNode;
}) {
if (state === "ready") return <>{children}</>;
if (state === "error") {
return <ErrorState message={error ?? "클라이언트를 불러오지 못했습니다."} onRetry={onRetry} />;
}
if (state === "not-found") {
return (
<EmptyState
title="인스턴스를 찾을 수 없습니다"
desc="삭제되었거나 접근 권한이 없는 데이터 소스입니다."
/>
);
}
// loading — 콜드스타트/프로비저닝 안내 + 상태 배지 + 스켈레톤.
const st = serviceStatus ? serviceStatusMeta(serviceStatus) : null;
return (
<div className="cs-loading" role="status" aria-live="polite">
<div className="cs-loading-head">
<span> </span>
{st && <Badge kind={st.kind}>{st.label}</Badge>}
</div>
{coldStartHint && <p className="cs-loading-hint">{coldStartHint}</p>}
<div className="cs-loading-skeletons">
<Skeleton h={20} w="40%" style={{ marginBottom: 10 }} />
<Skeleton h={14} style={{ marginBottom: 8 }} />
<Skeleton h={14} w="80%" style={{ marginBottom: 8 }} />
<Skeleton h={14} w="60%" />
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
// @yakcloud/client-shell — 순수 포매터(프레임워크·DOM 무관, SSR 안전).
// window/querySelector 미사용. OverviewPanel·BackupPanel 이 소비.
// ⛔ next/prisma/서버 금지. ds-sdk 는 이 파일에서 불필요(순수 값 변환).
// 로케일 기본값 — 소비자는 opts 로 override 가능.
const DEFAULT_DATE_OPTS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
};
// 입력을 밀리초 timestamp 로 정규화. null/빈값/파싱불가 → null.
function toMs(ts: string | number | Date | null | undefined): number | null {
if (ts === null || ts === undefined) return null;
if (ts instanceof Date) {
const t = ts.getTime();
return Number.isNaN(t) ? null : t;
}
if (typeof ts === "number") {
return Number.isFinite(ts) ? ts : null;
}
const parsed = Date.parse(ts);
return Number.isNaN(parsed) ? null : parsed;
}
/** ISO/epoch/Date → 로케일 날짜·시각 문자열. 무효값 → "—". */
export function fmtDate(
ts: string | number | Date | null | undefined,
opts?: Intl.DateTimeFormatOptions,
locale?: string,
): string {
const ms = toMs(ts);
if (ms === null) return "—";
return new Intl.DateTimeFormat(locale, opts ?? DEFAULT_DATE_OPTS).format(new Date(ms));
}
/** 상대 시간("3분 전" 등). Intl.RelativeTimeFormat 사용, SSR 안전(now 주입 가능). */
export function fmtRelative(
ts: string | number | Date | null | undefined,
nowMs: number = Date.now(),
locale?: string,
): string {
const ms = toMs(ts);
if (ms === null) return "—";
const diffSec = Math.round((ms - nowMs) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const abs = Math.abs(diffSec);
const units: { unit: Intl.RelativeTimeFormatUnit; sec: number }[] = [
{ unit: "year", sec: 31536000 },
{ unit: "month", sec: 2592000 },
{ unit: "day", sec: 86400 },
{ unit: "hour", sec: 3600 },
{ unit: "minute", sec: 60 },
{ unit: "second", sec: 1 },
];
for (const u of units) {
if (abs >= u.sec || u.unit === "second") {
return rtf.format(Math.round(diffSec / u.sec), u.unit);
}
}
return rtf.format(diffSec, "second");
}
/** 바이트 → 사람 읽는 크기(KB/MB/…). 음수·무효 → "—". */
export function fmtBytes(sizeBytes: number | null | undefined, fractionDigits = 1): string {
if (sizeBytes === null || sizeBytes === undefined || !Number.isFinite(sizeBytes) || sizeBytes < 0) {
return "—";
}
if (sizeBytes < 1) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
const exp = Math.min(Math.floor(Math.log(sizeBytes) / Math.log(1024)), units.length - 1);
const value = sizeBytes / Math.pow(1024, exp);
const unit = units[exp] ?? "B";
const rounded = exp === 0 ? String(Math.round(value)) : value.toFixed(fractionDigits);
return `${rounded} ${unit}`;
}

View File

@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"jsx": "react-jsx"
},
"include": [
"src"