Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
view/hinana/monitor.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1">
7 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
8 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
9 <title>Monitor - hinana.moe</title>
10
11 <link rel="stylesheet" href="/vendors/bootstrap/css/bootstrap.min.css" />
12 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
13 <link href="https://fonts.googleapis.com/css?family=Montserrat:400,700" rel="stylesheet" type="text/css">
14 <link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet" type="text/css">
15 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
16
17 <style>
18 :root {
19 --font-family: 'Noto Sans KR', sans-serif;
20 --bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
21 --text-primary: #0f1419; --text-secondary: #536471;
22 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
23 --success-color: #00ba7c; --warning-color: #ffd400;
24 --shadow-sm: 0 1px 2px 0 rgba(15, 20, 25, 0.06);
25 --shadow-md: 0 8px 24px rgba(15, 20, 25, 0.08);
26 }
27 body.dark-mode {
28 --bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
29 --text-primary: #e7e9ea; --text-secondary: #71767b;
30 --accent-color: #1d9bf0; --border-color: #2f3336;
31 --success-color: #00ba7c; --warning-color: #ffd400;
32 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
33 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.7);
34 }
35
36 html, body {
37 margin: 0; min-height: 100vh;
38 font-family: var(--font-family);
39 background-color: var(--bg-main);
40 color: var(--text-primary);
41 }
42 a { text-decoration: none; color: inherit; }
43 * { box-sizing: border-box; }
44
45 .global-header {
46 height: 60px; background-color: rgba(255, 255, 255, 0.9); border-bottom: 1px solid var(--border-color);
47 display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
48 position: sticky; top: 0; z-index: 1000; backdrop-filter: blur(12px);
49 }
50 body.dark-mode .global-header { background-color: rgba(0, 0, 0, 0.86); }
51 .header-brand { display: flex; align-items: center; }
52 .header-logo { height: 28px; width: auto; mix-blend-mode: multiply; }
53 body.dark-mode .header-logo { mix-blend-mode: screen; }
54 .header-nav {
55 position: absolute; left: 50%; transform: translateX(-50%);
56 display: flex; gap: 20px; align-items: center;
57 }
58 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); cursor: pointer; transition: color 0.2s; }
59 .nav-link:hover { color: var(--accent-color); }
60 .nav-link.active { color: var(--text-primary); }
61 .nav-divider { opacity: 0.3; color: var(--text-secondary); }
62 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; }
63 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; transition: color 0.2s; }
64 .icon-btn:hover { color: var(--text-primary); }
65
66 .content-area { max-width: 1100px; margin: 34px auto; padding: 0 20px; }
67 .page-title {
68 display: flex; justify-content: space-between; align-items: flex-end;
69 gap: 16px; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--border-color);
70 }
71 .page-title h1 { font-size: 1.45rem; font-weight: 800; margin: 0; color: var(--text-primary); letter-spacing: 0; }
72 .page-title p { font-size: 0.82rem; color: var(--text-secondary); margin: 5px 0 0; }
73 .section-title {
74 font-size: 1.2rem; font-weight: 700; color: var(--text-primary);
75 margin: 34px 0 15px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);
76 }
77 .section-title i { color: var(--accent-color); margin-right: 8px; }
78 .admin-card {
79 background-color: var(--bg-secondary); border: 1px solid var(--border-color);
80 border-radius: 8px; box-shadow: var(--shadow-sm); padding: 24px 28px; margin-bottom: 15px;
81 }
82 .admin-card h5 { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); margin-bottom: 8px; }
83
84 .metric-grid {
85 display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px;
86 }
87 .metric-card {
88 background: var(--bg-secondary); border: 1px solid var(--border-color);
89 border-radius: 8px; box-shadow: var(--shadow-sm); padding: 20px;
90 }
91 .metric-label { font-size: 0.75rem; font-weight: 800; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
92 .metric-value { margin-top: 8px; font-size: 1.55rem; font-weight: 800; color: var(--accent-color); font-feature-settings: "tnum"; }
93 .monitor-grid { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 15px; align-items: start; }
94
95 .monitor-row {
96 display: grid; gap: 14px; align-items: center;
97 padding: 14px 0; border-bottom: 1px solid var(--border-color);
98 }
99 .monitor-row:last-child { border-bottom: none; padding-bottom: 0; }
100 .feature-row { grid-template-columns: 1fr auto; }
101 .integration-row { grid-template-columns: 1fr auto; }
102 .file-row { grid-template-columns: 1fr 86px 100px 122px; }
103 .item-name { font-size: 0.92rem; font-weight: 800; color: var(--text-primary); }
104 .item-desc, .file-path { font-size: 0.78rem; color: var(--text-secondary); margin-top: 3px; overflow-wrap: anywhere; }
105 .feature-state { margin-top: 8px; }
106 .status-badges { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
107 .file-path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 620px; }
108 .mono { font-feature-settings: "tnum"; font-variant-numeric: tabular-nums; }
109
110 .badge-monitor {
111 display: inline-flex; align-items: center; justify-content: center;
112 min-width: 68px; border-radius: 999px; padding: 5px 9px;
113 font-size: 0.7rem; font-weight: 800; letter-spacing: 0.02em;
114 white-space: nowrap;
115 }
116 .badge-on { background: rgba(0, 186, 124, 0.14); color: var(--success-color); }
117 .badge-warn { background: rgba(255, 212, 0, 0.18); color: #997000; }
118 .badge-off { background: rgba(244, 33, 46, 0.12); color: var(--danger-color); }
119 body.dark-mode .badge-warn { color: var(--warning-color); }
120
121 .toggle {
122 width: 50px; height: 28px; border: 1px solid var(--border-color); border-radius: 999px;
123 background: var(--bg-tertiary); padding: 3px; cursor: pointer; transition: all 0.15s;
124 }
125 .toggle span {
126 display: block; width: 20px; height: 20px; border-radius: 50%;
127 background: var(--text-secondary); transition: all 0.15s;
128 }
129 .toggle.on { background: rgba(29, 155, 240, 0.16); border-color: var(--accent-color); }
130 .toggle.on span { transform: translateX(21px); background: var(--accent-color); }
131 .toggle:disabled { opacity: 0.55; cursor: wait; }
132
133 .btn-back, .refresh-btn {
134 border: 1px solid var(--border-color); background: transparent;
135 color: var(--text-secondary); border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s;
136 }
137 .btn-back { padding: 10px 25px; display: inline-flex; align-items: center; gap: 6px; }
138 .refresh-btn { width: 42px; height: 42px; display: inline-flex; align-items: center; justify-content: center; }
139 .btn-back:hover, .refresh-btn:hover { border-color: var(--accent-color); color: var(--accent-color); }
140
141 .footer-area {
142 text-align: center; padding: 40px 20px; color: var(--text-secondary); font-size: 0.75rem; line-height: 1.8;
143 }
144 .footer-area img { width: 160px; opacity: 0.7; mix-blend-mode: multiply; }
145 body.dark-mode .footer-area img { mix-blend-mode: screen; }
146
147 @media (max-width: 940px) {
148 .metric-grid, .monitor-grid { grid-template-columns: 1fr 1fr; }
149 .monitor-grid { display: grid; }
150 .monitor-grid .admin-card { grid-column: 1 / -1; }
151 }
152 @media (max-width: 768px) {
153 .header-nav { position: static; transform: none; gap: 12px; }
154 .global-header { flex-wrap: wrap; height: auto; padding: 10px 15px; gap: 8px; }
155 .content-area { margin: 22px auto; padding: 0 14px; }
156 .page-title { align-items: flex-start; flex-direction: column; }
157 .metric-grid, .monitor-grid { grid-template-columns: 1fr; }
158 .admin-card, .metric-card { padding: 20px; }
159 .integration-row { grid-template-columns: 1fr; align-items: start; gap: 10px; }
160 .status-badges { justify-content: flex-start; width: 100%; }
161 .status-badges .badge-monitor { min-width: 0; max-width: 100%; }
162 .file-row { grid-template-columns: 1fr auto; }
163 .file-row .mono:last-child { grid-column: 1 / -1; }
164 }
165 </style>
166 </head>
167
168 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
169 <header class="global-header">
170 <div class="header-brand">
171 <a href="/hinana/index">
172 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>" alt="Hinana Archive" class="header-logo">
173 </a>
174 </div>
175 <nav class="header-nav">
176 <a href="/hinana/index" class="nav-link">Archive</a>
177 <a href="/hinana/info" class="nav-link">Info</a>
178 <a href="/hinana/blog" class="nav-link">Blog</a>
179 <a href="/hinana/lounge" class="nav-link">Lounge</a>
180 <span class="nav-divider">|</span>
181 <a href="/hinana/userInfo" class="nav-link">User Info</a>
182 </nav>
183 <div class="header-controls">
184 <form action="/toggle-theme" method="POST" class="d-inline">
185 <button type="submit" class="icon-btn" title="테마 변경">
186 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
187 </button>
188 </form>
189 </div>
190 </header>
191
192 <main class="content-area">
193 <div class="page-title">
194 <div>
195 <h1><i class="bi bi-speedometer2" style="color: var(--accent-color); margin-right: 8px;"></i>통합 모니터링</h1>
196 <p>기준 시각 <span id="generated-at"><%= fmtDate(snapshot.generatedAt) %></span></p>
197 </div>
198 <button class="refresh-btn" type="button" onclick="refreshSnapshot()" title="새로고침">
199 <i class="bi bi-arrow-clockwise"></i>
200 </button>
201 </div>
202
203 <div class="metric-grid">
204 <div class="metric-card">
205 <div class="metric-label">Uptime</div>
206 <div class="metric-value mono" id="uptime"><%= Math.floor(snapshot.process.uptimeSeconds / 60) %>m</div>
207 </div>
208 <div class="metric-card">
209 <div class="metric-label">Server Memory</div>
210 <div class="metric-value mono" id="server-memory"><%= snapshot.process.systemMemoryUsedMb %> / <%= snapshot.process.systemMemoryTotalMb %> MB</div>
211 <div class="item-desc mono" id="server-memory-percent"><%= snapshot.process.systemMemoryUsedPercent %>% used</div>
212 </div>
213 <div class="metric-card">
214 <div class="metric-label">Service Memory</div>
215 <div class="metric-value mono" id="service-memory"><%= snapshot.process.memoryRssMb %> MB</div>
216 <div class="item-desc mono" id="service-heap">heap <%= snapshot.process.memoryHeapUsedMb %> MB</div>
217 </div>
218 <div class="metric-card">
219 <div class="metric-label">CPU</div>
220 <div class="metric-value mono" id="system-cpu"><%= snapshot.process.systemCpuPercent === null ? 'warming up' : snapshot.process.systemCpuPercent + '%' %></div>
221 <div class="item-desc mono" id="service-cpu">service <%= snapshot.process.serviceCpuPercent === null ? 'warming up' : snapshot.process.serviceCpuPercent + '%' %> · <%= snapshot.process.cpuCores %> cores</div>
222 </div>
223 <div class="metric-card">
224 <div class="metric-label">Disk</div>
225 <div class="metric-value mono" id="disk-usage"><%= snapshot.disk.supported ? snapshot.disk.usedMb + ' / ' + snapshot.disk.totalMb + ' MB' : 'unavailable' %></div>
226 <div class="item-desc mono" id="disk-percent"><%= snapshot.disk.supported ? snapshot.disk.usedPercent + '% used · ' + snapshot.disk.path : 'filesystem stats unsupported' %></div>
227 </div>
228 <div class="metric-card">
229 <div class="metric-label">Network</div>
230 <div class="metric-value mono" id="network-rate"><%= snapshot.network.supported ? '↓ ' + (snapshot.network.rxRateKbps === null ? 'warming' : snapshot.network.rxRateKbps + ' KB/s') : 'unavailable' %></div>
231 <div class="item-desc mono" id="network-detail"><%= snapshot.network.supported ? '↑ ' + (snapshot.network.txRateKbps === null ? 'warming' : snapshot.network.txRateKbps + ' KB/s') + ' · total ↓ ' + snapshot.network.rxTotalMb + ' MB ↑ ' + snapshot.network.txTotalMb + ' MB' : 'Linux/Docker interface stats only' %></div>
232 </div>
233 <div class="metric-card">
234 <div class="metric-label">Data Files</div>
235 <div class="metric-value mono" id="file-count"><%= snapshot.dataSummary.existingFileCount %>/<%= snapshot.dataSummary.fileCount %></div>
236 </div>
237 </div>
238
239 <div class="section-title"><i class="bi bi-toggles"></i>관리자 패널: 기능 토글</div>
240 <div class="monitor-grid">
241 <div class="admin-card">
242 <% snapshot.featureFlags.forEach(function(flag) { %>
243 <div class="monitor-row feature-row" data-feature="<%= flag.key %>">
244 <div>
245 <div class="item-name"><%= flag.name %></div>
246 <div class="item-desc"><%= flag.description %></div>
247 <% if (flag.stateLabel) { %>
248 <div class="feature-state">
249 <span class="badge-monitor <%= flag.stateTone === 'warn' ? 'badge-warn' : 'badge-on' %>"><%= flag.stateLabel %></span>
250 </div>
251 <% } %>
252 </div>
253 <button type="button" class="toggle <%= flag.enabled ? 'on' : '' %>" onclick="toggleFeature('<%= flag.key %>', this)" title="<%= flag.name %>">
254 <span></span>
255 </button>
256 </div>
257 <% }) %>
258 </div>
259
260 <div class="admin-card">
261 <h5><i class="bi bi-plug"></i> 연동 상태</h5>
262 <% snapshot.integrations.forEach(function(item) { %>
263 <div class="monitor-row integration-row">
264 <div>
265 <div class="item-name"><%= item.name %></div>
266 <div class="item-desc"><%= item.configured ? '환경변수 설정됨' : '환경변수 확인 필요' %></div>
267 </div>
268 <div class="status-badges">
269 <span class="badge-monitor <%= item.accessTone === 'warn' ? 'badge-warn' : (item.enabled ? 'badge-on' : 'badge-off') %>"><%= item.accessLabel || (item.enabled ? 'Feature On' : 'Feature Off') %></span>
270 <span class="badge-monitor <%= item.status === 'ready' ? 'badge-on' : 'badge-warn' %>"><%= item.status === 'ready' ? 'Configured' : 'Check Env' %></span>
271 </div>
272 </div>
273 <% }) %>
274 </div>
275 </div>
276
277 <div class="section-title"><i class="bi bi-database"></i>데이터 파일</div>
278 <div class="admin-card">
279 <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:12px;">
280 <h5 style="margin:0;">스토리지 상태</h5>
281 <span class="item-desc mono"><%= snapshot.dataSummary.totalDataSizeKb %> KB</span>
282 </div>
283 <% snapshot.dataFiles.forEach(function(file) { %>
284 <div class="monitor-row file-row">
285 <div>
286 <div class="item-name"><%= file.name %></div>
287 <div class="file-path" title="<%= file.path %>"><%= file.path %></div>
288 </div>
289 <span class="badge-monitor <%= file.exists ? 'badge-on' : 'badge-off' %>"><%= file.exists ? 'Found' : 'Missing' %></span>
290 <span class="mono item-desc"><%= file.records === null ? '-' : file.records %> rows</span>
291 <span class="mono item-desc"><%= file.updatedAt ? fmtDate(file.updatedAt, true) : '-' %></span>
292 </div>
293 <% }) %>
294 </div>
295
296 <div style="margin-top: 30px;">
297 <a href="/hinana/userInfo" class="btn-back">
298 <i class="bi bi-arrow-left"></i> User Info로 돌아가기
299 </a>
300 </div>
301 </main>
302
303 <div class="footer-area">
304 <img src="/image/sign.png" alt="sign"><br>
305 <strong>hinana.moe MONITOR</strong><br>
306 X - @NoctchillHinana<br>
307 &copy; 2024~2026. hinana.moe
308 </div>
309
310 <script>
311 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
312
313 function formatUptime(seconds) {
314 var h = Math.floor(seconds / 3600);
315 var m = Math.floor((seconds % 3600) / 60);
316 return h > 0 ? h + 'h ' + m + 'm' : m + 'm';
317 }
318
319 function formatPercent(value) {
320 return value === null || typeof value === 'undefined' ? 'warming up' : value + '%';
321 }
322
323 function formatRate(value) {
324 return value === null || typeof value === 'undefined' ? 'warming' : value + ' KB/s';
325 }
326
327 async function toggleFeature(key, button) {
328 button.disabled = true;
329 try {
330 var res = await fetch('/api/admin/features/' + encodeURIComponent(key) + '/toggle', {
331 method: 'POST',
332 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
333 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
334 });
335 var data = await res.json();
336 if (!data.success) throw new Error(data.message || 'toggle failed');
337 button.classList.toggle('on', data.enabled);
338 applySnapshot(data.snapshot);
339 } catch (err) {
340 alert('기능 설정을 바꾸지 못했습니다.');
341 } finally {
342 button.disabled = false;
343 }
344 }
345
346 async function refreshSnapshot() {
347 var res = await fetch('/api/admin/monitoring');
348 var data = await res.json();
349 if (data.success) applySnapshot(data.snapshot);
350 }
351
352 function applySnapshot(snapshot) {
353 document.getElementById('generated-at').textContent = new Date(snapshot.generatedAt).toLocaleString();
354 document.getElementById('uptime').textContent = formatUptime(snapshot.process.uptimeSeconds);
355 document.getElementById('server-memory').textContent = snapshot.process.systemMemoryUsedMb + ' / ' + snapshot.process.systemMemoryTotalMb + ' MB';
356 document.getElementById('server-memory-percent').textContent = snapshot.process.systemMemoryUsedPercent + '% used';
357 document.getElementById('service-memory').textContent = snapshot.process.memoryRssMb + ' MB';
358 document.getElementById('service-heap').textContent = 'heap ' + snapshot.process.memoryHeapUsedMb + ' MB';
359 document.getElementById('system-cpu').textContent = formatPercent(snapshot.process.systemCpuPercent);
360 document.getElementById('service-cpu').textContent = 'service ' + formatPercent(snapshot.process.serviceCpuPercent) + ' · ' + snapshot.process.cpuCores + ' cores';
361 document.getElementById('disk-usage').textContent = snapshot.disk.supported ? snapshot.disk.usedMb + ' / ' + snapshot.disk.totalMb + ' MB' : 'unavailable';
362 document.getElementById('disk-percent').textContent = snapshot.disk.supported ? snapshot.disk.usedPercent + '% used · ' + snapshot.disk.path : 'filesystem stats unsupported';
363 document.getElementById('network-rate').textContent = snapshot.network.supported ? '↓ ' + formatRate(snapshot.network.rxRateKbps) : 'unavailable';
364 document.getElementById('network-detail').textContent = snapshot.network.supported
365 ? '↑ ' + formatRate(snapshot.network.txRateKbps) + ' · total ↓ ' + snapshot.network.rxTotalMb + ' MB ↑ ' + snapshot.network.txTotalMb + ' MB'
366 : 'Linux/Docker interface stats only';
367 document.getElementById('file-count').textContent = snapshot.dataSummary.existingFileCount + '/' + snapshot.dataSummary.fileCount;
368 }
369 </script>
370 </body>
371 </html>
372