Skip to content
Merged

Dev #11

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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"
33 changes: 33 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
17 changes: 7 additions & 10 deletions app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,13 +39,6 @@ export async function getPodEvents(
return data;
}

export async function getTopology(namespace?: string): Promise<unknown> {
const { data } = await api.get("/topology", {
params: namespace ? { namespace } : undefined,
});
return data;
}

export async function getCurrentMetric(pod: string): Promise<CurrentMetric> {
const { data } = await api.get("/metrics/current", { params: { pod } });
return data;
Expand Down Expand Up @@ -88,7 +80,12 @@ export async function getTicket(id: number | string): Promise<TicketDetail> {

export async function updateTicketStatus(
id: number | string,
body: UpdateStatusRequest
body: {
status: string;
action: string;
memo: string;
performedBy: string;
}
): Promise<Ticket> {
const { data } = await api.patch(`/tickets/${id}/status`, body);
return data;
Expand Down
19 changes: 6 additions & 13 deletions app/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
}
106 changes: 39 additions & 67 deletions app/tickets/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
Expand All @@ -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 (
<div className="bg-gray-800 rounded p-3">
<p className="text-xs text-gray-400">{label}</p>
<p className={`text-lg font-bold mt-1 ${warn ? "text-red-400" : "text-white"}`}>
{value != null ? `${value}${unit}` : "-"}
</p>
</div>
);
}

function Toast({ message, type }: { message: string; type: "success" | "error" }) {
return (
<div
Expand All @@ -61,40 +45,33 @@ function Toast({ message, type }: { message: string; type: "success" | "error" }

export default function TicketDetailPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const queryClient = useQueryClient();

const [status, setStatus] = useState<TicketStatus>("OPEN");
const [statusOverride, setStatusOverride] = useState<TicketStatus | null>(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<TicketDetail>({
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("");
Expand Down Expand Up @@ -136,17 +113,9 @@ export default function TicketDetailPage() {

return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
onClick={() => router.back()}
className="text-gray-400 hover:text-white text-sm transition-colors"
>
← 뒤로
</button>
<h1 className="text-2xl font-bold text-white">
[{ticket.ticketNumber}] {ticket.title}
</h1>
</div>
<h1 className="text-2xl font-bold text-white">
[{ticket.ticketNumber}] {ticket.title}
</h1>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 왼쪽 영역 */}
Expand Down Expand Up @@ -218,19 +187,19 @@ export default function TicketDetailPage() {
<CardTitle className="text-sm text-gray-400">메트릭 스냅샷</CardTitle>
</CardHeader>
<CardContent>
{snapshot ? (
<div className="grid grid-cols-2 gap-3">
<MetricCell label="CPU" value={snapshot.cpu} warn={snapshot.cpu > 90} />
<MetricCell label="메모리" value={snapshot.memory} warn={snapshot.memory > 85} />
<MetricCell label="재시작" value={snapshot.restarts} unit="회" warn={snapshot.restarts >= 3} />
<MetricCell label="에러율" value={snapshot.errorRate} warn={snapshot.errorRate > 10} />
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<MetricCell label="감지 값" value={ticket.metricValue} warn={ticket.metricValue > ticket.threshold} />
<MetricCell label="임계치" value={ticket.threshold} />
</div>
)}
<div className="grid grid-cols-2 gap-3">
{[
{ 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 }) => (
<div key={label} className="bg-gray-800 rounded p-3">
<p className="text-xs text-gray-400">{label}</p>
<p className="text-lg font-bold text-white mt-1">{value}</p>
</div>
))}
</div>
</CardContent>
</Card>

Expand All @@ -241,7 +210,10 @@ export default function TicketDetailPage() {
<CardContent className="space-y-3">
<div className="space-y-1">
<label className="text-xs text-gray-400">상태</label>
<Select value={status} onValueChange={(v) => setStatus(v as TicketStatus)}>
<Select
value={status}
onValueChange={(v) => setStatusOverride(v as TicketStatus)}
>
<SelectTrigger className="bg-gray-800 border-gray-700 text-gray-200">
<SelectValue />
</SelectTrigger>
Expand Down Expand Up @@ -303,11 +275,11 @@ export default function TicketDetailPage() {
<CardTitle className="text-sm text-gray-400">조치 이력</CardTitle>
</CardHeader>
<CardContent>
{!logs || logs.length === 0 ? (
{actionLogs.length === 0 ? (
<p className="text-sm text-gray-500">이력이 없습니다</p>
) : (
<ol className="relative border-l border-gray-700 space-y-4 ml-2">
{logs.map((log) => (
{actionLogs.map((log) => (
<li key={log.id} className="ml-4">
<span className="absolute -left-1.5 mt-1 h-3 w-3 rounded-full bg-blue-500" />
<p className="text-xs text-gray-400">
Expand Down
5 changes: 0 additions & 5 deletions app/tickets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,6 @@ export default function TicketsPage() {
),
});

function handleNewTicketBannerClick() {
setHasNewTicket(false);
refetch();
}

return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-white">티켓</h1>
Expand Down
2 changes: 1 addition & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};

export default nextConfig;
Loading