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:
@ -10,6 +10,14 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./core": {
|
||||
"types": "./dist/core/index.d.ts",
|
||||
"import": "./dist/core/index.js"
|
||||
},
|
||||
"./dev-bypass": {
|
||||
"types": "./dist/dev-bypass/index.d.ts",
|
||||
"import": "./dist/dev-bypass/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@ -26,5 +34,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next-auth": ">=5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20"
|
||||
}
|
||||
}
|
||||
|
||||
66
packages/auth-adapter/src/core/helpers.ts
Normal file
66
packages/auth-adapter/src/core/helpers.ts
Normal file
@ -0,0 +1,66 @@
|
||||
// @yakcloud/auth-adapter — core/helpers
|
||||
// 순수 함수. ⛔ next-auth / next / prisma 정적 import 금지. ds-sdk 는 type-only.
|
||||
|
||||
import { CODE_STATUS } from "@yakcloud/ds-sdk";
|
||||
import type { ApiCode } from "@yakcloud/ds-sdk";
|
||||
import type { AuthAdapter, Session, User } from "./types";
|
||||
|
||||
/**
|
||||
* 인증/인가 실패를 나타내는 프레임워크 무관 에러.
|
||||
* bff-kit 의 respond.handle() 이 code → HTTP status 로 변환한다(ds-sdk CODE_STATUS 재사용).
|
||||
*/
|
||||
export class AuthError extends Error {
|
||||
readonly code: ApiCode;
|
||||
readonly status: number;
|
||||
constructor(code: ApiCode, message?: string) {
|
||||
super(message ?? code);
|
||||
this.name = "AuthError";
|
||||
this.code = code;
|
||||
this.status = CODE_STATUS[code];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에서 세션을 해석한다. 미인증이면 null.
|
||||
* 어댑터 구현(next-auth 등)은 DI 로 주입된다.
|
||||
*/
|
||||
export async function getSession<C, S extends Session>(
|
||||
req: Request,
|
||||
adapter: AuthAdapter<C, S>,
|
||||
ctx?: C,
|
||||
): Promise<S | null> {
|
||||
return adapter.resolveSession(req, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 → 포털 User 로 승격하는 리졸버.
|
||||
* ⚠️ auth-adapter 는 Repo 를 모른다: consuming app(yakconsole) 이
|
||||
* getSessionUser() (Repo 조회/JIT upsert)를 이 시그니처로 주입한다.
|
||||
*/
|
||||
export type UserResolver<S extends Session = Session> = (
|
||||
session: S,
|
||||
) => Promise<User | null>;
|
||||
|
||||
/**
|
||||
* 인증된 포털 User 를 반환하거나 throw.
|
||||
* - 세션 없음/User 없음 → UNAUTHORIZED
|
||||
* - status === "SUSPENDED" → FORBIDDEN
|
||||
*
|
||||
* @param req Fetch API Request.
|
||||
* @param adapter 세션 해석 어댑터(DI).
|
||||
* @param resolve 세션 → 포털 User 리졸버(DI; Repo 접근은 여기 안에서).
|
||||
* @param ctx 어댑터별 컨텍스트(선택).
|
||||
*/
|
||||
export async function requireUser<C, S extends Session>(
|
||||
req: Request,
|
||||
adapter: AuthAdapter<C, S>,
|
||||
resolve: UserResolver<S>,
|
||||
ctx?: C,
|
||||
): Promise<User> {
|
||||
const session = await getSession(req, adapter, ctx);
|
||||
if (!session) throw new AuthError("UNAUTHORIZED");
|
||||
const user = await resolve(session);
|
||||
if (!user) throw new AuthError("UNAUTHORIZED");
|
||||
if (user.status === "SUSPENDED") throw new AuthError("FORBIDDEN", "정지된 계정입니다.");
|
||||
return user;
|
||||
}
|
||||
5
packages/auth-adapter/src/core/index.ts
Normal file
5
packages/auth-adapter/src/core/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// @yakcloud/auth-adapter — core
|
||||
// 프레임워크 무관 계약 재export. 런타임 의존 0 (ds-sdk 는 type/const 리프).
|
||||
export type { AuthAdapter, Session, User, Role, UserStatus } from "./types";
|
||||
export { AuthError, getSession, requireUser } from "./helpers";
|
||||
export type { UserResolver } from "./helpers";
|
||||
59
packages/auth-adapter/src/core/types.ts
Normal file
59
packages/auth-adapter/src/core/types.ts
Normal file
@ -0,0 +1,59 @@
|
||||
// @yakcloud/auth-adapter — core/types
|
||||
// 프레임워크 무관 인증 계약. ⛔ next-auth / next / prisma 정적 import 금지.
|
||||
// 여기 있는 것은 순수 타입뿐이며 런타임 코드는 없다.
|
||||
|
||||
/** 사용자 역할. Keycloak realm_access.roles → 정규화. */
|
||||
export type Role = "USER" | "ADMIN";
|
||||
|
||||
/** 포털 사용자 계정 상태. */
|
||||
export type UserStatus = "ACTIVE" | "SUSPENDED";
|
||||
|
||||
/**
|
||||
* Session = IdP(JWT) 관점의 최소 세션 뷰.
|
||||
* 클라이언트에 노출되는 표면은 최소화한다: access_token/refresh_token 은 절대 포함하지 않는다.
|
||||
* (§4.4 kubeconfig 교환은 서버에서 getToken() 으로만 원 토큰을 읽는다.)
|
||||
*/
|
||||
export interface Session {
|
||||
/** Keycloak subject (sub) — Repo 사용자 조회 키. */
|
||||
readonly sub: string;
|
||||
readonly email?: string;
|
||||
readonly displayName?: string;
|
||||
readonly role: Role;
|
||||
/**
|
||||
* 서버 전용(선택). 클라이언트 세션에는 노출하지 않는다.
|
||||
* getToken() 을 통해서만 채워지는 백채널 액세스 토큰.
|
||||
*/
|
||||
readonly accessToken?: string;
|
||||
/** refresh 실패 등 오류 신호(예: "RefreshAccessTokenError"). */
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User = 포털(Repo) 사용자 모델의 프레임워크 무관 뷰.
|
||||
* ⚠️ Prisma 모델이 아니다 — 필요한 필드만 가진 구조적 인터페이스.
|
||||
* 실제 Repo 조회/upsert 는 consuming app(yakconsole) 에서 수행한다.
|
||||
*/
|
||||
export interface User {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly displayName: string;
|
||||
readonly role: Role;
|
||||
readonly status: UserStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthAdapter<C, S> = 교체 가능한 세션 해석기.
|
||||
* - C: 어댑터별 컨텍스트(예: 쿠키 저장소 핸들). 기본 unknown.
|
||||
* - S: 어댑터가 반환하는 세션 형태(기본 Session).
|
||||
*
|
||||
* bff-kit / middleware 는 이 인터페이스만 알고, 구체 구현(next-auth 등)은 주입(DI)받는다.
|
||||
* ⛔ 어떤 패키지도 auth-adapter 구현체를 정적 import 하지 않는다.
|
||||
*/
|
||||
export interface AuthAdapter<C = unknown, S extends Session = Session> {
|
||||
/**
|
||||
* 요청에서 세션을 해석한다. 미인증이면 null.
|
||||
* @param req Fetch API 표준 Request.
|
||||
* @param ctx 어댑터별 컨텍스트(선택).
|
||||
*/
|
||||
resolveSession(req: Request, ctx?: C): Promise<S | null>;
|
||||
}
|
||||
42
packages/auth-adapter/src/dev-bypass/index.ts
Normal file
42
packages/auth-adapter/src/dev-bypass/index.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// @yakcloud/auth-adapter — dev-bypass
|
||||
// IdP(Keycloak) 가 없을 때만 활성화되는 mock 세션 제공자.
|
||||
// ⛔ IdP/Keycloak/next-auth 정적 import 금지. consuming app 에서 조건부로만 사용.
|
||||
|
||||
import type { Role, Session, User } from "../core/types";
|
||||
|
||||
/**
|
||||
* DEV-BYPASS: KEYCLOAK_ISSUER 가 없을 때만 true.
|
||||
* 어떤 환경이든 KEYCLOAK_ISSUER 를 설정하면 결정적으로 false (잊을 수 있는 별도 opt-in 플래그 없음).
|
||||
*/
|
||||
export const DEV_BYPASS: boolean = !process.env.KEYCLOAK_ISSUER;
|
||||
|
||||
/** dev-bypass 전용 개발 신원(seed 와 이메일 일치). */
|
||||
export interface DevIdentity {
|
||||
readonly id: string;
|
||||
readonly keycloakSub: string;
|
||||
readonly email: string;
|
||||
readonly displayName: string;
|
||||
readonly role: Role;
|
||||
}
|
||||
|
||||
export const DEV_USER: DevIdentity = {
|
||||
id: "usr_dev",
|
||||
keycloakSub: "dev-sub-yakenator",
|
||||
email: "yakenator@gmail.com",
|
||||
displayName: "최정우",
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
/**
|
||||
* mock 세션 팩토리. DevIdentity(또는 포털 User) 로부터 Session 을 만든다.
|
||||
* consuming app 의 getSessionUser() 가 DEV_BYPASS 분기에서 사용.
|
||||
*/
|
||||
export function createDevBypassSession(user: DevIdentity | User): Session {
|
||||
const sub = "keycloakSub" in user ? user.keycloakSub : user.id;
|
||||
return {
|
||||
sub,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,24 @@
|
||||
// @yakcloud/auth-adapter
|
||||
// 인증 어댑터(대부분 신규): IdP 인터페이스, auth.ts 씨앗. ⛔ 클라 패키지 정적 import 금지.
|
||||
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조).
|
||||
export const __package = "@yakcloud/auth-adapter";
|
||||
// 인증 어댑터: (1) core = 프레임워크 무관 계약, (2) dev-bypass = mock 세션,
|
||||
// (3) next-auth = Next.js + Keycloak 격리 구현(peer: next-auth).
|
||||
//
|
||||
// ⚠️ 이 배럴은 next-auth 팩토리도 재export 하므로, 이 모듈을 통째로 import 하면
|
||||
// next-auth 를 정적 로드한다. 프레임워크 무관 소비자(bff-kit 등)는
|
||||
// "@yakcloud/auth-adapter/core" / "/dev-bypass" 서브패스로만 import 할 것.
|
||||
// (bff-kit 은 AuthAdapter 를 DI 로 받고 이 패키지를 deps 에 넣지 않는다.)
|
||||
|
||||
// ── core (framework-free) ──
|
||||
export type { AuthAdapter, Session, User, Role, UserStatus, UserResolver } from "./core";
|
||||
export { AuthError, getSession, requireUser } from "./core";
|
||||
|
||||
// ── dev-bypass (opt-in mock; IdP 미가용 시) ──
|
||||
export { DEV_BYPASS, DEV_USER, createDevBypassSession } from "./dev-bypass";
|
||||
export type { DevIdentity } from "./dev-bypass";
|
||||
|
||||
// ── next-auth (⚠ peer: next-auth >=5 필요) ──
|
||||
export { createAuthConfig, auth, handlers, signIn, signOut } from "./next-auth";
|
||||
export type { AuthConfigOptions } from "./next-auth";
|
||||
export { createAuthMiddleware, matcher } from "./next-auth/middleware";
|
||||
export { getToken } from "./next-auth/handlers";
|
||||
export type { KeycloakToken } from "./next-auth/keycloak";
|
||||
export { makeKeycloak, keycloakProviders, refreshKeycloakToken } from "./next-auth/keycloak";
|
||||
|
||||
28
packages/auth-adapter/src/next-auth/augment.d.ts
vendored
Normal file
28
packages/auth-adapter/src/next-auth/augment.d.ts
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
// @yakcloud/auth-adapter — next-auth 모듈 보강(augmentation)
|
||||
// session 콜백이 노출하는 최소 필드(keycloakSub, role, error) 와
|
||||
// JWT 에 보관하는 Keycloak 토큰 필드의 타입을 next-auth 에 주입한다.
|
||||
// ⚠️ next-auth 하위 구현에만 관계 — core/dev-bypass 는 이 파일과 무관.
|
||||
|
||||
import "next-auth";
|
||||
import "next-auth/jwt";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
keycloakSub?: string;
|
||||
role?: "USER" | "ADMIN";
|
||||
} & import("next-auth").DefaultSession["user"];
|
||||
error?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_at?: number;
|
||||
role?: "USER" | "ADMIN";
|
||||
error?: string;
|
||||
}
|
||||
}
|
||||
24
packages/auth-adapter/src/next-auth/handlers.ts
Normal file
24
packages/auth-adapter/src/next-auth/handlers.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// @yakcloud/auth-adapter — next-auth/handlers
|
||||
// Next.js app 용 라우트 핸들러 재export + 서버측 Keycloak 토큰 접근(getToken).
|
||||
// next-auth/jwt 는 peerDependency.
|
||||
|
||||
import { getToken as nextAuthGetToken } from "next-auth/jwt";
|
||||
import type { KeycloakToken } from "./keycloak";
|
||||
|
||||
export { auth, handlers, signIn, signOut } from "./index";
|
||||
|
||||
/**
|
||||
* 서버 라우트에서 Keycloak 토큰(access/refresh/id)을 직접 읽는다.
|
||||
* §4.4 kubeconfig 교환처럼 원 access_token 이 필요할 때만 사용(클라이언트엔 노출 금지).
|
||||
*
|
||||
* @param req Fetch API Request(App Router route handler 의 req).
|
||||
* @param secret AUTH_SECRET (기본 process.env.AUTH_SECRET).
|
||||
*/
|
||||
export async function getToken(
|
||||
req: Request,
|
||||
secret = process.env.AUTH_SECRET,
|
||||
): Promise<KeycloakToken | null> {
|
||||
if (!secret) return null;
|
||||
const token = await nextAuthGetToken({ req, secret });
|
||||
return (token as KeycloakToken | null) ?? null;
|
||||
}
|
||||
83
packages/auth-adapter/src/next-auth/index.ts
Normal file
83
packages/auth-adapter/src/next-auth/index.ts
Normal file
@ -0,0 +1,83 @@
|
||||
// @yakcloud/auth-adapter — next-auth
|
||||
// NextAuth 초기화 격리 계층. next-auth 는 peerDependency (consuming app 이 제공).
|
||||
// ⚠️ 이 모듈을 import 하면 next-auth 를 정적 로드한다 — dev-bypass/core 만 필요하면 import 하지 말 것.
|
||||
|
||||
/// <reference path="./augment.d.ts" />
|
||||
import NextAuth from "next-auth";
|
||||
import type { NextAuthConfig, NextAuthResult } from "next-auth";
|
||||
import { keycloakProviders, refreshKeycloakToken } from "./keycloak";
|
||||
import type { KeycloakToken } from "./keycloak";
|
||||
import { DEV_BYPASS } from "../dev-bypass";
|
||||
|
||||
/** createAuthConfig 옵션(선택 DI). */
|
||||
export interface AuthConfigOptions {
|
||||
/** 로그인 페이지 경로(기본 "/login"). */
|
||||
signInPath?: string;
|
||||
/** 추가/오버라이드 config(edge-sync 바인딩 등 후속 DI 지점). */
|
||||
overrides?: Partial<NextAuthConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* NextAuthConfig 팩토리.
|
||||
* - DEV_BYPASS 면 providers=[] (로그인 불가; consuming app 이 mock 세션 사용).
|
||||
* - JWT 전략(세션 테이블 없음, design §3 note 1).
|
||||
* - jwt 콜백: Keycloak 토큰 보관 + 만료 임박 시 회전.
|
||||
* - session 콜백: 최소·비민감 필드만(keycloakSub, role, error) 노출.
|
||||
*/
|
||||
export function createAuthConfig(options: AuthConfigOptions = {}): NextAuthConfig {
|
||||
const { signInPath = "/login", overrides } = options;
|
||||
const providers = DEV_BYPASS ? [] : keycloakProviders();
|
||||
|
||||
const config: NextAuthConfig = {
|
||||
trustHost: true,
|
||||
session: { strategy: "jwt" },
|
||||
providers,
|
||||
pages: { signIn: signInPath },
|
||||
callbacks: {
|
||||
// Keycloak 토큰을 암호화 JWT 에 보관(§4.4 kubeconfig 교환용); 만료 임박 시 회전.
|
||||
async jwt({ token, account, profile }) {
|
||||
const t = token as KeycloakToken;
|
||||
if (account) {
|
||||
t.access_token = account.access_token as string | undefined;
|
||||
t.refresh_token = account.refresh_token as string | undefined;
|
||||
t.id_token = account.id_token as string | undefined;
|
||||
t.expires_at = account.expires_at as number | undefined; // epoch seconds
|
||||
if (profile?.sub) t.sub = profile.sub;
|
||||
const roles =
|
||||
(profile as { realm_access?: { roles?: string[] } } | null)?.realm_access?.roles ?? [];
|
||||
t.role = roles.includes("admin") ? "ADMIN" : "USER";
|
||||
return t;
|
||||
}
|
||||
if (t.expires_at && Date.now() < (Number(t.expires_at) - 60) * 1000) {
|
||||
return t;
|
||||
}
|
||||
if (t.refresh_token) return refreshKeycloakToken(t);
|
||||
return t;
|
||||
},
|
||||
// 클라이언트 세션엔 최소·비민감 필드만(access_token/refresh_token 노출 금지 — §4.4 은 getToken 으로 서버에서만).
|
||||
async session({ session, token }) {
|
||||
const t = token as KeycloakToken;
|
||||
if (session.user) {
|
||||
session.user.keycloakSub = t.sub as string;
|
||||
session.user.role = t.role ?? "USER";
|
||||
}
|
||||
session.error = t.error;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 config 로 NextAuth 를 초기화한 결과.
|
||||
* consuming app 은 이 재export(handlers/auth/signIn/signOut)를 그대로 쓰거나,
|
||||
* createAuthConfig(...) 로 커스텀 config 를 만들어 직접 NextAuth() 를 호출해도 된다.
|
||||
*/
|
||||
const nextAuth: NextAuthResult = NextAuth(createAuthConfig());
|
||||
|
||||
export const handlers = nextAuth.handlers;
|
||||
export const auth = nextAuth.auth;
|
||||
export const signIn = nextAuth.signIn;
|
||||
export const signOut = nextAuth.signOut;
|
||||
100
packages/auth-adapter/src/next-auth/keycloak.ts
Normal file
100
packages/auth-adapter/src/next-auth/keycloak.ts
Normal file
@ -0,0 +1,100 @@
|
||||
// @yakcloud/auth-adapter — next-auth/keycloak
|
||||
// Keycloak provider 팩토리 + refresh 콜백. next-auth 는 peerDependency.
|
||||
// 이 하위 디렉터리(src/next-auth/*)에만 next-auth 의존을 격리한다.
|
||||
|
||||
import Keycloak from "next-auth/providers/keycloak";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
|
||||
// 프론트채널(브라우저: authorize·iss) vs 백채널(서버: discovery·token·userinfo·jwks).
|
||||
// 도커 등에서 두 경로의 호스트가 다를 때 KEYCLOAK_INTERNAL_ISSUER 로 백채널을 분리한다.
|
||||
// 프로덕션 단일 호스트면 미설정 → 프론트=백=KEYCLOAK_ISSUER (일반 동작).
|
||||
const KC_FRONT = process.env.KEYCLOAK_ISSUER;
|
||||
const KC_BACK = process.env.KEYCLOAK_INTERNAL_ISSUER ?? KC_FRONT;
|
||||
|
||||
/** JWT 콜백이 다루는 최소 토큰 형태. */
|
||||
export interface KeycloakToken {
|
||||
sub?: string;
|
||||
role?: "USER" | "ADMIN";
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_at?: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Keycloak refresh_token 회전(백채널 사용). */
|
||||
export async function refreshKeycloakToken(token: KeycloakToken): Promise<KeycloakToken> {
|
||||
try {
|
||||
const secret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||
if (!secret) throw new Error("KEYCLOAK_CLIENT_SECRET not set");
|
||||
const res = await fetch(`${KC_BACK}/protocol/openid-connect/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: process.env.KEYCLOAK_CLIENT_ID ?? "yakconsole",
|
||||
client_secret: secret,
|
||||
refresh_token: String(token.refresh_token),
|
||||
}),
|
||||
});
|
||||
const t = (await res.json()) as {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
};
|
||||
if (!res.ok) throw t;
|
||||
return {
|
||||
...token,
|
||||
access_token: t.access_token,
|
||||
expires_at: Math.floor(Date.now() / 1000) + Number(t.expires_in ?? 0),
|
||||
refresh_token: t.refresh_token ?? token.refresh_token,
|
||||
id_token: t.id_token ?? token.id_token,
|
||||
error: undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[auth] refresh failed", e);
|
||||
return { ...token, error: "RefreshAccessTokenError" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* authorizePath 만 다른 provider 를 만든다:
|
||||
* - "keycloak": 로그인(/auth)
|
||||
* - "keycloak-register": 회원가입(/registrations) — /signup 이 등록 폼으로 직행
|
||||
*/
|
||||
export function makeKeycloak(id: string, name: string, authorizePath: string): Provider {
|
||||
const base = {
|
||||
id,
|
||||
name,
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "yakconsole",
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
issuer: KC_FRONT, // iss 검증·기본 discovery
|
||||
};
|
||||
// 백채널이 프론트와 같으면 authorize 경로만 지정, 나머지는 discovery.
|
||||
if (KC_BACK === KC_FRONT) {
|
||||
return Keycloak({ ...base, authorization: `${KC_FRONT}${authorizePath}` });
|
||||
}
|
||||
// 분리 시: discovery·token·userinfo·jwks 는 백채널, authorize 만 프론트(브라우저).
|
||||
return Keycloak({
|
||||
...base,
|
||||
wellKnown: `${KC_BACK}/.well-known/openid-configuration`,
|
||||
authorization: `${KC_FRONT}${authorizePath}`,
|
||||
token: `${KC_BACK}/protocol/openid-connect/token`,
|
||||
userinfo: `${KC_BACK}/protocol/openid-connect/userinfo`,
|
||||
jwks_endpoint: `${KC_BACK}/protocol/openid-connect/certs`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 로그인 + 회원가입 두 provider. dev-bypass 시 빈 배열을 쓰라. */
|
||||
export function keycloakProviders(): Provider[] {
|
||||
return [
|
||||
makeKeycloak("keycloak", "YakCloud", "/protocol/openid-connect/auth"),
|
||||
makeKeycloak(
|
||||
"keycloak-register",
|
||||
"YakCloud 회원가입",
|
||||
"/protocol/openid-connect/registrations",
|
||||
),
|
||||
];
|
||||
}
|
||||
48
packages/auth-adapter/src/next-auth/middleware.ts
Normal file
48
packages/auth-adapter/src/next-auth/middleware.ts
Normal file
@ -0,0 +1,48 @@
|
||||
// @yakcloud/auth-adapter — next-auth/middleware
|
||||
// 라우트 보호 미들웨어 팩토리. AuthAdapter 를 주입받아 Fetch Request 로 세션을 판정한다.
|
||||
// next-auth 정적 import 없음(어댑터 DI). next/server 는 응답 헬퍼용 peer.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { CODE_STATUS } from "@yakcloud/ds-sdk";
|
||||
import type { AuthAdapter, Session } from "../core/types";
|
||||
|
||||
/** (console) 라우트 + /api/v1 매칭. /api/v1/internal/* 는 미들웨어 내부에서 우회. */
|
||||
export const matcher: string[] = ["/api/v1/:path*", "/((?!_next|login|signup|manual).*)"];
|
||||
|
||||
/**
|
||||
* 미들웨어 팩토리. AuthAdapter 를 주입받는다.
|
||||
* - /api/v1/internal/* → 세션 검사 우회(서비스 토큰은 핸들러에서 검증).
|
||||
* - /api/v1/* → 미인증이면 401 JSON.
|
||||
* - 그 외(console) → 미인증이면 /login 리다이렉트.
|
||||
*/
|
||||
export function createAuthMiddleware<C, S extends Session>(
|
||||
adapter: AuthAdapter<C, S>,
|
||||
loginPath = "/login",
|
||||
) {
|
||||
return async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// /internal/* 는 서비스 토큰(세션 아님)으로 게이트 — 미들웨어 통과.
|
||||
if (pathname.startsWith("/api/v1/internal/")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const session = await adapter.resolveSession(req);
|
||||
if (session) return NextResponse.next();
|
||||
|
||||
// /api/v1 → 401 JSON.
|
||||
if (pathname.startsWith("/api/v1")) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { code: "UNAUTHORIZED" } },
|
||||
{ status: CODE_STATUS.UNAUTHORIZED },
|
||||
);
|
||||
}
|
||||
|
||||
// (console) → /login 리다이렉트(원래 경로 callbackUrl 보존).
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = loginPath;
|
||||
url.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(url);
|
||||
};
|
||||
}
|
||||
@ -26,10 +26,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3",
|
||||
"@yakcloud/ds-sdk": "workspace:*",
|
||||
"@yakcloud/auth-adapter": "workspace:*"
|
||||
"@yakcloud/ds-sdk": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": ">=15"
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@yakcloud/auth-adapter": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
61
packages/bff-kit/src/handlers/binding.ts
Normal file
61
packages/bff-kit/src/handlers/binding.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// @yakcloud/bff-kit — handlers/binding
|
||||
// 앱 ↔ 서비스 바인딩 생성. 같은 클러스터 강제(유일 격리점) + 별칭 중복 방지 → createBinding + 재배포.
|
||||
// Repo 는 DI. Response(accepted) 반환. yakconsole services/[id]/bindings/route.ts 하강.
|
||||
|
||||
import { ApiError, accepted } from "../respond";
|
||||
import { requireOwnership, type SessionUser } from "../session";
|
||||
import type { BindingRepo } from "./types";
|
||||
|
||||
export interface CreateBindingArgs {
|
||||
serviceId: string;
|
||||
deploymentId: string;
|
||||
alias?: string | null;
|
||||
}
|
||||
|
||||
// 소유·같은클러스터·READY·중복 검증 후 바인딩 생성 → 앱 재배포 큐잉 + 감사.
|
||||
export async function createServiceBinding(
|
||||
repo: BindingRepo,
|
||||
user: SessionUser,
|
||||
args: CreateBindingArgs,
|
||||
): Promise<Response> {
|
||||
const svc = await repo.getService(args.serviceId);
|
||||
if (!svc || svc.deletedAt) throw new ApiError("NOT_FOUND");
|
||||
requireOwnership(await repo.getCluster(svc.clusterId), user);
|
||||
|
||||
const dep = await repo.getDeployment(args.deploymentId);
|
||||
if (!dep) throw new ApiError("NOT_FOUND", "앱을 찾을 수 없습니다.");
|
||||
// LOCKED#1: 같은 클러스터의 앱에만 연결(크로스클러스터 차단) — 유일한 격리 강제점.
|
||||
if (dep.clusterId !== svc.clusterId) {
|
||||
throw new ApiError("VALIDATION_FAILED", "같은 클러스터의 앱에만 연결할 수 있습니다.");
|
||||
}
|
||||
if (svc.status !== "READY") {
|
||||
throw new ApiError("VALIDATION_FAILED", "서비스가 준비(READY)된 후에 연결할 수 있습니다.");
|
||||
}
|
||||
|
||||
const alias = args.alias ?? svc.name;
|
||||
const existing = await repo.listBindingsByDeployment(dep.id);
|
||||
if (existing.some((b) => b.serviceId === svc.id)) {
|
||||
throw new ApiError("NAME_TAKEN", "이미 이 앱에 연결된 서비스입니다.");
|
||||
}
|
||||
if (existing.some((b) => b.alias === alias)) {
|
||||
throw new ApiError("NAME_TAKEN", "이미 사용 중인 별칭입니다.");
|
||||
}
|
||||
|
||||
const binding = await repo.createBinding({
|
||||
serviceId: svc.id,
|
||||
deploymentId: dep.id,
|
||||
alias,
|
||||
injectedKeys: [], // 실제 주입 키(BIND_ENV_VARS 파생)는 배포 시 백엔드가 도출.
|
||||
});
|
||||
// 바인딩 반영 = 재배포(deployment_manifest 가 binds 를 다시 엮음).
|
||||
await repo.updateDeployment(dep.id, { status: "QUEUED", statusMessage: null });
|
||||
await repo.enqueueJob({ kind: "app.deploy", payload: { deploymentId: dep.id } });
|
||||
await repo.addAudit({
|
||||
userId: user.id,
|
||||
action: "service.bind",
|
||||
targetType: "service",
|
||||
targetId: svc.id,
|
||||
meta: { deploymentId: dep.id, app: dep.name, alias },
|
||||
});
|
||||
return accepted({ id: binding.id, alias, serviceId: svc.id, deploymentId: dep.id });
|
||||
}
|
||||
23
packages/bff-kit/src/handlers/index.ts
Normal file
23
packages/bff-kit/src/handlers/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// @yakcloud/bff-kit — handlers 배럴
|
||||
// 재사용 라우트 로직(yakconsole API 라우트에서 추출). Repo + yakcloud 를 DI 로 받아 Response 반환.
|
||||
// 향후 quota/validation/provisioning orchestration 으로 확장 가능.
|
||||
|
||||
export { createServiceBinding } from "./binding";
|
||||
export type { CreateBindingArgs } from "./binding";
|
||||
|
||||
export { createService } from "./service";
|
||||
export type { CreateServiceArgs } from "./service";
|
||||
|
||||
export type {
|
||||
ClusterRow,
|
||||
DeploymentRow,
|
||||
ServiceRow,
|
||||
BindingRow,
|
||||
QuotaRow,
|
||||
ServiceRepo,
|
||||
BindingRepo,
|
||||
YakServiceSpecInput,
|
||||
YakServiceResultInput,
|
||||
YakcloudClient,
|
||||
HandlerDeps,
|
||||
} from "./types";
|
||||
130
packages/bff-kit/src/handlers/service.ts
Normal file
130
packages/bff-kit/src/handlers/service.ts
Normal file
@ -0,0 +1,130 @@
|
||||
// @yakcloud/bff-kit — handlers/service
|
||||
// 데이터소스(관리형/외부) 생성. remote=yakcloud 즉시 등록(READY), local=프로비저닝 Job.
|
||||
// 소스 소유자 = 클러스터 소유자(테넌트). Repo·yakcloud 는 DI. 크리덴셜은 DB/잡에 저장하지 않는다.
|
||||
// yakconsole services/route.ts 하강.
|
||||
|
||||
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
|
||||
import { ApiError, accepted } from "../respond";
|
||||
import { requireOwnership, type SessionUser } from "../session";
|
||||
import { publicService } from "../serialize";
|
||||
import type { ServiceRepo, YakcloudClient, YakServiceSpecInput } from "./types";
|
||||
|
||||
// 플랜별 기본 스토리지(표시/기록용). 백엔드가 최종 결정하지만 DB엔 이 값을 저장.
|
||||
const DEFAULT_STORAGE: Record<string, number> = { small: 10, medium: 20, large: 50 };
|
||||
|
||||
// createServiceBody(zod)로 파싱된 입력 형태. bff-kit 은 스키마를 소유하지 않으므로 구조만 정의.
|
||||
export interface CreateServiceArgs {
|
||||
clusterId: string;
|
||||
type: ServiceTypeDTO;
|
||||
mode?: "local" | "remote";
|
||||
name: string;
|
||||
size: string;
|
||||
storageGb?: number | null;
|
||||
persist?: boolean | null;
|
||||
// remote 접속정보 — mode=remote 일 때만
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
database?: string | null;
|
||||
vhost?: string | null;
|
||||
core?: string | null;
|
||||
dbNum?: number | null;
|
||||
}
|
||||
|
||||
// 쿼터·소유·이름중복 검증 → remote 즉시 등록 or local 프로비저닝 Job. 202 반환.
|
||||
export async function createService(
|
||||
repo: ServiceRepo,
|
||||
yakcloud: YakcloudClient,
|
||||
user: SessionUser,
|
||||
args: CreateServiceArgs,
|
||||
): Promise<Response> {
|
||||
const cluster = requireOwnership(await repo.getCluster(args.clusterId), user);
|
||||
if (cluster.status !== "ACTIVE") {
|
||||
throw new ApiError("VALIDATION_FAILED", "클러스터가 Active 여야 서비스를 만들 수 있습니다.");
|
||||
}
|
||||
|
||||
const me = await repo.getMe(user.id);
|
||||
const q = me?.quota;
|
||||
if (q && (await repo.countServicesByCluster(cluster.id)) >= q.maxServices) {
|
||||
throw new ApiError("QUOTA_EXCEEDED", undefined, { limit: q.maxServices });
|
||||
}
|
||||
if (await repo.isServiceNameTaken(cluster.id, args.name)) {
|
||||
throw new ApiError("NAME_TAKEN");
|
||||
}
|
||||
|
||||
// ── remote(외부 서버 연결): 프로비저닝 없이 즉시 등록. 비밀번호는 DB/잡에 저장하지 않고
|
||||
// yakcloud → 테넌트 Secret 으로만 전달. 성공 시 READY 행을 바로 생성.
|
||||
if (args.mode === "remote") {
|
||||
if (!yakcloud.configured() || !cluster.rancherClusterId) {
|
||||
throw new ApiError("VALIDATION_FAILED", "이 클러스터는 외부 연결을 지원하지 않습니다.");
|
||||
}
|
||||
const spec: YakServiceSpecInput = {
|
||||
type: args.type.toLowerCase(),
|
||||
name: args.name,
|
||||
mode: "remote",
|
||||
host: args.host,
|
||||
port: args.port,
|
||||
username: args.username,
|
||||
password: args.password,
|
||||
database: args.database,
|
||||
bucket: args.type === "MINIO" ? args.database : undefined, // minio 는 bucket 을 database 필드로 입력
|
||||
vhost: args.vhost,
|
||||
core: args.core,
|
||||
dbNum: args.dbNum,
|
||||
};
|
||||
let conn: { conn_secret_ref?: string; conn_info?: Record<string, unknown> | null };
|
||||
try {
|
||||
conn = await yakcloud.createService(cluster.rancherClusterId, spec);
|
||||
} catch (e) {
|
||||
throw new ApiError("INTERNAL", e instanceof Error ? e.message : "외부 연결 등록에 실패했습니다.");
|
||||
}
|
||||
const svc = await repo.createService({
|
||||
// 소스 소유자 = 클러스터 소유자(테넌트). 관리자가 남의 클러스터에 등록해도 소스가 새지 않게.
|
||||
ownerId: cluster.ownerId,
|
||||
clusterId: cluster.id,
|
||||
type: args.type,
|
||||
mode: "REMOTE",
|
||||
name: args.name,
|
||||
size: args.size,
|
||||
storageGb: 0,
|
||||
persist: false,
|
||||
});
|
||||
const updated = await repo.updateService(svc.id, {
|
||||
status: "READY",
|
||||
connSecretRef: conn.conn_secret_ref ?? args.name,
|
||||
connInfo: conn.conn_info ?? null,
|
||||
});
|
||||
await repo.addAudit({
|
||||
userId: user.id,
|
||||
action: "service.create",
|
||||
targetType: "service",
|
||||
targetId: svc.id,
|
||||
meta: { clusterId: cluster.id, type: args.type, name: args.name, mode: "remote" }, // 비밀 제외
|
||||
});
|
||||
return accepted(publicService(updated));
|
||||
}
|
||||
|
||||
// ── local(관리형 프로비저닝) ──
|
||||
const persist = args.persist ?? args.type !== "REDIS"; // redis 기본 비영속
|
||||
const storageGb = persist ? (args.storageGb ?? DEFAULT_STORAGE[args.size] ?? 0) : 0;
|
||||
const svc = await repo.createService({
|
||||
// 소스 소유자 = 클러스터 소유자(테넌트).
|
||||
ownerId: cluster.ownerId,
|
||||
clusterId: cluster.id,
|
||||
type: args.type,
|
||||
name: args.name,
|
||||
size: args.size,
|
||||
storageGb,
|
||||
persist,
|
||||
});
|
||||
await repo.enqueueJob({ kind: "service.provision", payload: { serviceId: svc.id } });
|
||||
await repo.addAudit({
|
||||
userId: user.id,
|
||||
action: "service.create",
|
||||
targetType: "service",
|
||||
targetId: svc.id,
|
||||
meta: { clusterId: cluster.id, type: args.type, name: args.name, size: args.size },
|
||||
});
|
||||
return accepted(publicService(svc));
|
||||
}
|
||||
124
packages/bff-kit/src/handlers/types.ts
Normal file
124
packages/bff-kit/src/handlers/types.ts
Normal file
@ -0,0 +1,124 @@
|
||||
// @yakcloud/bff-kit — handlers/types
|
||||
// 핸들러가 소비하는 최소 의존성 계약(DI). ⛔ Prisma/db/yakcloud-실구현 정적 import 금지.
|
||||
// 포털은 자기 Repo/yakcloud 클라이언트를 이 구조적 인터페이스로 주입한다.
|
||||
|
||||
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
|
||||
import type { SessionUser } from "../session";
|
||||
import type { ServiceInput } from "../serialize";
|
||||
|
||||
// ── 도메인 형태(구조적, Prisma 모델과 호환) ──
|
||||
export interface ClusterRow {
|
||||
readonly id: string;
|
||||
readonly ownerId: string;
|
||||
readonly deletedAt?: Date | null;
|
||||
readonly status: string;
|
||||
readonly rancherClusterId?: string | null;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
}
|
||||
|
||||
export interface DeploymentRow {
|
||||
readonly id: string;
|
||||
readonly clusterId: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface ServiceRow extends ServiceInput {
|
||||
readonly deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface BindingRow {
|
||||
readonly id: string;
|
||||
readonly serviceId: string;
|
||||
readonly alias: string;
|
||||
}
|
||||
|
||||
export interface QuotaRow {
|
||||
readonly maxServices: number;
|
||||
}
|
||||
|
||||
// ── Repo 계약(핸들러가 실제 호출하는 부분집합만) ──
|
||||
export interface ServiceRepo {
|
||||
getCluster(id: string): Promise<ClusterRow | null>;
|
||||
getMe(userId: string): Promise<{ quota: QuotaRow | null } | null>;
|
||||
countServicesByCluster(clusterId: string): Promise<number>;
|
||||
isServiceNameTaken(clusterId: string, name: string): Promise<boolean>;
|
||||
createService(input: {
|
||||
ownerId: string;
|
||||
clusterId: string;
|
||||
type: ServiceTypeDTO;
|
||||
mode?: "LOCAL" | "REMOTE";
|
||||
name: string;
|
||||
size: string;
|
||||
storageGb: number;
|
||||
persist: boolean;
|
||||
}): Promise<ServiceRow>;
|
||||
updateService(
|
||||
id: string,
|
||||
patch: { status?: string; connSecretRef?: string | null; connInfo?: unknown },
|
||||
): Promise<ServiceRow>;
|
||||
enqueueJob(input: { kind: string; payload: unknown }): Promise<unknown>;
|
||||
addAudit(input: {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string | null;
|
||||
meta?: unknown;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface BindingRepo {
|
||||
getService(id: string): Promise<ServiceRow | null>;
|
||||
getCluster(id: string): Promise<ClusterRow | null>;
|
||||
getDeployment(id: string): Promise<DeploymentRow | null>;
|
||||
listBindingsByDeployment(deploymentId: string): Promise<BindingRow[]>;
|
||||
createBinding(input: {
|
||||
serviceId: string;
|
||||
deploymentId: string;
|
||||
alias: string;
|
||||
injectedKeys: string[];
|
||||
}): Promise<{ id: string }>;
|
||||
updateDeployment(
|
||||
id: string,
|
||||
patch: { status?: string; statusMessage?: string | null },
|
||||
): Promise<unknown>;
|
||||
enqueueJob(input: { kind: string; payload: unknown }): Promise<unknown>;
|
||||
addAudit(input: {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string | null;
|
||||
meta?: unknown;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
// ── yakcloud 클라이언트 계약(원격 서비스 등록에 쓰는 부분집합만) ──
|
||||
export interface YakServiceSpecInput {
|
||||
type: string; // 소문자 서비스 타입
|
||||
name: string;
|
||||
mode?: "local" | "remote";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
database?: string | null;
|
||||
bucket?: string | null;
|
||||
vhost?: string | null;
|
||||
core?: string | null;
|
||||
dbNum?: number | null;
|
||||
}
|
||||
|
||||
export interface YakServiceResultInput {
|
||||
conn_secret_ref?: string;
|
||||
conn_info?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface YakcloudClient {
|
||||
configured(): boolean;
|
||||
createService(cluster: string, spec: YakServiceSpecInput): Promise<YakServiceResultInput>;
|
||||
}
|
||||
|
||||
// 핸들러 공통 의존성 번들.
|
||||
export interface HandlerDeps {
|
||||
readonly user: SessionUser;
|
||||
}
|
||||
@ -1,4 +1,37 @@
|
||||
// @yakcloud/bff-kit
|
||||
// 서버·BFF: respond/session/ratelimit/serialize/handlers. ⛔ api-client 참조 금지.
|
||||
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조).
|
||||
export const __package = "@yakcloud/bff-kit";
|
||||
// ApiCode/CODE_STATUS 는 ds-sdk 리터럴 재사용. auth 는 auth-adapter 인터페이스를 DI(정적 import 하지 않음).
|
||||
|
||||
// ── respond (HTTP 봉투·오류) ──
|
||||
export { ok, fail, accepted, handle, parseBody, ApiError, CODE_STATUS } from "./respond";
|
||||
export type { ApiCode } from "./respond";
|
||||
|
||||
// ── session (인증/인가 정책 — getSessionUser DI) ──
|
||||
export {
|
||||
requireUser,
|
||||
requireOwnership,
|
||||
requireOwnershipAllowDeleted,
|
||||
requireAdmin,
|
||||
requireServiceToken,
|
||||
ipv4InCidr,
|
||||
} from "./session";
|
||||
export type {
|
||||
SessionUser,
|
||||
SessionUserProvider,
|
||||
OwnableCluster,
|
||||
AdminGateOptions,
|
||||
} from "./session";
|
||||
|
||||
// ── ratelimit (교체 가능 백엔드) ──
|
||||
export { rateLimit, MemoryRatelimiter, defaultRatelimiter, BUCKETS } from "./ratelimit";
|
||||
export type { Bucket, Ratelimiter } from "./ratelimit";
|
||||
|
||||
// ── serialize (DTO 마스킹 — Prisma-free 구조적 입력) ──
|
||||
export { publicService, publicDeployment, envOf } from "./serialize";
|
||||
export type { ServiceInput, DeploymentInput, PublicServiceExtra } from "./serialize";
|
||||
|
||||
// ── handlers (재사용 라우트 로직 — Repo + yakcloud DI) ──
|
||||
export * from "./handlers";
|
||||
|
||||
// ── auth-adapter 인터페이스 재export (type-only; 구현체는 정적 import 하지 않음) ──
|
||||
export type { AuthAdapter, Session, User } from "@yakcloud/auth-adapter/core";
|
||||
|
||||
47
packages/bff-kit/src/ratelimit.ts
Normal file
47
packages/bff-kit/src/ratelimit.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// @yakcloud/bff-kit — ratelimit
|
||||
// per-user 토큰 버킷(슬라이딩 윈도우). 한도 초과 시 RATE_LIMITED throw(detail.retryAfterSec 포함).
|
||||
// 백엔드 교체 가능(Ratelimiter 인터페이스) — 기본은 in-memory Map, prod 는 Redis(INCR+EXPIRE) 주입.
|
||||
// yakconsole api/ratelimit.ts 하강.
|
||||
|
||||
import { ApiError } from "./respond";
|
||||
|
||||
// write 10/min, kubeconfig 5/min, terminal 30/min (§4.1). terminal 은 탭 열기/재연결로 자주 호출되므로 분리.
|
||||
export const BUCKETS = {
|
||||
write: { limit: 10, windowMs: 60_000 },
|
||||
kubeconfig: { limit: 5, windowMs: 60_000 },
|
||||
terminal: { limit: 30, windowMs: 60_000 },
|
||||
} as const;
|
||||
export type Bucket = keyof typeof BUCKETS;
|
||||
|
||||
// 교체 가능한 rate limiter 백엔드 계약. 한도 초과 시 ApiError('RATE_LIMITED') throw.
|
||||
export interface Ratelimiter {
|
||||
check(userId: string, bucket: Bucket): void | Promise<void>;
|
||||
}
|
||||
|
||||
// in-memory 슬라이딩 윈도우 구현. 모듈 레벨 Map 은 재시작 시 초기화·인스턴스 간 미공유 → prod 는 Redis.
|
||||
export class MemoryRatelimiter implements Ratelimiter {
|
||||
private readonly hits = new Map<string, number[]>(); // key=`${bucket}:${userId}` -> timestamps
|
||||
|
||||
check(userId: string, bucket: Bucket): void {
|
||||
const { limit, windowMs } = BUCKETS[bucket];
|
||||
const key = `${bucket}:${userId}`;
|
||||
const now = Date.now();
|
||||
const arr = (this.hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
if (arr.length >= limit) {
|
||||
const first = arr[0] ?? now;
|
||||
const retry = Math.ceil((windowMs - (now - first)) / 1000);
|
||||
throw new ApiError("RATE_LIMITED", undefined, { retryAfterSec: retry });
|
||||
}
|
||||
arr.push(now);
|
||||
this.hits.set(key, arr);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본(프로세스 전역) limiter. 핸들러가 별도 limiter 를 주입하지 않으면 이걸 쓴다.
|
||||
export const defaultRatelimiter: Ratelimiter = new MemoryRatelimiter();
|
||||
|
||||
// 편의 함수: 기본 limiter 로 검사. rateLimit(userId, bucket) throws RATE_LIMITED → 호출자는 handle() 로 감싼다.
|
||||
export function rateLimit(userId: string, bucket: Bucket): void {
|
||||
const r = defaultRatelimiter.check(userId, bucket);
|
||||
void r; // MemoryRatelimiter 는 동기지만 인터페이스는 Promise 도 허용.
|
||||
}
|
||||
107
packages/bff-kit/src/respond.ts
Normal file
107
packages/bff-kit/src/respond.ts
Normal file
@ -0,0 +1,107 @@
|
||||
// @yakcloud/bff-kit — respond
|
||||
// HTTP 응답 봉투 & 오류 처리. web 표준 Request/Response(Fetch API)만 사용 — ⛔ next 정적 import 금지.
|
||||
// ApiCode/CODE_STATUS 는 ds-sdk 리터럴 재사용(서버→클라 역참조 방지). yakconsole api/respond.ts 하강.
|
||||
|
||||
import { ZodError, z } from "zod";
|
||||
import { CODE_STATUS } from "@yakcloud/ds-sdk";
|
||||
import type { ApiCode } from "@yakcloud/ds-sdk";
|
||||
|
||||
// ds-sdk 계약 재export — bff-kit 소비자가 한 곳에서 코드 맵을 얻도록.
|
||||
export { CODE_STATUS };
|
||||
export type { ApiCode };
|
||||
|
||||
// 기본 한국어 사용자 메시지 (i18n 은 §6-4). detail 은 기계 판독용.
|
||||
const DEFAULT_MSG: Record<ApiCode, string> = {
|
||||
UNAUTHORIZED: "로그인이 필요합니다.",
|
||||
FORBIDDEN: "권한이 없습니다.",
|
||||
NOT_FOUND: "찾을 수 없습니다.",
|
||||
VALIDATION_FAILED: "입력값이 올바르지 않습니다.",
|
||||
NAME_TAKEN: "이미 사용 중인 이름입니다.",
|
||||
CONFLICT: "다른 리소스가 사용 중이라 처리할 수 없습니다.",
|
||||
NAME_RESERVED: "시스템이 예약한 이름이라 사용할 수 없습니다.",
|
||||
QUOTA_EXCEEDED: "쿼터를 초과했습니다.",
|
||||
CAPACITY_UNAVAILABLE: "현재 용량이 부족합니다. 잠시 후 다시 시도해 주세요.",
|
||||
RANCHER_UPSTREAM_ERROR: "백엔드 통신 오류가 발생했습니다.",
|
||||
RATE_LIMITED: "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.",
|
||||
NOT_IMPLEMENTED: "아직 제공되지 않는 기능입니다.",
|
||||
INTERNAL: "서버 오류가 발생했습니다.",
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: ApiCode;
|
||||
readonly status: number;
|
||||
readonly detail?: unknown;
|
||||
constructor(code: ApiCode, message?: string, detail?: unknown) {
|
||||
super(message ?? DEFAULT_MSG[code]);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.status = CODE_STATUS[code];
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
// success 봉투: { data }. init 으로 상태·헤더 설정 가능.
|
||||
export function ok<T>(data: T, init?: ResponseInit): Response {
|
||||
return Response.json({ data }, init);
|
||||
}
|
||||
|
||||
// 202 Accepted + (선택) Location 헤더 — 비동기 프로비저닝 응답.
|
||||
export function accepted<T>(data: T, location?: string): Response {
|
||||
const headers = location ? { Location: location } : undefined;
|
||||
return Response.json({ data }, { status: 202, headers });
|
||||
}
|
||||
|
||||
// error 봉투: { error: { code, message, detail? } }
|
||||
export function fail(err: ApiError): Response {
|
||||
const body = {
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
...(err.detail !== undefined ? { detail: err.detail } : {}),
|
||||
},
|
||||
};
|
||||
return Response.json(body, { status: err.status });
|
||||
}
|
||||
|
||||
// handle(): 라우트 본문을 감싸 던진 ApiError/ZodError/Prisma P2002/미지 오류를 봉투로 직렬화.
|
||||
// ⚠️ Prisma 를 정적 import 하지 않는다 — 던져진 오류의 구조(code === "P2002")만 덕타이핑으로 검사.
|
||||
export function handle<A extends unknown[]>(
|
||||
fn: (...args: A) => Promise<Response> | Response,
|
||||
): (...args: A) => Promise<Response> {
|
||||
return async (...args: A): Promise<Response> => {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return fail(e);
|
||||
if (e instanceof ZodError) {
|
||||
// zod v4: 인스턴스 .flatten() 제거됨 → z.flattenError 사용.
|
||||
return fail(new ApiError("VALIDATION_FAILED", undefined, z.flattenError(e)));
|
||||
}
|
||||
// Prisma 유니크 위반(P2002): TOCTOU 경합에서 발생 가능 → 409(재시도 가능)로 매핑.
|
||||
if (
|
||||
e !== null &&
|
||||
typeof e === "object" &&
|
||||
"code" in e &&
|
||||
(e as { code?: unknown }).code === "P2002"
|
||||
) {
|
||||
return fail(new ApiError("NAME_TAKEN", "값이 충돌했습니다. 잠시 후 다시 시도해 주세요."));
|
||||
}
|
||||
console.error("[bff] unhandled", e);
|
||||
return fail(new ApiError("INTERNAL"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// JSON 본문을 zod 스키마로 파싱·검증 (ZodError → 422 via handle).
|
||||
export async function parseBody<S extends z.ZodType>(
|
||||
req: Request,
|
||||
schema: S,
|
||||
): Promise<z.infer<S>> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
throw new ApiError("VALIDATION_FAILED", "요청 본문이 JSON이 아닙니다.");
|
||||
}
|
||||
return schema.parse(raw) as z.infer<S>;
|
||||
}
|
||||
75
packages/bff-kit/src/serialize.ts
Normal file
75
packages/bff-kit/src/serialize.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// @yakcloud/bff-kit — serialize
|
||||
// DTO 마스킹·필터. Prisma-free: 전체 모델 대신 필요한 필드만 가진 구조적 입력 타입을 받는다.
|
||||
// connSecretRef(내부 Secret 이름) 및 시크릿 env 값은 절대 출력에 노출하지 않는다(마스킹 계약).
|
||||
// yakconsole api/serialize.ts 하강.
|
||||
|
||||
import type { EnvVarDTO } from "@yakcloud/ds-sdk";
|
||||
|
||||
// ── 구조적 입력 타입(Repo 결과에서 핸들러가 구성; Prisma 모델도 구조적으로 호환) ──
|
||||
|
||||
// 날짜는 Repo 가 Date 로 주든 ISO string 으로 주든 그대로 통과시킨다(직렬화는 Response.json 소관).
|
||||
type DateLike = Date | string;
|
||||
|
||||
export interface DeploymentInput {
|
||||
env: unknown; // Deployment.env(Json) — 배열이 아니면 []
|
||||
[k: string]: unknown; // 나머지 필드는 그대로 스프레드(구조적)
|
||||
}
|
||||
|
||||
export interface ServiceInput {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
clusterId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
statusMessage: string | null;
|
||||
size: string;
|
||||
storageGb: number;
|
||||
persist: boolean;
|
||||
connSecretRef?: string | null; // ⛔ 출력에서 제외(마스킹)
|
||||
connInfo: unknown; // 비밀 제외 접속 메타 JSON
|
||||
createdAt: DateLike;
|
||||
updatedAt: DateLike;
|
||||
}
|
||||
|
||||
export interface PublicServiceExtra {
|
||||
clusterName?: string;
|
||||
bindings?: { app: string; alias: string }[];
|
||||
}
|
||||
|
||||
// Deployment.env(Json) → EnvVar[]. 배열이 아니면 빈 배열.
|
||||
export function envOf(dep: DeploymentInput): EnvVarDTO[] {
|
||||
const raw = dep.env;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw as EnvVarDTO[];
|
||||
}
|
||||
|
||||
// API 응답용: 시크릿 환경변수의 값을 마스킹(빈 문자열)해 클라이언트로 노출하지 않는다.
|
||||
export function publicDeployment<D extends DeploymentInput>(dep: D): Omit<D, "env"> & { env: EnvVarDTO[] } {
|
||||
const env = envOf(dep).map((e) => (e.secret ? { key: e.key, value: "", secret: true } : e));
|
||||
return { ...dep, env };
|
||||
}
|
||||
|
||||
// 외부 서비스 응답 DTO — connSecretRef 미노출, connInfo(host/port/db/bucket)만.
|
||||
// 크리덴셜은 애초에 포털에 저장되지 않으므로 여기서 마스킹할 비밀이 없다(설계 C).
|
||||
export function publicService(svc: ServiceInput, extra?: PublicServiceExtra) {
|
||||
return {
|
||||
id: svc.id,
|
||||
ownerId: svc.ownerId,
|
||||
clusterId: svc.clusterId,
|
||||
name: svc.name,
|
||||
type: svc.type,
|
||||
mode: svc.mode,
|
||||
status: svc.status,
|
||||
statusMessage: svc.statusMessage,
|
||||
size: svc.size,
|
||||
storageGb: svc.storageGb,
|
||||
persist: svc.persist,
|
||||
connInfo: (svc.connInfo as Record<string, unknown> | null) ?? null,
|
||||
createdAt: svc.createdAt,
|
||||
updatedAt: svc.updatedAt,
|
||||
...(extra?.clusterName !== undefined ? { clusterName: extra.clusterName } : {}),
|
||||
...(extra?.bindings !== undefined ? { bindings: extra.bindings } : {}),
|
||||
};
|
||||
}
|
||||
118
packages/bff-kit/src/session.ts
Normal file
118
packages/bff-kit/src/session.ts
Normal file
@ -0,0 +1,118 @@
|
||||
// @yakcloud/bff-kit — session
|
||||
// 인증/인가 정책 강제. auth-adapter 인터페이스를 DI 로 받는다 — ⛔ next-auth/next/prisma/db 정적 import 금지.
|
||||
// getSessionUser 는 포털이 주입(next-auth + DEV_BYPASS + Repo 조회). 여기는 정책만 알고 조회는 모른다.
|
||||
// yakconsole api/session.ts 하강.
|
||||
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { User as AuthUser } from "@yakcloud/auth-adapter/core";
|
||||
import { ApiError } from "./respond";
|
||||
|
||||
// ── 구조적 입력 타입(Prisma-free) ──
|
||||
// 포털 User 의 정책 판단에 필요한 최소 필드만. auth-adapter 의 User 와 호환(role/status).
|
||||
export type SessionUser = AuthUser;
|
||||
|
||||
// 소유권 검사에 필요한 클러스터 최소 필드만(구조적). 전체 Prisma 모델을 통과시켜도 호환된다.
|
||||
export interface OwnableCluster {
|
||||
readonly ownerId: string;
|
||||
readonly deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
// 현재 세션 → 포털 User(또는 null). 포털이 주입(DI).
|
||||
export type SessionUserProvider = (req: Request) => Promise<SessionUser | null>;
|
||||
|
||||
// /api/v1 핸들러용: 유저 또는 UNAUTHORIZED throw. SUSPENDED 는 FORBIDDEN.
|
||||
export async function requireUser(
|
||||
req: Request,
|
||||
getSessionUser: SessionUserProvider,
|
||||
): Promise<SessionUser> {
|
||||
const user = await getSessionUser(req);
|
||||
if (!user) throw new ApiError("UNAUTHORIZED");
|
||||
if (user.status === "SUSPENDED") throw new ApiError("FORBIDDEN", "정지된 계정입니다.");
|
||||
return user;
|
||||
}
|
||||
|
||||
// 소유권: 남의(또는 삭제된) 클러스터는 403 아닌 404 로 존재를 숨김.
|
||||
// ADMIN 은 삭제되지 않은 어떤 사용자의 클러스터도 열람·관리 가능(운영자 조망).
|
||||
export function requireOwnership<C extends OwnableCluster>(
|
||||
cluster: C | null,
|
||||
user: SessionUser,
|
||||
): C {
|
||||
if (!cluster || cluster.deletedAt) throw new ApiError("NOT_FOUND");
|
||||
if (user.role !== "ADMIN" && cluster.ownerId !== user.id) throw new ApiError("NOT_FOUND");
|
||||
return cluster;
|
||||
}
|
||||
|
||||
// 소유권만 검사(삭제된 클러스터도 허용) — 삭제된 클러스터의 고아 데이터소스 조회/삭제용.
|
||||
export function requireOwnershipAllowDeleted<C extends OwnableCluster>(
|
||||
cluster: C | null,
|
||||
user: SessionUser,
|
||||
): C {
|
||||
if (!cluster) throw new ApiError("NOT_FOUND");
|
||||
if (user.role !== "ADMIN" && cluster.ownerId !== user.id) throw new ApiError("NOT_FOUND");
|
||||
return cluster;
|
||||
}
|
||||
|
||||
// /admin: role=ADMIN AND source IP ∈ VPN CIDR. 어떤 실패든 404 (design §2.5-⑦).
|
||||
export async function requireAdmin(
|
||||
req: Request,
|
||||
getSessionUser: SessionUserProvider,
|
||||
opts?: AdminGateOptions,
|
||||
): Promise<SessionUser> {
|
||||
try {
|
||||
const user = await requireUser(req, getSessionUser);
|
||||
if (user.role !== "ADMIN") throw new ApiError("NOT_FOUND");
|
||||
if (!sourceIpAllowed(req, opts)) throw new ApiError("NOT_FOUND");
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code !== "NOT_FOUND") throw new ApiError("NOT_FOUND");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminGateOptions {
|
||||
/** 허용 CIDR 목록. 미지정 시 process.env.ADMIN_VPN_CIDRS(콤마 구분) 사용. */
|
||||
readonly vpnCidrs?: readonly string[];
|
||||
/** CIDR 미설정 시 dev 는 허용·prod 는 거부. 미지정 시 process.env.NODE_ENV 로 판정. */
|
||||
readonly allowWhenUnset?: boolean;
|
||||
}
|
||||
|
||||
function sourceIpAllowed(req: Request, opts?: AdminGateOptions): boolean {
|
||||
const cidrs = (
|
||||
opts?.vpnCidrs ??
|
||||
(process.env.ADMIN_VPN_CIDRS ?? "").split(",")
|
||||
)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// 미설정: dev 는 허용, prod-misconfig 는 거부.
|
||||
if (cidrs.length === 0) {
|
||||
return opts?.allowWhenUnset ?? process.env.NODE_ENV !== "production";
|
||||
}
|
||||
// 최좌측 XFF 는 위조 가능하므로 신뢰 금지. 신뢰 프록시(ingress)가 세팅하는 x-real-ip 를 우선 사용,
|
||||
// 없으면 XFF 최우측(마지막 신뢰 홉이 append 한 값)을 쓴다.
|
||||
const xff = req.headers.get("x-forwarded-for");
|
||||
const ip = (req.headers.get("x-real-ip") ?? (xff ? (xff.split(",").pop() ?? "") : null) ?? "").trim();
|
||||
return cidrs.some((c) => ipv4InCidr(ip, c));
|
||||
}
|
||||
|
||||
// ── CIDR 유틸(프레임워크 무관 순수 함수) ──
|
||||
export function ipv4InCidr(ip: string, cidr: string): boolean {
|
||||
const [range, bitsStr] = cidr.split("/");
|
||||
if (range === undefined) return false;
|
||||
const bits = Number(bitsStr ?? 32);
|
||||
const toInt = (s: string): number =>
|
||||
s.split(".").reduce((a, o) => (a << 8) + (Number(o) & 255), 0) >>> 0;
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
|
||||
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
||||
return (toInt(ip) & mask) === (toInt(range) & mask);
|
||||
}
|
||||
|
||||
// /internal/* 용 서비스 토큰 게이트(워커·edge-sync). 길이 분기(타이밍 오라클) 없이
|
||||
// 양쪽을 고정 길이 SHA-256 다이제스트로 만들어 상수시간 비교. 미설정 => 거부(404).
|
||||
export function requireServiceToken(req: Request, expectedToken?: string): void {
|
||||
const expected = expectedToken ?? process.env.INTERNAL_SERVICE_TOKEN;
|
||||
const got = req.headers.get("x-internal-token") ?? "";
|
||||
if (!expected) throw new ApiError("NOT_FOUND");
|
||||
const a = createHash("sha256").update(got).digest();
|
||||
const b = createHash("sha256").update(expected).digest();
|
||||
if (!timingSafeEqual(a, b)) throw new ApiError("NOT_FOUND");
|
||||
}
|
||||
@ -31,5 +31,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
301
packages/client-shell/src/ServiceClientShell.tsx
Normal file
301
packages/client-shell/src/ServiceClientShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
packages/client-shell/src/components/ServiceClientHeader.tsx
Normal file
70
packages/client-shell/src/components/ServiceClientHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
packages/client-shell/src/components/TabNavigation.tsx
Normal file
96
packages/client-shell/src/components/TabNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
packages/client-shell/src/context/ServiceClientContext.tsx
Normal file
60
packages/client-shell/src/context/ServiceClientContext.tsx
Normal 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;
|
||||
}
|
||||
99
packages/client-shell/src/hooks/useServiceClient.ts
Normal file
99
packages/client-shell/src/hooks/useServiceClient.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
70
packages/client-shell/src/layout/TwoPaneLayout.tsx
Normal file
70
packages/client-shell/src/layout/TwoPaneLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
packages/client-shell/src/panels/BackupPanel.tsx
Normal file
196
packages/client-shell/src/panels/BackupPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
packages/client-shell/src/panels/OverviewPanel.tsx
Normal file
121
packages/client-shell/src/panels/OverviewPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
packages/client-shell/src/panels/PlaceholderPanel.tsx
Normal file
47
packages/client-shell/src/panels/PlaceholderPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/client-shell/src/panels/index.ts
Normal file
48
packages/client-shell/src/panels/index.ts
Normal 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);
|
||||
}
|
||||
76
packages/client-shell/src/sheets/ConnectionHelpSheet.tsx
Normal file
76
packages/client-shell/src/sheets/ConnectionHelpSheet.tsx
Normal 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}=<{v.suffix.toLowerCase()}> \
|
||||
<br />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{" "}your-app:latest
|
||||
</CodeBlock>
|
||||
|
||||
<p className="cs-help-note">
|
||||
비밀번호·액세스 키는 이 화면에 표시되지 않으며, 앱 실행 시점에만 파드 환경으로 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</CardSheet>
|
||||
);
|
||||
}
|
||||
59
packages/client-shell/src/states/LoadingGuard.tsx
Normal file
59
packages/client-shell/src/states/LoadingGuard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
packages/client-shell/src/utils/formatters.ts
Normal file
78
packages/client-shell/src/utils/formatters.ts
Normal 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}`;
|
||||
}
|
||||
@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
Reference in New Issue
Block a user