From e432d31180fd6d70f38aedd43e00bbe182808afb Mon Sep 17 00:00:00 2001 From: yeoniii20 Date: Fri, 16 Jan 2026 17:45:36 +0900 Subject: [PATCH 1/2] feat: Alerts API integration --- app/(files)/alerts/page.tsx | 48 ++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/app/(files)/alerts/page.tsx b/app/(files)/alerts/page.tsx index 36196ef..52cd99d 100644 --- a/app/(files)/alerts/page.tsx +++ b/app/(files)/alerts/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { RefreshCw, Bell, @@ -12,6 +12,12 @@ import { } from "lucide-react"; import { AlertLevel, AlertEntry } from "@/app/types/type"; +const API_BASE = + process.env.NEXT_PUBLIC_ALERTS_API_BASE ?? "http://localhost:4000/api"; +// globalPrefix("api") 여부 확인 + +const ALERTS_URL = `${API_BASE}/alerts`; + const LEVEL_COLOR: Record = { INFO: "#4fc1ff", WARN: "#ffc148", @@ -31,10 +37,31 @@ export default function AlertsPage() { const [search, setSearch] = useState(""); const [isLoading, setIsLoading] = useState(false); - const fetchAlerts = async () => { + // 서버 필터 조금 활용: + // - showAck=false면 ack=false로 서버에서 미확인만 내려받기 + // - 레벨이 딱 1개만 선택됐을 때만 level=WARN 같은 필터 적용 + const buildQueryString = useCallback(() => { + const params = new URLSearchParams(); + + if (!showAck) params.set("ack", "false"); + + const selected = (Object.keys(levels) as AlertLevel[]).filter( + (k) => levels[k] + ); + if (selected.length === 1) params.set("level", selected[0]); + + const qs = params.toString(); + return qs ? `?${qs}` : ""; + }, [levels, showAck]); + + const fetchAlerts = useCallback(async () => { setIsLoading(true); try { - const res = await fetch("/api/alerts"); + const res = await fetch(`${ALERTS_URL}${buildQueryString()}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + cache: "no-store", // 브라우저/프록시 캐시 방지 + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const result = await res.json(); if (result.success) setAlerts(result.data); @@ -43,18 +70,21 @@ export default function AlertsPage() { } finally { setIsLoading(false); } - }; + }, [buildQueryString]); useEffect(() => { fetchAlerts(); const t = setInterval(fetchAlerts, 5000); return () => clearInterval(t); - }, []); + }, [fetchAlerts]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return alerts.filter((a) => { + // 레벨은 여전히 클라이언트에서 다중 선택 필터링 if (!levels[a.level]) return false; + + // showAck=true면 전부 표시, false면 acknowledged는 숨김 if (!showAck && a.acknowledged) return false; if (q === "") return true; @@ -68,13 +98,15 @@ export default function AlertsPage() { const toggleAck = async (id: number, next: boolean) => { try { - const res = await fetch("/api/alerts", { + const res = await fetch(ALERTS_URL, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, acknowledged: next }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const result = await res.json(); + + // Nest 서비스는 { success: true, data: target }를 주고 있어서 기존 로직 그대로 사용 if (result.success) { setAlerts((prev) => prev.map((a) => (a.id === id ? result.data : a))); } @@ -117,7 +149,7 @@ export default function AlertsPage() { const s = samples[Math.floor(Math.random() * samples.length)]; try { - const res = await fetch("/api/alerts", { + const res = await fetch(ALERTS_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(s), @@ -132,7 +164,7 @@ export default function AlertsPage() { const clearAlerts = async () => { try { - const res = await fetch("/api/alerts", { method: "DELETE" }); + const res = await fetch(ALERTS_URL, { method: "DELETE" }); const result = await res.json(); if (result.success) setAlerts([]); } catch (e) { From 786bc6c0c09026b921f7b94c98cd31d76ee2aeba Mon Sep 17 00:00:00 2001 From: yeoniii20 Date: Fri, 16 Jan 2026 17:46:57 +0900 Subject: [PATCH 2/2] feat: Create ui.tsx --- app/(files)/alerts/ui.tsx | 262 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 app/(files)/alerts/ui.tsx diff --git a/app/(files)/alerts/ui.tsx b/app/(files)/alerts/ui.tsx new file mode 100644 index 0000000..36196ef --- /dev/null +++ b/app/(files)/alerts/ui.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + RefreshCw, + Bell, + Filter, + CheckCircle2, + Circle, + Plus, + Trash2, +} from "lucide-react"; +import { AlertLevel, AlertEntry } from "@/app/types/type"; + +const LEVEL_COLOR: Record = { + INFO: "#4fc1ff", + WARN: "#ffc148", + ERROR: "#ff6b6b", + CRITICAL: "#ff4f4f", +}; + +export default function AlertsPage() { + const [alerts, setAlerts] = useState([]); + const [levels, setLevels] = useState>({ + INFO: true, + WARN: true, + ERROR: true, + CRITICAL: true, + }); + const [showAck, setShowAck] = useState(true); + const [search, setSearch] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const fetchAlerts = async () => { + setIsLoading(true); + try { + const res = await fetch("/api/alerts"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const result = await res.json(); + if (result.success) setAlerts(result.data); + } catch (e) { + console.error("❌ Failed to fetch alerts:", e); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchAlerts(); + const t = setInterval(fetchAlerts, 5000); + return () => clearInterval(t); + }, []); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return alerts.filter((a) => { + if (!levels[a.level]) return false; + if (!showAck && a.acknowledged) return false; + + if (q === "") return true; + return ( + a.title.toLowerCase().includes(q) || + a.message.toLowerCase().includes(q) || + (a.source ?? "").toLowerCase().includes(q) + ); + }); + }, [alerts, levels, showAck, search]); + + const toggleAck = async (id: number, next: boolean) => { + try { + const res = await fetch("/api/alerts", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, acknowledged: next }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const result = await res.json(); + if (result.success) { + setAlerts((prev) => prev.map((a) => (a.id === id ? result.data : a))); + } + } catch (e) { + console.error("❌ Ack update failed:", e); + } + }; + + const addRandomAlert = async () => { + const samples: Omit< + AlertEntry, + "id" | "timestamp" | "acknowledged" | "createdAt" + >[] = [ + { + level: "INFO", + title: "Consumer group stable", + message: "Rebalance completed without errors.", + source: "consumer/orders", + }, + { + level: "WARN", + title: "High lag detected", + message: "Lag is rising on topic 'payments'.", + source: "topic/payments", + }, + { + level: "ERROR", + title: "Broker degraded", + message: "Disk IO wait exceeded threshold.", + source: "broker-2.local", + }, + { + level: "CRITICAL", + title: "Service down", + message: "Gateway health check failed consecutively.", + source: "gateway", + }, + ]; + + const s = samples[Math.floor(Math.random() * samples.length)]; + + try { + const res = await fetch("/api/alerts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(s), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const result = await res.json(); + if (result.success) setAlerts((prev) => [...prev, result.data]); + } catch (e) { + console.error("❌ Failed to add alert:", e); + } + }; + + const clearAlerts = async () => { + try { + const res = await fetch("/api/alerts", { method: "DELETE" }); + const result = await res.json(); + if (result.success) setAlerts([]); + } catch (e) { + console.error("❌ Failed to clear alerts:", e); + } + }; + + return ( +
+ {/* Header */} +
+
+ + Alerts + ({alerts.length}) +
+ +
+ + + + + +
+
+ + {/* Filters */} +
+
+ + Level: +
+ + {(["INFO", "WARN", "ERROR", "CRITICAL"] as AlertLevel[]).map((lvl) => ( + + ))} + + + + setSearch(e.target.value)} + placeholder="Search title/source/message..." + className="ml-auto bg-bg-dark border border-border-light px-2 py-1 rounded outline-none text-text-light" + /> +
+ + {/* List */} +
+ {filtered.length === 0 ? ( +
+ No alerts to display +
+ ) : ( + filtered.map((a) => ( +
+ + +
+
+ {a.timestamp} + + [{a.level}] + + {a.title} + {a.source && ( + + @{a.source} + + )} +
+
{a.message}
+
+
+ )) + )} +
+
+ ); +}