diff --git a/.github/workflows/build-design-tokens.yml b/.github/workflows/build-design-tokens.yml new file mode 100644 index 0000000..b1d5685 --- /dev/null +++ b/.github/workflows/build-design-tokens.yml @@ -0,0 +1,48 @@ +name: Build Design Tokens + +on: + push: + branches: + - develop + paths: + - 'packages/design-tokens/tokens/**' + workflow_dispatch: + +concurrency: + group: build-design-tokens + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.YML_GITHUB_TOKEN }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build design tokens + run: pnpm --filter @infra-support/design-tokens build + + - name: Commit generated dist + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add packages/design-tokens/dist/ + git diff --cached --quiet && echo "No changes to commit" || \ + (git commit -m "chore(design-tokens): auto-build from Figma tokens" && git push) diff --git a/.github/workflows/monitor-server-schedule.yml b/.github/workflows/monitor-server-schedule.yml new file mode 100644 index 0000000..04aaf76 --- /dev/null +++ b/.github/workflows/monitor-server-schedule.yml @@ -0,0 +1,39 @@ +name: Monitor Server Schedule + +on: + schedule: + - cron: "0 0,4,9 * * *" + workflow_dispatch: + +concurrency: + group: monitor-server-schedule + cancel-in-progress: false + +jobs: + run-monitoring: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run monitor batch + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + run: pnpm --filter @infra-support/monitor-server exec tsx src/index.ts diff --git a/.gitignore b/.gitignore index a6c6742..b4685c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ # Dependencies node_modules/ -pnpm-lock.yaml package-lock.json yarn.lock # Build outputs dist/ +!packages/design-tokens/dist/ build/ .next/ .turbo/ diff --git a/.vscode/settings.json b/.vscode/settings.json index bf620b9..08fb4f0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "tailwindCSS.experimental.configFile": "apps/monitor-web/tailwind.config.ts", - "cSpell.words": ["jikwon", "supabase"], + "cSpell.words": ["jikwon", "junyeol", "supabase"], "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, diff --git a/apps/monitor-server/package.json b/apps/monitor-server/package.json index 44a443b..9f81bfa 100644 --- a/apps/monitor-server/package.json +++ b/apps/monitor-server/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "type": "module", "scripts": { - "check": "tsx src/index.ts", + "check": "tsx --env-file=.env src/index.ts", "lint": "eslint src --ext ts", "clean": "rm -rf dist" }, diff --git a/apps/monitor-server/src/index.ts b/apps/monitor-server/src/index.ts index 8495e8f..99b0537 100644 --- a/apps/monitor-server/src/index.ts +++ b/apps/monitor-server/src/index.ts @@ -1,2 +1,10 @@ -// 나중에 구현 -console.log("Monitor server is ready"); +import { runMonitoring } from "./services/monitoring.service"; + +runMonitoring() + .then(() => { + console.log("monitoring job completed"); + }) + .catch((error) => { + console.error("monitoring job failed:", error); + process.exit(1); + }); diff --git a/apps/monitor-server/src/repositories/api.repository.ts b/apps/monitor-server/src/repositories/api.repository.ts new file mode 100644 index 0000000..57699de --- /dev/null +++ b/apps/monitor-server/src/repositories/api.repository.ts @@ -0,0 +1,25 @@ +import { supabase } from "../utils/supabase"; +import { ApisRow } from "@infra-support/shared"; + +export type ActiveApiRow = Pick; + +/** + * 모니터링 대상 API 목록을 조회하는 함수입니다. + * + * @returns 활성화되어 있고 source_url이 존재하는 API 목록 + * + * @author junyeol + */ + +const getActiveApis = async (): Promise => { + const { data, error } = await supabase + .from("apis") + .select("id, name, source_url, is_active") + .eq("is_active", true) + .not("source_url", "is", null); + + if (error) throw new Error(`apis 조회 실패: ${error.message}`); + return (data ?? []) as ActiveApiRow[]; +}; + +export default getActiveApis; diff --git a/apps/monitor-server/src/repositories/monitoring.repository.ts b/apps/monitor-server/src/repositories/monitoring.repository.ts new file mode 100644 index 0000000..2d37cd7 --- /dev/null +++ b/apps/monitor-server/src/repositories/monitoring.repository.ts @@ -0,0 +1,28 @@ +import { supabase } from "../utils/supabase"; +import type { ErrorLogsInsert, MonitoringResultsInsert } from "@infra-support/shared"; + +/** + * 모니터링 결과를 저장하는 함수입니다. + * + * @param payload - monitoring_results 테이블 insert payload + * + * @author junyeol + */ + +export const insertMonitoringResult = async (payload: MonitoringResultsInsert): Promise => { + const { error } = await supabase.from("monitoring_results").insert(payload); + if (error) throw new Error(`monitoring_results 저장 실패: ${error.message}`); +}; + +/** + * 에러 로그를 저장하는 함수입니다. + * + * @param payload - error_logs 테이블 insert payload + * + * @author junyeol + */ + +export const insertErrorLog = async (payload: ErrorLogsInsert): Promise => { + const { error } = await supabase.from("error_logs").insert(payload); + if (error) throw new Error(`error_logs 저장 실패: ${error.message}`); +}; diff --git a/apps/monitor-server/src/services/monitoring.processor.ts b/apps/monitor-server/src/services/monitoring.processor.ts new file mode 100644 index 0000000..3a7d103 --- /dev/null +++ b/apps/monitor-server/src/services/monitoring.processor.ts @@ -0,0 +1,139 @@ +import type { ActiveApiRow } from "../repositories/api.repository"; +import { + insertErrorLog, + insertMonitoringResult, +} from "../repositories/monitoring.repository"; +import { resolveApiStatus, shouldWriteErrorLog } from "../utils/status"; + +type CallResult = { + ok: boolean; + httpStatus: number | null; + responseTime: number | null; + errorType: string | null; + errorMessage: string | null; +}; + +/** + * 단일 API 엔드포인트를 호출해 상태 판별용 결과를 반환하는 함수입니다. + * + * @remarks + * - HTTP 비정상 응답은 `ok: false`와 `http_error`로 반환합니다. + * - 네트워크/타임아웃 예외는 throw하지 않고 결과 객체로 변환합니다. + * + * @returns API 호출 결과(성공 여부, HTTP 상태, 응답 시간, 에러 정보) + * + * @author junyeol + */ + +const callApi = async (url: string, timeoutMs = 5000): Promise => { + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const getElapsed = () => Date.now() - startedAt; + + try { + const res = await fetch(url, { + method: "GET", + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + return { + ok: false, + httpStatus: res.status, + responseTime: getElapsed(), + errorType: "http_error", + errorMessage: `HTTP ${res.status} ${res.statusText}`, + }; + } + + return { + ok: true, + httpStatus: res.status, + responseTime: getElapsed(), + errorType: null, + errorMessage: null, + }; + } catch (error) { + const isAbort = error instanceof Error && error.name === "AbortError"; + const elapsed = Date.now() - startedAt; + + return { + ok: false, + httpStatus: null, + responseTime: elapsed, + errorType: isAbort ? "timeout" : "network_error", + errorMessage: error instanceof Error ? error.message : "unknown error", + }; + } finally { + clearTimeout(timeout); + } +}; + +/** + * 단일 API 모니터링 결과를 저장하는 함수입니다. + * + * @remarks + * - 모니터링 결과는 항상 `monitoring_results`에 저장합니다. + * - 상태가 `degraded`/`outage`이면 `error_logs`도 추가 저장합니다. + * - 처리 중 예외는 내부에서 로깅하고 `false`를 반환합니다. + * + * @returns 저장 성공 시 `true`, 실패 시 `false` + * + * @author junyeol + */ + +export const processApi = async (api: ActiveApiRow): Promise => { + try { + const checkedAt = new Date().toISOString(); + + if (!api.source_url) return true; + + const result = await callApi(api.source_url); + const status = resolveApiStatus({ + ok: result.ok, + httpStatus: result.httpStatus, + responseTime: result.responseTime, + }); + + const normalizedErrorType = + status === "degraded" && !result.errorType ? "slow_response" : result.errorType; + + const normalizedErrorMessage = + status === "degraded" && !result.errorMessage + ? `Response delayed: ${result.responseTime ?? "unknown"}ms` + : result.errorMessage; + + const monitoringPayload = { + api_id: api.id, + status, + response_time: result.responseTime, + http_status: result.httpStatus, + error_type: normalizedErrorType, + error_message: normalizedErrorMessage, + checked_at: checkedAt, + }; + + await insertMonitoringResult(monitoringPayload); + + if (shouldWriteErrorLog(status)) { + const errorLogPayload = { + api_id: api.id, + status, + error_type: normalizedErrorType, + error_message: normalizedErrorMessage, + response_time: result.responseTime, + http_status: result.httpStatus, + is_checked: false, + occurred_at: checkedAt, + }; + + await insertErrorLog(errorLogPayload); + } + return true; + } catch (error) { + console.error("[monitoring] api_id=" + api.id + " (" + api.name + ") 처리 실패", error); + return false; + } +}; diff --git a/apps/monitor-server/src/services/monitoring.service.ts b/apps/monitor-server/src/services/monitoring.service.ts new file mode 100644 index 0000000..f108b2c --- /dev/null +++ b/apps/monitor-server/src/services/monitoring.service.ts @@ -0,0 +1,39 @@ +import getActiveApis from "../repositories/api.repository"; +import { processApi } from "./monitoring.processor"; + +const CONCURRENCY = 5; + +/** + * 모니터링 배치를 실행하는 함수입니다. + * + * @remarks + * - 활성 API 목록을 조회한 뒤 병렬 점검합니다. + * - API 단위 실패는 집계 후 배치 종료 시점에 에러로 전파합니다. + * + * @throws 하나 이상의 API 처리에 실패하면 에러를 던집니다. + * + * @author junyeol + */ + +export const runMonitoring = async (): Promise => { + const apis = await getActiveApis(); + let nextIndex = 0; + let failedCount = 0; + const workerCount = Math.min(CONCURRENCY, apis.length); + + const workers = Array.from({ length: workerCount }, async () => { + while (true) { + const currentIndex = nextIndex++; + if (currentIndex >= apis.length) break; + + const ok = await processApi(apis[currentIndex]); + if (!ok) failedCount += 1; + } + }); + + await Promise.all(workers); + + if (failedCount > 0) { + throw new Error(`모니터링 저장 실패 ${failedCount}건`); + } +}; diff --git a/apps/monitor-server/src/utils/status.ts b/apps/monitor-server/src/utils/status.ts new file mode 100644 index 0000000..82370aa --- /dev/null +++ b/apps/monitor-server/src/utils/status.ts @@ -0,0 +1,34 @@ +export type ApiStatus = "healthy" | "degraded" | "outage"; + +const DELAY_THRESHOLD_MS = 3000; + +type ResolveApiStatusParams = { + ok: boolean; + httpStatus: number | null; + responseTime: number | null; +}; + +/** + * API 상태를 판별하는 함수입니다. + * + * @param ok - 호출 성공 여부 + * @param httpStatus - HTTP 상태 코드 + * @param responseTime - 응답 시간(ms) + * + * @returns healthy | degraded | outage 상태 값 + * + * @author junyeol + */ + +export const resolveApiStatus = ({ + ok, + httpStatus, + responseTime, +}: ResolveApiStatusParams): ApiStatus => { + if (!ok || httpStatus === null || httpStatus >= 500) return "outage"; + if (responseTime !== null && responseTime >= DELAY_THRESHOLD_MS) return "degraded"; + return "healthy"; +}; + +export const shouldWriteErrorLog = (status: ApiStatus): boolean => + status === "degraded" || status === "outage"; diff --git a/apps/monitor-server/src/utils/supabase.ts b/apps/monitor-server/src/utils/supabase.ts index abd0029..2fd0b81 100644 --- a/apps/monitor-server/src/utils/supabase.ts +++ b/apps/monitor-server/src/utils/supabase.ts @@ -1,6 +1,6 @@ import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.SUPABASE_URL!; -const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!; +const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +export const supabase = createClient(supabaseUrl, supabaseServiceRoleKey); diff --git a/apps/monitor-server/tsconfig.json b/apps/monitor-server/tsconfig.json index ba247c0..170f172 100644 --- a/apps/monitor-server/tsconfig.json +++ b/apps/monitor-server/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "skipLibCheck": true, "esModuleInterop": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src"], "references": [{ "path": "../../packages/shared" }] diff --git a/apps/monitor-web/package.json b/apps/monitor-web/package.json index ab8e1f0..633a3c5 100644 --- a/apps/monitor-web/package.json +++ b/apps/monitor-web/package.json @@ -10,6 +10,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@infra-support/design-tokens": "workspace:*", "@supabase/supabase-js": "^2.38.0", "@tanstack/react-query": "^5.28.0", "clsx": "^2.0.0", diff --git a/apps/monitor-web/src/assets/icons.constants.ts b/apps/monitor-web/src/assets/icons.constants.ts index 0bc9b95..86b80ee 100644 --- a/apps/monitor-web/src/assets/icons.constants.ts +++ b/apps/monitor-web/src/assets/icons.constants.ts @@ -2,6 +2,7 @@ import AlertIcon from "@/assets/icons/alert.svg?react"; import BaseLogo from "@/assets/icons/base-logo.svg?react"; import Check from "@/assets/icons/check.svg?react"; import Clear from "@/assets/icons/clear.svg?react"; +import EditPencil from "@/assets/icons/edit-pencil.svg?react"; import LoadingSpinner from "@/assets/icons/loading-spinner.svg?react"; export const ICON_MAP = { @@ -9,5 +10,6 @@ export const ICON_MAP = { baseLogo: BaseLogo, check: Check, clear: Clear, + editPencil: EditPencil, loadingSpinner: LoadingSpinner, } as const; diff --git a/apps/monitor-web/src/assets/icons/check.svg b/apps/monitor-web/src/assets/icons/check.svg index 330b744..4ce9fe9 100644 --- a/apps/monitor-web/src/assets/icons/check.svg +++ b/apps/monitor-web/src/assets/icons/check.svg @@ -1,3 +1,3 @@ - - + + diff --git a/apps/monitor-web/src/assets/icons/edit-pencil.svg b/apps/monitor-web/src/assets/icons/edit-pencil.svg new file mode 100644 index 0000000..f7ccbb6 --- /dev/null +++ b/apps/monitor-web/src/assets/icons/edit-pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/monitor-web/src/components/common/buttons/CheckboxButton.tsx b/apps/monitor-web/src/components/common/buttons/CheckboxButton.tsx index d447de6..b6e01ba 100644 --- a/apps/monitor-web/src/components/common/buttons/CheckboxButton.tsx +++ b/apps/monitor-web/src/components/common/buttons/CheckboxButton.tsx @@ -20,13 +20,17 @@ interface CheckboxButtonProps extends Omit void; /** 아이콘 크기 (default: `"md"`) */ - size?: "sm" | "md" | "lg"; + size?: "sm" | "md"; } const SIZE_MAP = { - sm: 16, - md: 20, - lg: 24, + sm: 18, + md: 30, +} as const; + +const PADDING_MAP = { + sm: "p-[3px]", + md: "p-[5px]", } as const; /** @@ -36,7 +40,7 @@ const SIZE_MAP = { * 항목 선택 * * // 크기 지정 - * 대형 체크박스 + * 소형 체크박스 */ const CheckboxButton = ({ @@ -51,13 +55,18 @@ const CheckboxButton = ({
+
{label && ( -