본문으로 건너뛰기
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 <link rel="stylesheet" href="/css/site-ui.css?v=3">
167 <link rel="stylesheet" href="/css/site-app.css?v=1">
168 </head>
169
170 <body data-ui-page="monitor" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
171 <%- include('partials/site-header', { uiPage: 'monitor' }) %>
172
173 <main id="ui-content" tabindex="-1" class="content-area">
174 <div class="page-title">
175 <div>
176 <h1><i class="bi bi-speedometer2" style="color: var(--accent-color); margin-right: 8px;"></i>통합 모니터링</h1>
177 <p>기준 시각 <span id="generated-at"><%= fmtDate(snapshot.generatedAt) %></span></p>
178 </div>
179 <button class="refresh-btn" type="button" onclick="refreshSnapshot()" title="새로고침">
180 <i class="bi bi-arrow-clockwise"></i>
181 </button>
182 </div>
183
184 <div class="metric-grid">
185 <div class="metric-card">
186 <div class="metric-label">Uptime</div>
187 <div class="metric-value mono" id="uptime"><%= Math.floor(snapshot.process.uptimeSeconds / 60) %>m</div>
188 </div>
189 <div class="metric-card">
190 <div class="metric-label">Server Memory</div>
191 <div class="metric-value mono" id="server-memory"><%= snapshot.process.systemMemoryUsedMb %> / <%= snapshot.process.systemMemoryTotalMb %> MB</div>
192 <div class="item-desc mono" id="server-memory-percent"><%= snapshot.process.systemMemoryUsedPercent %>% used</div>
193 </div>
194 <div class="metric-card">
195 <div class="metric-label">Service Memory</div>
196 <div class="metric-value mono" id="service-memory"><%= snapshot.process.memoryRssMb %> MB</div>
197 <div class="item-desc mono" id="service-heap">heap <%= snapshot.process.memoryHeapUsedMb %> MB</div>
198 </div>
199 <div class="metric-card">
200 <div class="metric-label">CPU</div>
201 <div class="metric-value mono" id="system-cpu"><%= snapshot.process.systemCpuPercent === null ? 'warming up' : snapshot.process.systemCpuPercent + '%' %></div>
202 <div class="item-desc mono" id="service-cpu">service <%= snapshot.process.serviceCpuPercent === null ? 'warming up' : snapshot.process.serviceCpuPercent + '%' %> · <%= snapshot.process.cpuCores %> cores</div>
203 </div>
204 <div class="metric-card">
205 <div class="metric-label">Disk</div>
206 <div class="metric-value mono" id="disk-usage"><%= snapshot.disk.supported ? snapshot.disk.usedMb + ' / ' + snapshot.disk.totalMb + ' MB' : 'unavailable' %></div>
207 <div class="item-desc mono" id="disk-percent"><%= snapshot.disk.supported ? snapshot.disk.usedPercent + '% used · ' + snapshot.disk.path : 'filesystem stats unsupported' %></div>
208 </div>
209 <div class="metric-card">
210 <div class="metric-label">Network</div>
211 <div class="metric-value mono" id="network-rate"><%= snapshot.network.supported ? '↓ ' + (snapshot.network.rxRateKbps === null ? 'warming' : snapshot.network.rxRateKbps + ' KB/s') : 'unavailable' %></div>
212 <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>
213 </div>
214 <div class="metric-card">
215 <div class="metric-label">Data Files</div>
216 <div class="metric-value mono" id="file-count"><%= snapshot.dataSummary.existingFileCount %>/<%= snapshot.dataSummary.fileCount %></div>
217 </div>
218 </div>
219
220 <div class="section-title"><i class="bi bi-toggles"></i>관리자 패널: 기능 토글</div>
221 <div class="monitor-grid">
222 <div class="admin-card">
223 <% snapshot.featureFlags.forEach(function(flag) { %>
224 <div class="monitor-row feature-row" data-feature="<%= flag.key %>">
225 <div>
226 <div class="item-name"><%= flag.name %></div>
227 <div class="item-desc"><%= flag.description %></div>
228 <% if (flag.stateLabel) { %>
229 <div class="feature-state">
230 <span class="badge-monitor <%= flag.stateTone === 'warn' ? 'badge-warn' : 'badge-on' %>"><%= flag.stateLabel %></span>
231 </div>
232 <% } %>
233 </div>
234 <button type="button" class="toggle <%= flag.enabled ? 'on' : '' %>" onclick="toggleFeature('<%= flag.key %>', this)" title="<%= flag.name %>">
235 <span></span>
236 </button>
237 </div>
238 <% }) %>
239 </div>
240
241 <div class="admin-card">
242 <h5><i class="bi bi-plug"></i> 연동 상태</h5>
243 <% snapshot.integrations.forEach(function(item) { %>
244 <div class="monitor-row integration-row">
245 <div>
246 <div class="item-name"><%= item.name %></div>
247 <div class="item-desc"><%= item.configured ? '환경변수 설정됨' : '환경변수 확인 필요' %></div>
248 </div>
249 <div class="status-badges">
250 <span class="badge-monitor <%= item.accessTone === 'warn' ? 'badge-warn' : (item.enabled ? 'badge-on' : 'badge-off') %>"><%= item.accessLabel || (item.enabled ? 'Feature On' : 'Feature Off') %></span>
251 <span class="badge-monitor <%= item.status === 'ready' ? 'badge-on' : 'badge-warn' %>"><%= item.status === 'ready' ? 'Configured' : 'Check Env' %></span>
252 </div>
253 </div>
254 <% }) %>
255 </div>
256 </div>
257
258 <div class="section-title"><i class="bi bi-database"></i>데이터 파일</div>
259 <div class="admin-card">
260 <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:12px;">
261 <h5 style="margin:0;">스토리지 상태</h5>
262 <span class="item-desc mono"><%= snapshot.dataSummary.totalDataSizeKb %> KB</span>
263 </div>
264 <% snapshot.dataFiles.forEach(function(file) { %>
265 <div class="monitor-row file-row">
266 <div>
267 <div class="item-name"><%= file.name %></div>
268 <div class="file-path" title="<%= file.path %>"><%= file.path %></div>
269 </div>
270 <span class="badge-monitor <%= file.exists ? 'badge-on' : 'badge-off' %>"><%= file.exists ? 'Found' : 'Missing' %></span>
271 <span class="mono item-desc"><%= file.records === null ? '-' : file.records %> rows</span>
272 <span class="mono item-desc"><%= file.updatedAt ? fmtDate(file.updatedAt, true) : '-' %></span>
273 </div>
274 <% }) %>
275 </div>
276
277 <div style="margin-top: 30px;">
278 <a href="/hinana/userInfo" class="btn-back">
279 <i class="bi bi-arrow-left"></i> User Info로 돌아가기
280 </a>
281 </div>
282 </main>
283
284 <div class="footer-area">
285 <img src="/image/sign.png" alt="sign"><br>
286 <strong>hinana.moe MONITOR</strong><br>
287 X - @NoctchillHinana<br>
288 &copy; 2024~2026. hinana.moe
289 </div>
290
291 <script>
292 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
293
294 function formatUptime(seconds) {
295 var h = Math.floor(seconds / 3600);
296 var m = Math.floor((seconds % 3600) / 60);
297 return h > 0 ? h + 'h ' + m + 'm' : m + 'm';
298 }
299
300 function formatPercent(value) {
301 return value === null || typeof value === 'undefined' ? 'warming up' : value + '%';
302 }
303
304 function formatRate(value) {
305 return value === null || typeof value === 'undefined' ? 'warming' : value + ' KB/s';
306 }
307
308 async function toggleFeature(key, button) {
309 button.disabled = true;
310 try {
311 var res = await fetch('/api/admin/features/' + encodeURIComponent(key) + '/toggle', {
312 method: 'POST',
313 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
314 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
315 });
316 var data = await res.json();
317 if (!data.success) throw new Error(data.message || 'toggle failed');
318 button.classList.toggle('on', data.enabled);
319 applySnapshot(data.snapshot);
320 } catch (err) {
321 alert('기능 설정을 바꾸지 못했습니다.');
322 } finally {
323 button.disabled = false;
324 }
325 }
326
327 async function refreshSnapshot() {
328 var res = await fetch('/api/admin/monitoring');
329 var data = await res.json();
330 if (data.success) applySnapshot(data.snapshot);
331 }
332
333 function applySnapshot(snapshot) {
334 document.getElementById('generated-at').textContent = new Date(snapshot.generatedAt).toLocaleString();
335 document.getElementById('uptime').textContent = formatUptime(snapshot.process.uptimeSeconds);
336 document.getElementById('server-memory').textContent = snapshot.process.systemMemoryUsedMb + ' / ' + snapshot.process.systemMemoryTotalMb + ' MB';
337 document.getElementById('server-memory-percent').textContent = snapshot.process.systemMemoryUsedPercent + '% used';
338 document.getElementById('service-memory').textContent = snapshot.process.memoryRssMb + ' MB';
339 document.getElementById('service-heap').textContent = 'heap ' + snapshot.process.memoryHeapUsedMb + ' MB';
340 document.getElementById('system-cpu').textContent = formatPercent(snapshot.process.systemCpuPercent);
341 document.getElementById('service-cpu').textContent = 'service ' + formatPercent(snapshot.process.serviceCpuPercent) + ' · ' + snapshot.process.cpuCores + ' cores';
342 document.getElementById('disk-usage').textContent = snapshot.disk.supported ? snapshot.disk.usedMb + ' / ' + snapshot.disk.totalMb + ' MB' : 'unavailable';
343 document.getElementById('disk-percent').textContent = snapshot.disk.supported ? snapshot.disk.usedPercent + '% used · ' + snapshot.disk.path : 'filesystem stats unsupported';
344 document.getElementById('network-rate').textContent = snapshot.network.supported ? '↓ ' + formatRate(snapshot.network.rxRateKbps) : 'unavailable';
345 document.getElementById('network-detail').textContent = snapshot.network.supported
346 ? '↑ ' + formatRate(snapshot.network.txRateKbps) + ' · total ↓ ' + snapshot.network.rxTotalMb + ' MB ↑ ' + snapshot.network.txTotalMb + ' MB'
347 : 'Linux/Docker interface stats only';
348 document.getElementById('file-count').textContent = snapshot.dataSummary.existingFileCount + '/' + snapshot.dataSummary.fileCount;
349 }
350 </script>
351 </body>
352 </html>
353