<!-- src/main/resources/static/check.html-->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>Actuator 모니터링</title>
<style>
:root {
--page: #f9f9f7;
--surface: #fcfcfb;
--ink: #0b0b0b;
--ink-2: #52514e;
--muted: #898781;
--grid: #e1e0d9;
--baseline: #c3c2b7;
--border: rgba(11, 11, 11, 0.1);
--series-1: #2a78d6;
--series-2: #1baf7a;
--track: #cde2fb;
--good: #0ca30c;
--warning: #fab219;
--serious: #ec835a;
--critical: #d03b3b;
}
@media (prefers-color-scheme: dark) {
:root {
--page: #0d0d0d;
--surface: #1a1a19;
--ink: #ffffff;
--ink-2: #c3c2b7;
--muted: #898781;
--grid: #2c2c2a;
--baseline: #383835;
--border: rgba(255, 255, 255, 0.1);
--series-1: #3987e5;
--series-2: #199e70;
--track: #184f95;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 28px 24px 48px;
background: var(--page);
color: var(--ink);
font-family:
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
line-height: 1.5;
}
header {
margin-bottom: 20px;
}
h1 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
}
.sub {
margin: 0;
color: var(--ink-2);
font-size: 13px;
}
/* ── 필터 행: 스코프하는 모든 것 위에 한 줄 ── */
.filters {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filters label {
font-size: 12px;
color: var(--ink-2);
margin-right: 2px;
}
select,
.btn {
padding: 6px 10px;
font: inherit;
font-size: 13px;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.btn:hover,
select:hover {
border-color: var(--baseline);
}
.live {
font-size: 12px;
color: var(--muted);
margin-left: auto;
}
/* ── 카드 ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
}
.card-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 4px;
}
.card-title {
font-size: 14px;
font-weight: 600;
margin: 0;
}
.card-sub {
font-size: 12px;
color: var(--muted);
margin: 0;
}
.card-head .btn {
margin-left: auto;
padding: 3px 9px;
font-size: 11px;
border-radius: 6px;
}
.grid {
display: grid;
gap: 16px;
margin-bottom: 16px;
}
.g-kpi {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.g-two {
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
}
/* ── 스탯 타일 ── */
.tile .label {
font-size: 12px;
color: var(--ink-2);
}
.tile .value {
font-size: 28px;
font-weight: 600;
margin-top: 2px;
letter-spacing: -0.01em;
}
.tile .foot {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
/* ── 상태 칩 (아이콘 + 라벨 + 색: 색 단독으로 의미를 전달하지 않음) ── */
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px 5px 9px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
color: var(--ink-2);
background: var(--surface);
}
.chip .ico {
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.chip .nm {
color: var(--ink);
font-weight: 500;
}
/* ── 표 보기 ── */
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
margin-top: 10px;
}
th,
td {
text-align: right;
padding: 5px 8px;
border-bottom: 1px solid var(--grid);
font-variant-numeric: tabular-nums;
}
th:first-child,
td:first-child {
text-align: left;
font-variant-numeric: normal;
}
th {
color: var(--muted);
font-weight: 500;
}
.tablewrap {
max-height: 240px;
overflow: auto;
}
.hidden {
display: none;
}
/* ── 범례 ── */
.legend {
display: flex;
gap: 16px;
margin-top: 2px;
}
.legend .key {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--ink-2);
}
.legend .swatch-line {
width: 14px;
height: 2px;
border-radius: 1px;
}
/* ── 툴팁 ── */
.tip {
position: fixed;
pointer-events: none;
z-index: 10;
min-width: 130px;
padding: 8px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14);
font-size: 12px;
opacity: 0;
transition: opacity 0.1s;
}
.tip.on {
opacity: 1;
}
.tip .t-head {
color: var(--muted);
margin-bottom: 5px;
font-variant-numeric: tabular-nums;
}
.tip .t-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
}
.tip .t-key {
width: 12px;
height: 2px;
border-radius: 1px;
flex: none;
}
.tip .t-name {
color: var(--ink-2);
}
.tip .t-val {
margin-left: auto;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
svg {
display: block;
width: 100%;
}
svg text {
font-family:
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
}
details.raw {
margin-top: 16px;
}
details.raw summary {
cursor: pointer;
font-size: 13px;
color: var(--ink-2);
padding: 6px 0;
}
.raw-grid {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
margin-top: 10px;
}
.ep {
display: block;
width: 100%;
padding: 9px 12px;
border: 0;
border-bottom: 1px solid var(--grid);
background: transparent;
color: var(--ink);
text-align: left;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.ep:last-child {
border-bottom: 0;
}
.ep:hover {
background: var(--page);
}
.ep .p {
font-family: ui-monospace, Menlo, monospace;
font-size: 12.5px;
}
.ep .d {
font-size: 11.5px;
color: var(--muted);
}
pre {
margin: 0;
padding: 12px;
max-height: 380px;
overflow: auto;
background: var(--page);
border: 1px solid var(--border);
border-radius: 8px;
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
color: var(--ink-2);
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<header>
<h1>Actuator 모니터링</h1>
<p class="sub">Spring Boot Actuator 지표를 차트로 확인합니다. 인증: aaa / bbb</p>
</header>
<!-- 필터 한 줄: 아래 모든 카드에 동일하게 적용된다 -->
<div class="filters">
<label for="interval">갱신 주기</label>
<select id="interval">
<option value="1000">1초</option>
<option
value="2000"
selected
>
2초
</option>
<option value="5000">5초</option>
<option value="0">중지</option>
</select>
<label for="window">표시 구간</label>
<select id="window">
<option
value="30"
selected
>
최근 30포인트
</option>
<option value="60">최근 60포인트</option>
<option value="120">최근 120포인트</option>
</select>
<button
class="btn"
id="refreshBtn"
>
지금 갱신
</button>
<span
class="live"
id="live"
>—</span
>
</div>
<!-- KPI -->
<div
class="grid g-kpi"
id="kpi"
></div>
<!-- 헬스 -->
<div
class="grid"
style="grid-template-columns:1fr;"
>
<div class="card">
<div class="card-head">
<h2 class="card-title">서비스 상태</h2>
<p class="card-sub">/actuator/health</p>
</div>
<div
class="chips"
id="healthChips"
></div>
</div>
</div>
<!-- 시계열 -->
<div class="grid g-two">
<div class="card">
<div class="card-head">
<h2 class="card-title">JVM 메모리 사용량</h2>
<p class="card-sub">MB</p>
<button
class="btn"
data-toggle="memTable"
>
표로 보기
</button>
</div>
<div
class="legend"
id="memLegend"
></div>
<svg
id="memChart"
height="220"
role="img"
aria-label="JVM 메모리 사용량 추이"
></svg>
<div
class="tablewrap hidden"
id="memTable"
></div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">CPU 사용률</h2>
<p class="card-sub">%</p>
<button
class="btn"
data-toggle="cpuTable"
>
표로 보기
</button>
</div>
<div
class="legend"
id="cpuLegend"
></div>
<svg
id="cpuChart"
height="220"
role="img"
aria-label="CPU 사용률 추이"
></svg>
<div
class="tablewrap hidden"
id="cpuTable"
></div>
</div>
</div>
<!-- 분포 -->
<div class="grid g-two">
<div class="card">
<div class="card-head">
<h2 class="card-title">메모리 영역별 사용량</h2>
<p class="card-sub">MB · jvm.memory.used</p>
<button
class="btn"
data-toggle="poolTable"
>
표로 보기
</button>
</div>
<svg
id="poolChart"
height="220"
role="img"
aria-label="메모리 영역별 사용량"
></svg>
<div
class="tablewrap hidden"
id="poolTable"
></div>
</div>
<div class="card">
<div class="card-head">
<h2 class="card-title">로그 레벨 분포</h2>
<p class="card-sub">로거 수 · 실제 적용 레벨(effectiveLevel) 기준</p>
<button
class="btn"
data-toggle="logTable"
>
표로 보기
</button>
</div>
<svg
id="logChart"
height="220"
role="img"
aria-label="로그 레벨 분포"
></svg>
<div
class="tablewrap hidden"
id="logTable"
></div>
</div>
</div>
<!-- 디스크 -->
<div
class="grid"
style="grid-template-columns:1fr;"
>
<div class="card">
<div class="card-head">
<h2 class="card-title">디스크 사용률</h2>
<p class="card-sub">/actuator/health · diskSpace</p>
</div>
<svg
id="diskMeter"
height="72"
role="img"
aria-label="디스크 사용률"
></svg>
</div>
</div>
<!-- 원본 JSON -->
<details class="raw">
<summary>원본 JSON 보기 (8개 엔드포인트)</summary>
<div class="raw-grid">
<div
class="card"
style="padding:0;overflow:hidden;"
id="epList"
></div>
<pre id="rawOut">왼쪽에서 엔드포인트를 선택하세요.</pre>
</div>
</details>
<div
class="tip"
id="tip"
></div>
<script>
"use strict";
const BASE = location.pathname.replace(/\/check\.html$/, "") + "/actuator";
/* ─────────── 색 토큰 (검증된 팔레트) ─────────── */
const css = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const C = () => ({
s1: css("--series-1"),
s2: css("--series-2"),
surface: css("--surface"),
grid: css("--grid"),
baseline: css("--baseline"),
muted: css("--muted"),
ink2: css("--ink-2"),
track: css("--track"),
good: css("--good"),
warning: css("--warning"),
critical: css("--critical"),
});
/* ─────────── 상태 (시계열 버퍼) ─────────── */
const MAXBUF = 120;
const hist = { t: [], heap: [], nonheap: [], sys: [], proc: [] };
let windowSize = 30;
let timer = null;
/* ─────────── 유틸 ─────────── */
const mb = (b) => b / 1024 / 1024;
const fmt = (v, d = 0) => v.toLocaleString("ko-KR", { minimumFractionDigits: d, maximumFractionDigits: d });
const clock = (d) => d.toTimeString().slice(0, 8);
function uptimeText(sec) {
const h = Math.floor(sec / 3600),
m = Math.floor((sec % 3600) / 60),
s = Math.floor(sec % 60);
if (h) return `${h}시간 ${m}분`;
if (m) return `${m}분 ${s}초`;
return `${s}초`;
}
async function get(path) {
const r = await fetch(BASE + path);
if (!r.ok) throw new Error(path + " → HTTP " + r.status);
return r.json();
}
async function metric(name, tag) {
const d = await get("/metrics/" + name + (tag ? "?tag=" + tag : ""));
return d.measurements[0].value;
}
async function metricCount(name) {
const d = await get("/metrics/" + name);
const c = d.measurements.find((m) => m.statistic === "COUNT");
return c ? c.value : d.measurements[0].value;
}
/* 축 눈금을 깔끔한 수(1/2/5×10ⁿ)로 맞춘다 */
function niceStep(raw) {
const exp = Math.pow(10, Math.floor(Math.log10(raw)));
const f = raw / exp;
return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * exp;
}
function niceScale(max, ticks) {
if (max <= 0) return { max: ticks, step: 1 };
const step = niceStep(max / ticks);
return { max: step * ticks, step };
}
/* 라벨을 그리기 전에 실제 렌더 폭을 잰다 (클리핑 방지) */
const _ctx = document.createElement("canvas").getContext("2d");
function measureText(s, px) {
_ctx.font = `${px}px system-ui, -apple-system, "Segoe UI", sans-serif`;
return _ctx.measureText(s).width;
}
const NS = "http://www.w3.org/2000/svg";
function el(tag, attrs = {}) {
const n = document.createElementNS(NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function clear(svg) {
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
/* ─────────── 툴팁 ─────────── */
const tipEl = document.getElementById("tip");
function showTip(x, y, head, rows) {
tipEl.textContent = "";
const h = document.createElement("div");
h.className = "t-head";
h.textContent = head;
tipEl.appendChild(h);
rows.forEach((r) => {
const row = document.createElement("div");
row.className = "t-row";
if (r.color) {
const k = document.createElement("span");
k.className = "t-key";
k.style.background = r.color;
row.appendChild(k);
}
const nm = document.createElement("span");
nm.className = "t-name";
nm.textContent = r.name; // 외부 데이터 → textContent
const vl = document.createElement("span");
vl.className = "t-val";
vl.textContent = r.value;
row.append(nm, vl);
tipEl.appendChild(row);
});
tipEl.classList.add("on");
const w = tipEl.offsetWidth,
hh = tipEl.offsetHeight;
tipEl.style.left = Math.min(x + 14, innerWidth - w - 8) + "px";
tipEl.style.top = Math.max(8, y - hh - 12) + "px";
}
const hideTip = () => tipEl.classList.remove("on");
/* ─────────── 라인 차트 ─────────── */
function lineChart(svg, series, times, opt) {
const c = C();
const W = svg.clientWidth || 520,
H = +svg.getAttribute("height");
const P = { t: 14, r: 62, b: 26, l: 46 };
const pw = W - P.l - P.r,
ph = H - P.t - P.b;
clear(svg);
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
const n = times.length;
if (!n) return;
const TICKS = 4;
let peak = 0;
series.forEach((s) =>
s.data.forEach((v) => {
if (v > peak) peak = v;
}),
);
// 축 상단은 깔끔한 수로 (0 / 50 / 100 …)
const max = opt.fixedMax != null ? opt.fixedMax : niceScale(Math.max(peak * 1.1, opt.minMax || 1), TICKS).max;
const X = (i) => P.l + (n === 1 ? pw : (i / (n - 1)) * pw);
const Y = (v) => P.t + ph - (v / max) * ph;
// 그리드: 실선 헤어라인, 후퇴색
for (let i = 0; i <= TICKS; i++) {
const v = (max * i) / TICKS,
y = Y(v);
svg.appendChild(el("line", { x1: P.l, x2: P.l + pw, y1: y, y2: y, stroke: c.grid, "stroke-width": 1 }));
const tx = el("text", {
x: P.l - 8,
y: y + 4,
"text-anchor": "end",
"font-size": 10.5,
fill: c.muted,
style: "font-variant-numeric:tabular-nums",
});
tx.textContent = fmt(v, opt.decimals || 0);
svg.appendChild(tx);
}
// 베이스라인
svg.appendChild(
el("line", { x1: P.l, x2: P.l + pw, y1: P.t + ph, y2: P.t + ph, stroke: c.baseline, "stroke-width": 1 }),
);
// x축 라벨 (양 끝 + 중앙)
[0, Math.floor((n - 1) / 2), n - 1]
.filter((v, i, a) => a.indexOf(v) === i)
.forEach((i) => {
const tx = el("text", {
x: X(i),
y: H - 8,
"text-anchor": i === 0 ? "start" : i === n - 1 ? "end" : "middle",
"font-size": 10.5,
fill: c.muted,
style: "font-variant-numeric:tabular-nums",
});
tx.textContent = times[i];
svg.appendChild(tx);
});
// 선 (2px) + 끝점 마커(8px, 2px 서피스 링) + 끝 직접 라벨
series.forEach((s) => {
const d = s.data.map((v, i) => `${i ? "L" : "M"}${X(i)},${Y(v)}`).join(" ");
svg.appendChild(
el("path", {
d,
fill: "none",
stroke: s.color,
"stroke-width": 2,
"stroke-linejoin": "round",
"stroke-linecap": "round",
}),
);
const li = n - 1,
lv = s.data[li];
svg.appendChild(
el("circle", { cx: X(li), cy: Y(lv), r: 4, fill: s.color, stroke: c.surface, "stroke-width": 2 }),
);
const lb = el("text", {
x: X(li) + 9,
y: Y(lv) + 4,
"font-size": 11,
fill: c.ink2,
style: "font-variant-numeric:tabular-nums",
});
lb.textContent = fmt(lv, opt.decimals || 0) + (opt.unit || "");
svg.appendChild(lb);
});
// 크로스헤어 + 히트 레이어
const cross = el("line", { y1: P.t, y2: P.t + ph, stroke: c.baseline, "stroke-width": 1, opacity: 0 });
svg.appendChild(cross);
const dots = series.map((s) => {
const d = el("circle", { r: 4, fill: s.color, stroke: c.surface, "stroke-width": 2, opacity: 0 });
svg.appendChild(d);
return d;
});
const hit = el("rect", { x: P.l, y: P.t, width: pw, height: ph, fill: "transparent" });
svg.appendChild(hit);
hit.addEventListener("pointermove", (e) => {
const r = svg.getBoundingClientRect();
const rel = (e.clientX - r.left) * (W / r.width);
let i = n === 1 ? 0 : Math.round(((rel - P.l) / pw) * (n - 1));
i = Math.max(0, Math.min(n - 1, i));
cross.setAttribute("x1", X(i));
cross.setAttribute("x2", X(i));
cross.setAttribute("opacity", 1);
series.forEach((s, k) => {
dots[k].setAttribute("cx", X(i));
dots[k].setAttribute("cy", Y(s.data[i]));
dots[k].setAttribute("opacity", 1);
});
showTip(
e.clientX,
e.clientY,
times[i],
series.map((s) => ({
color: s.color,
name: s.name,
value: fmt(s.data[i], opt.decimals || 0) + (opt.unit || ""),
})),
);
});
hit.addEventListener("pointerleave", () => {
cross.setAttribute("opacity", 0);
dots.forEach((d) => d.setAttribute("opacity", 0));
hideTip();
});
}
function legend(box, series) {
box.textContent = "";
series.forEach((s) => {
const k = document.createElement("span");
k.className = "key";
const sw = document.createElement("span");
sw.className = "swatch-line";
sw.style.background = s.color;
const nm = document.createElement("span");
nm.textContent = s.name;
k.append(sw, nm);
box.appendChild(k);
});
}
/* ─────────── 가로 막대 (명목형 → 단일 색) ─────────── */
function barChart(svg, rows, opt) {
const c = C();
const W = svg.clientWidth || 520,
H = +svg.getAttribute("height");
clear(svg);
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
if (!rows.length) return;
// 가장 긴 라벨이 잘리지 않도록 좌측 여백을 실제 텍스트 폭으로 계산한다
const longest = rows.reduce((a, r) => (r.name.length > a.length ? r.name : a), "");
const labelW = Math.min(measureText(longest, 11), W * 0.42);
const P = { t: 8, r: 66, b: 8, l: Math.ceil(labelW) + 14 };
const pw = W - P.l - P.r,
ph = H - P.t - P.b;
const max = Math.max(...rows.map((r) => r.value)) || 1;
const band = ph / rows.length;
const TH = Math.min(18, band - 8); // ≤24px, 밴드 여백 확보
const X = (v) => (v / max) * pw;
// 베이스라인
svg.appendChild(el("line", { x1: P.l, x2: P.l, y1: P.t, y2: P.t + ph, stroke: c.baseline, "stroke-width": 1 }));
rows.forEach((r, i) => {
const y = P.t + i * band + (band - TH) / 2;
const w = Math.max(X(r.value), 2);
// 데이터 끝 4px 라운드, 베이스라인 쪽은 직각
const g = el("g");
const path = el("path", {
d: `M${P.l},${y} H${P.l + w - 4} a4,4 0 0 1 4,4 V${y + TH - 4} a4,4 0 0 1 -4,4 H${P.l} Z`,
fill: opt.color,
});
g.appendChild(path);
const nm = el("text", {
x: P.l - 10,
y: y + TH / 2 + 4,
"text-anchor": "end",
"font-size": 11,
fill: c.ink2,
});
nm.textContent = r.name; // 외부 데이터 → textContent
g.appendChild(nm);
const vl = el("text", {
x: P.l + w + 8,
y: y + TH / 2 + 4,
"font-size": 11,
fill: c.ink2,
style: "font-variant-numeric:tabular-nums",
});
vl.textContent = fmt(r.value, opt.decimals || 0) + (opt.unit || "");
g.appendChild(vl);
// 히트 영역은 마크보다 크게
const hit = el("rect", { x: P.l, y: P.t + i * band, width: pw + 60, height: band, fill: "transparent" });
hit.addEventListener("pointermove", (e) => {
path.setAttribute("opacity", 0.78);
showTip(e.clientX, e.clientY, r.name, [
{ color: opt.color, name: opt.metric, value: fmt(r.value, opt.decimals || 0) + (opt.unit || "") },
]);
});
hit.addEventListener("pointerleave", () => {
path.setAttribute("opacity", 1);
hideTip();
});
g.appendChild(hit);
svg.appendChild(g);
});
}
/* ─────────── 미터 (단일 비율 vs 한계) ─────────── */
function meter(svg, used, total) {
const c = C();
const W = svg.clientWidth || 800,
H = +svg.getAttribute("height");
clear(svg);
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
const pct = (used / total) * 100;
const fill = pct >= 90 ? c.critical : pct >= 80 ? c.warning : c.s1;
const x = 0,
y = 30,
w = W,
h = 14;
svg.appendChild(el("rect", { x, y, width: w, height: h, rx: 7, fill: c.track }));
svg.appendChild(el("rect", { x, y, width: Math.max((w * pct) / 100, 8), height: h, rx: 7, fill }));
const lb = el("text", { x: 0, y: 18, "font-size": 12, fill: c.ink2 });
lb.textContent = `사용 ${fmt(used / 1e9, 1)}GB / 전체 ${fmt(total / 1e9, 1)}GB`;
svg.appendChild(lb);
const pv = el("text", {
x: W,
y: 18,
"text-anchor": "end",
"font-size": 12,
fill: c.ink2,
style: "font-variant-numeric:tabular-nums",
});
pv.textContent = fmt(pct, 1) + "%";
svg.appendChild(pv);
const rm = el("text", { x: W, y: y + h + 16, "text-anchor": "end", "font-size": 11, fill: c.muted });
rm.textContent = `여유 ${fmt((total - used) / 1e9, 1)}GB`;
svg.appendChild(rm);
}
/* ─────────── 표 보기 ─────────── */
function renderTable(box, cols, rows) {
box.textContent = "";
const t = document.createElement("table");
const thead = document.createElement("thead");
const hr = document.createElement("tr");
cols.forEach((cname) => {
const th = document.createElement("th");
th.textContent = cname;
hr.appendChild(th);
});
thead.appendChild(hr);
const tb = document.createElement("tbody");
rows.forEach((r) => {
const tr = document.createElement("tr");
r.forEach((v) => {
const td = document.createElement("td");
td.textContent = v;
tr.appendChild(td);
});
tb.appendChild(tr);
});
t.append(thead, tb);
box.appendChild(t);
}
document.querySelectorAll("[data-toggle]").forEach((b) => {
b.onclick = () => {
const box = document.getElementById(b.dataset.toggle);
const on = box.classList.toggle("hidden");
b.textContent = on ? "표로 보기" : "표 숨기기";
};
});
/* ─────────── 스탯 타일 ─────────── */
function tiles(items) {
const box = document.getElementById("kpi");
box.textContent = "";
items.forEach((it) => {
const d = document.createElement("div");
d.className = "card tile";
const l = document.createElement("div");
l.className = "label";
l.textContent = it.label;
const v = document.createElement("div");
v.className = "value";
v.textContent = it.value;
const f = document.createElement("div");
f.className = "foot";
f.textContent = it.foot || "";
d.append(l, v, f);
box.appendChild(d);
});
}
/* ─────────── 헬스 칩 (아이콘 + 라벨 + 색) ─────────── */
function healthChips(health) {
const c = C();
const box = document.getElementById("healthChips");
box.textContent = "";
const mark = (s) =>
s === "UP"
? { ico: "✔", col: c.good }
: s === "UNKNOWN"
? { ico: "!", col: c.warning }
: { ico: "✕", col: c.critical };
const all = [
["전체", health.status],
...Object.entries(health.components || {}).map(([k, v]) => [k, v.status]),
];
all.forEach(([name, status]) => {
const m = mark(status);
const chip = document.createElement("span");
chip.className = "chip";
const i = document.createElement("span");
i.className = "ico";
i.style.color = m.col;
i.textContent = m.ico;
const n = document.createElement("span");
n.className = "nm";
n.textContent = name;
const s = document.createElement("span");
s.textContent = status;
chip.append(i, n, s);
box.appendChild(chip);
});
}
/* ─────────── 데이터 수집 & 렌더 ─────────── */
let poolTags = [];
async function tick() {
const c = C();
try {
const [health, heap, nonheap, heapMax, sys, proc, up, thr, dmn] = await Promise.all([
get("/health"),
metric("jvm.memory.used", "area:heap"),
metric("jvm.memory.used", "area:nonheap"),
metric("jvm.memory.max", "area:heap"),
metric("system.cpu.usage"),
metric("process.cpu.usage"),
metric("process.uptime"),
metric("jvm.threads.live"),
metric("jvm.threads.daemon"),
]);
const reqs = await metricCount("http.server.requests").catch(() => 0);
// 버퍼 적재
hist.t.push(clock(new Date()));
hist.heap.push(mb(heap));
hist.nonheap.push(mb(nonheap));
hist.sys.push(sys * 100);
hist.proc.push(proc * 100);
for (const k in hist) if (hist[k].length > MAXBUF) hist[k].shift();
const N = Math.min(windowSize, hist.t.length);
const sl = (a) => a.slice(-N);
const times = sl(hist.t);
// KPI
tiles([
{ label: "가동 시간", value: uptimeText(up), foot: "process.uptime" },
{
label: "힙 사용",
value: fmt(mb(heap)) + " MB",
foot: `최대 ${fmt(mb(heapMax))} MB · ${fmt((heap / heapMax) * 100, 1)}%`,
},
{ label: "스레드", value: fmt(thr), foot: `데몬 ${fmt(dmn)}` },
{ label: "HTTP 요청", value: fmt(reqs), foot: "http.server.requests" },
]);
healthChips(health);
// 메모리 시계열 (두 계열 · 단일 축 MB)
const memS = [
{ name: "heap", color: c.s1, data: sl(hist.heap) },
{ name: "nonheap", color: c.s2, data: sl(hist.nonheap) },
];
legend(document.getElementById("memLegend"), memS);
lineChart(document.getElementById("memChart"), memS, times, { unit: "", decimals: 0, minMax: 64 });
renderTable(
document.getElementById("memTable"),
["시각", "heap (MB)", "nonheap (MB)"],
times.map((t, i) => [t, fmt(memS[0].data[i]), fmt(memS[1].data[i])]).reverse(),
);
// CPU 시계열 (두 계열 · 단일 축 %)
const cpuS = [
{ name: "system", color: c.s1, data: sl(hist.sys) },
{ name: "process", color: c.s2, data: sl(hist.proc) },
];
legend(document.getElementById("cpuLegend"), cpuS);
lineChart(document.getElementById("cpuChart"), cpuS, times, { unit: "%", decimals: 1, fixedMax: 100 });
renderTable(
document.getElementById("cpuTable"),
["시각", "system (%)", "process (%)"],
times.map((t, i) => [t, fmt(cpuS[0].data[i], 1), fmt(cpuS[1].data[i], 1)]).reverse(),
);
// 메모리 영역별 (명목형 → 단일 색)
if (!poolTags.length) {
const d = await get("/metrics/jvm.memory.used");
poolTags = (d.availableTags.find((t) => t.tag === "id") || { values: [] }).values;
}
const pools = await Promise.all(
poolTags.map(async (id) => ({
name: id,
value: mb(await metric("jvm.memory.used", "id:" + encodeURIComponent(id))),
})),
);
pools.sort((a, b) => b.value - a.value);
barChart(document.getElementById("poolChart"), pools, {
color: c.s1,
unit: "",
decimals: 1,
metric: "jvm.memory.used",
});
renderTable(
document.getElementById("poolTable"),
["영역", "사용 (MB)"],
pools.map((p) => [p.name, fmt(p.value, 1)]),
);
// 디스크 미터
const ds = health.components?.diskSpace?.details;
if (ds) meter(document.getElementById("diskMeter"), ds.total - ds.free, ds.total);
document.getElementById("live").textContent = "갱신 " + clock(new Date());
} catch (e) {
document.getElementById("live").textContent = "오류: " + e.message;
}
}
/* 로그 레벨 분포 — 자주 변하지 않으므로 별도 갱신 */
async function loadLoggers() {
const c = C();
try {
const d = await get("/loggers");
const count = {};
// configuredLevel 은 명시 설정된 몇 개뿐이라, 실제 적용 레벨(effectiveLevel)로 센다
Object.values(d.loggers).forEach((l) => {
const lv = l.effectiveLevel || l.configuredLevel;
if (lv) count[lv] = (count[lv] || 0) + 1;
});
const order = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "OFF"];
const rows = order.filter((k) => count[k]).map((k) => ({ name: k, value: count[k] }));
barChart(document.getElementById("logChart"), rows, { color: c.s1, decimals: 0, metric: "로거 수" });
renderTable(
document.getElementById("logTable"),
["레벨", "로거 수"],
rows.map((r) => [r.name, fmt(r.value)]),
);
} catch (e) {
/* loggers 미노출 시 무시 */
}
}
/* ─────────── 원본 JSON 뷰어 ─────────── */
const ENDPOINTS = [
["/health", "서비스 상태 확인"],
["/env", "Spring 의 모든 properties"],
["/info", "서비스 정보"],
["/metrics", "추적 가능한 지표 목록"],
["/loggers", "로그 레벨 조회/변경"],
["/configprops", "서비스 설정값"],
["/beans", "등록된 Bean 상세"],
["/mappings", "모든 RequestMapping"],
];
const epList = document.getElementById("epList");
const rawOut = document.getElementById("rawOut");
ENDPOINTS.forEach(([p, d]) => {
const b = document.createElement("button");
b.className = "ep";
const pp = document.createElement("div");
pp.className = "p";
pp.textContent = "/actuator" + p;
const dd = document.createElement("div");
dd.className = "d";
dd.textContent = d;
b.append(pp, dd);
b.onclick = async () => {
rawOut.textContent = "불러오는 중…";
try {
rawOut.textContent = JSON.stringify(await get(p), null, 2);
} catch (e) {
rawOut.textContent = e.message;
}
};
epList.appendChild(b);
});
/* ─────────── 구동 ─────────── */
function restart() {
if (timer) clearInterval(timer);
const ms = +document.getElementById("interval").value;
if (ms) timer = setInterval(tick, ms);
}
document.getElementById("interval").onchange = restart;
document.getElementById("window").onchange = (e) => {
windowSize = +e.target.value;
tick();
};
document.getElementById("refreshBtn").onclick = () => {
tick();
loadLoggers();
};
addEventListener("resize", () => tick());
tick();
loadLoggers();
restart();
</script>
</body>
</html>