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
<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
© 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