<!-- 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.10);
--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.10);
--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 .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>