diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a06cb1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI/CD + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api' }} + NEXT_PUBLIC_SSE_URL: ${{ secrets.NEXT_PUBLIC_SSE_URL || 'http://localhost:8080/api/stream' }} + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag, and push image to ECR + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: dgu-cap-frontend + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest + echo "Image pushed: $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c190737 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM node:20-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/app/lib/api.ts b/app/lib/api.ts index 00199e6..cde524c 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -5,9 +5,8 @@ import type { CurrentMetric, MetricPoint, Ticket, - TicketActionLog, TicketDetail, - UpdateStatusRequest, + TicketActionLog, } from "./types"; const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api"; @@ -40,13 +39,6 @@ export async function getPodEvents( return data; } -export async function getTopology(namespace?: string): Promise { - const { data } = await api.get("/topology", { - params: namespace ? { namespace } : undefined, - }); - return data; -} - export async function getCurrentMetric(pod: string): Promise { const { data } = await api.get("/metrics/current", { params: { pod } }); return data; @@ -88,7 +80,12 @@ export async function getTicket(id: number | string): Promise { export async function updateTicketStatus( id: number | string, - body: UpdateStatusRequest + body: { + status: string; + action: string; + memo: string; + performedBy: string; + } ): Promise { const { data } = await api.patch(`/tickets/${id}/status`, body); return data; diff --git a/app/lib/types.ts b/app/lib/types.ts index f88369b..879c761 100644 --- a/app/lib/types.ts +++ b/app/lib/types.ts @@ -45,6 +45,12 @@ export interface TicketMetricSnapshot { capturedAt: string; } +export interface TicketDetail { + ticket: Ticket; + metricSnapshot: TicketMetricSnapshot | null; + actionLogs: TicketActionLog[]; +} + export interface PodInfo { podName: string; namespace: string; @@ -79,16 +85,3 @@ export interface AlertEvent { podName: string; anomalyType: AnomalyType; } - -export interface UpdateStatusRequest { - status: TicketStatus; - action: string; - memo: string; - performedBy: string; -} - -export interface TicketDetail { - ticket: Ticket; - metricSnapshot: TicketMetricSnapshot | null; - actionLogs: TicketActionLog[]; -} diff --git a/app/tickets/[id]/page.tsx b/app/tickets/[id]/page.tsx index 5cb35df..b238fbf 100644 --- a/app/tickets/[id]/page.tsx +++ b/app/tickets/[id]/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useParams, useRouter } from "next/navigation"; -import { getTicket, getTicketLogs, updateTicketStatus } from "../../lib/api"; -import type { TicketStatus } from "../../lib/types"; +import type { TicketDetail, TicketStatus } from "../../lib/types"; +import { useParams } from "next/navigation"; +import { getTicket, updateTicketStatus } from "../../lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"; import { Select, @@ -15,7 +15,12 @@ import { } from "../../../components/ui/select"; import { Button } from "../../../components/ui/button"; -const STATUS_OPTIONS: TicketStatus[] = ["OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"]; +const STATUS_OPTIONS: TicketStatus[] = [ + "OPEN", + "IN_PROGRESS", + "RESOLVED", + "CLOSED", +]; function InfoRow({ label, value }: { label: string; value?: string | null }) { return ( @@ -26,27 +31,6 @@ function InfoRow({ label, value }: { label: string; value?: string | null }) { ); } -function MetricCell({ - label, - value, - unit = "%", - warn, -}: { - label: string; - value: number | null | undefined; - unit?: string; - warn?: boolean; -}) { - return ( -
-

{label}

-

- {value != null ? `${value}${unit}` : "-"} -

-
- ); -} - function Toast({ message, type }: { message: string; type: "success" | "error" }) { return (
("OPEN"); + const [statusOverride, setStatusOverride] = useState(null); const [action, setAction] = useState(""); const [memo, setMemo] = useState(""); const [performedBy, setPerformedBy] = useState(""); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); - const { data: detail, isLoading, isError } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ["ticket", id], queryFn: () => getTicket(id), }); - const ticket = detail?.ticket; - const snapshot = detail?.metricSnapshot; + const ticket = data?.ticket; + const metricSnapshot = data?.metricSnapshot; + const actionLogs = data?.actionLogs ?? []; - useEffect(() => { - if (ticket) setStatus(ticket.status); - }, [ticket]); - - const { data: logs } = useQuery({ - queryKey: ["ticket-logs", id], - queryFn: () => getTicketLogs(id), - }); + const status = statusOverride ?? ticket?.status ?? "OPEN"; const mutation = useMutation({ mutationFn: () => updateTicketStatus(id, { status, action, memo, performedBy }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["ticket", id] }); - queryClient.invalidateQueries({ queryKey: ["ticket-logs", id] }); queryClient.invalidateQueries({ queryKey: ["tickets"] }); + setStatusOverride(null); setAction(""); setMemo(""); setPerformedBy(""); @@ -136,17 +113,9 @@ export default function TicketDetailPage() { return (
-
- -

- [{ticket.ticketNumber}] {ticket.title} -

-
+

+ [{ticket.ticketNumber}] {ticket.title} +

{/* 왼쪽 영역 */} @@ -218,19 +187,19 @@ export default function TicketDetailPage() { 메트릭 스냅샷 - {snapshot ? ( -
- 90} /> - 85} /> - = 3} /> - 10} /> -
- ) : ( -
- ticket.threshold} /> - -
- )} +
+ {[ + { label: "CPU", value: metricSnapshot ? `${metricSnapshot.cpu}%` : `${ticket.metricValue ?? "-"}%` }, + { label: "Threshold", value: `${ticket.threshold ?? "-"}%` }, + { label: "Severity", value: ticket.severity }, + { label: "Assignee", value: ticket.assigneeName || "-" }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
@@ -241,7 +210,10 @@ export default function TicketDetailPage() {
- setStatusOverride(v as TicketStatus)} + > @@ -303,11 +275,11 @@ export default function TicketDetailPage() { 조치 이력 - {!logs || logs.length === 0 ? ( + {actionLogs.length === 0 ? (

이력이 없습니다

) : (
    - {logs.map((log) => ( + {actionLogs.map((log) => (
  1. diff --git a/app/tickets/page.tsx b/app/tickets/page.tsx index 4c7db25..e7a9d02 100644 --- a/app/tickets/page.tsx +++ b/app/tickets/page.tsx @@ -98,11 +98,6 @@ export default function TicketsPage() { ), }); - function handleNewTicketBannerClick() { - setHasNewTicket(false); - refetch(); - } - return (

    티켓

    diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig;