Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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
© 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