Komari 部署与美化完整指南
一、系统环境要求
- 操作系统:Debian 12
- 用户权限:root 或具备 sudo 权限的用户
二、Komari 安装步骤
1. 下载安装脚本
1
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
2. 添加执行权限
1
chmod +x install-komari.sh
3. 执行安装脚本
1
sudo ./install-komari.sh
安装过程中将自动完成依赖安装与系统配置,无需人工干预。
三、查找 Komari 安装目录
安装完成后,可通过以下命令定位 Komari 的实际安装路径:
1
sudo find / -type d -name "komari" 2>/dev/null
默认情况下,Komari 通常安装在:
/opt/komari
四、修改管理员密码
1. 进入 Komari 目录
1
cd /opt/komari
2. 执行密码修改命令
1
./komari chpasswd -p 你的新密码
示例:
1
./komari chpasswd -p Ab890725
3. 重启服务生效
sudo reboot
五、验证安装状态
服务器重启后,可通过浏览器访问 Komari 控制面板:
- 访问地址:http://你的服务器IP:3000(以安装输出为准)
- 默认用户名:admin
- 密码:你刚刚设置的新密码
注意事项:
- 确保防火墙已放行 Komari 使用的端口
- 建议首次登录后立即完成安全配置
- 可定期检查 Komari 日志以确认运行状态
六、Komari 前端美化与功能增强
以下内容为 Komari 探针自定义代码,可用于 隐藏默认元素、固定顶部服务器名称、增加流量进度条、优化价格与流量展示逻辑。
1. 自定义 Head 脚本
将以下代码插入 Komari 页面 Head 区域:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298<script>
(function() {
// 配置
var CONFIG = {
interval: 60000,
apiUrl: '/api/rpc2',
trafficTolerance: 0.10
};
// 注入样式
var style = document.createElement('style');
style.textContent = '.server-footer-name>div:first-child{visibility:hidden!important}.server-footer-theme{display:none!important}';
document.head.appendChild(style);
// 全局配置
window.CustomDesc = "BITJEBE's Node";
window.ShowNetTransfer = true;
window.DisableAnimatedMan = true;
window.FixedTopServerName = true;
// 工具函数
var UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
var UNIT_MAP = { KIB: 1024, MIB: 1048576, GIB: 1073741824, TIB: 1099511627776 };
var CYCLE_MAP = { 30: '月', 92: '季', 184: '半年', 365: '年', 730: '二年', 1095: '三年', 1825: '五年' };
function formatBytes(bytes) {
if (!bytes) return { value: '0', unit: 'B' };
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return { value: (bytes / Math.pow(1024, i)).toFixed(2), unit: UNITS[i] };
}
function formatTime(s) {
if (!s) return 'N/A';
try {
return new Date(s).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) { return 'N/A'; }
}
function getCycleText(c) {
if (!c || c <= 0) return c === -1 || c === 0 ? '一次性' : '年';
for (var days in CYCLE_MAP) {
if (Math.abs(c - days) <= 3) return CYCLE_MAP[days];
}
var y = Math.round(c / 365);
return y >= 1 && y <= 10 ? y + '年' : '年';
}
function calcResetDays(expiredAt) {
if (!expiredAt) return 'N/A';
try {
var day = new Date(expiredAt).getDate(), now = new Date();
var reset = new Date(now.getFullYear(), now.getMonth(), day);
if (reset <= now) reset.setMonth(reset.getMonth() + 1);
return Math.ceil((reset - now) / 86400000) + '日';
} catch (e) { return 'N/A'; }
}
function parseTraffic(text) {
var m = text.match(/([\d.]+)\s*(GiB|MiB|TiB|KiB)/i);
return m ? parseFloat(m[1]) * (UNIT_MAP[m[2].toUpperCase()] || 1) : null;
}
function normName(n) { return n.toUpperCase().replace(/[^A-Z0-9-]/g, ''); }
function getColor(p) { return 'hsl(' + ((100 - p) * 1.4) + ',70%,50%)'; }
// 渲染器
var barCache = new Map();
function createBar(d) {
var uf = formatBytes(d.used), tf = formatBytes(d.limit);
var pc = Math.min(100, d.used / d.limit * 100).toFixed(2);
var div = document.createElement('div');
div.className = 'space-y-1.5 traffic-bar';
div.dataset.uuid = d.uuid;
div.style.width = '100%';
div.innerHTML =
'<div class="flex items-center justify-between">' +
'<div class="flex items-baseline gap-1">' +
'<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-val">' + uf.value + '</span>' +
'<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-unit">' + uf.unit + '</span>' +
'<span class="text-[10px] text-neutral-500 dark:text-neutral-400">/ ' + tf.value + ' ' + tf.unit + '</span>' +
'</div>' +
'<div class="text-[10px] font-medium text-neutral-600 dark:text-neutral-300 percent-val">' + pc + '%</div>' +
'</div>' +
'<div class="relative h-1.5" style="width:100%">' +
'<div class="absolute inset-0 bg-neutral-100 dark:bg-neutral-800 rounded-full"></div>' +
'<div class="absolute inset-0 rounded-full transition-all duration-300 progress-bar" style="width:' + pc + '%;background-color:' + getColor(parseFloat(pc)) + '"></div>' +
'</div>' +
'<div class="flex items-center justify-between">' +
'<div class="text-[10px] text-neutral-500 dark:text-neutral-400 update-time">更新于: ' + formatTime(d.next_update) + '</div>' +
'<div class="text-[10px] text-neutral-500 dark:text-neutral-400 reset-date">距离流量重置: ' + d.reset_date + '</div>' +
'</div>';
return div;
}
function updateBar(el, d) {
var uf = formatBytes(d.used);
var pc = Math.min(100, d.used / d.limit * 100).toFixed(2);
el.querySelector('.used-val').textContent = uf.value;
el.querySelector('.used-unit').textContent = uf.unit;
el.querySelector('.percent-val').textContent = pc + '%';
el.querySelector('.update-time').textContent = '更新于: ' + formatTime(d.next_update);
el.querySelector('.reset-date').textContent = '距离流量重置: ' + d.reset_date;
var bar = el.querySelector('.progress-bar');
bar.style.width = pc + '%';
bar.style.backgroundColor = getColor(parseFloat(pc));
}
function fixPrice(container, d) {
if (!d.price || d.billing_cycle == null) return;
var text = '价格: ' + (d.currency || '$') + d.price + '/' + getCycleText(d.billing_cycle);
var ps = container.getElementsByTagName('p');
for (var i = 0; i < ps.length; i++) {
if (ps[i].textContent.indexOf('价格:') !== -1) ps[i].textContent = text;
}
}
function getCardTraffic(container) {
var divs = container.querySelectorAll('.inline-flex');
var up = null, down = null;
for (var i = 0; i < divs.length; i++) {
var t = divs[i].textContent;
if (t.indexOf('上传') !== -1) up = parseTraffic(t);
else if (t.indexOf('下载') !== -1) down = parseTraffic(t);
}
return up && down ? { up: up, down: down } : null;
}
function matchCard(candidates, d) {
if (candidates.length === 1) return candidates[0];
var best = null, bestDiff = Infinity;
for (var i = 0; i < candidates.length; i++) {
var traffic = getCardTraffic(candidates[i].closest('div'));
if (!traffic) continue;
var upDiff = Math.abs(traffic.up - d.net_total_up) / Math.max(d.net_total_up, 1);
var downDiff = Math.abs(traffic.down - d.net_total_down) / Math.max(d.net_total_down, 1);
if (upDiff < CONFIG.trafficTolerance && downDiff < CONFIG.trafficTolerance) {
var avg = (upDiff + downDiff) / 2;
if (avg < bestDiff) { best = candidates[i]; bestDiff = avg; }
}
}
return best || candidates[0];
}
function render(list) {
var sections = document.querySelectorAll('section.grid.items-center.gap-2');
var used = new Set();
for (var i = 0; i < list.length; i++) {
var d = list[i];
if (!d.limit || !d.used) continue;
var norm = normName(d.name);
var candidates = [];
for (var j = 0; j < sections.length; j++) {
if (used.has(sections[j])) continue;
var nameEl = sections[j].querySelector('p');
if (nameEl && normName(nameEl.textContent.trim()) === norm) candidates.push(sections[j]);
}
if (!candidates.length) continue;
var target = matchCard(candidates, d);
used.add(target);
var container = target.closest('div');
fixPrice(container, d);
// 找到上传下载的 section 作为插入点
var uploadDownloadSec = null;
var allSections = container.querySelectorAll('section.flex.items-center.w-full.justify-between.gap-1');
for (var k = 0; k < allSections.length; k++) {
if (allSections[k].textContent.indexOf('上传:') !== -1 && allSections[k].textContent.indexOf('下载:') !== -1) {
uploadDownloadSec = allSections[k];
break;
}
}
if (!uploadDownloadSec) continue;
// 检查是否已存在进度条(防止重复)
var existingBar = container.querySelector('.traffic-bar[data-uuid="' + d.uuid + '"]');
if (existingBar) {
// 已存在,只更新数据
updateBar(existingBar, d);
} else {
// 不存在,创建新的
var bar = createBar(d);
uploadDownloadSec.parentNode.insertBefore(bar, uploadDownloadSec.nextSibling);
barCache.set(d.uuid, bar);
}
}
}
// 数据管理
var dataCache = null, loading = false;
function rpc(method, params) {
return fetch(CONFIG.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id: Date.now(), method: method, params: params || {}, jsonrpc: '2.0' })
}).then(function(r) { return r.json(); });
}
function calcUsed(up, down, type) {
if (type === 'max') return Math.max(up, down);
if (type === 'min') return Math.min(up, down);
if (type === 'up') return up;
if (type === 'down') return down;
return up + down;
}
function fetchData(cb) {
var now = Date.now();
if (dataCache && now - dataCache.time < CONFIG.interval) { cb(dataCache.data); return; }
if (loading) return;
loading = true;
rpc('common:getNodes').then(function(res) {
var nodes = Object.values(res.result || res.data || {});
return Promise.all(nodes.map(function(n) {
return rpc('common:getNodeRecentStatus', { uuid: n.uuid, limit: 1 }).then(function(sr) {
var rec = ((sr.result || sr.data || {}).records || [])[0] || {};
var up = rec.net_total_up || 0, down = rec.net_total_down || 0;
return {
name: n.name, uuid: n.uuid,
limit: n.traffic_limit || 0,
used: calcUsed(up, down, n.traffic_limit_type || 'sum'),
next_update: rec.time,
reset_date: calcResetDays(n.expired_at),
price: n.price, currency: n.currency, billing_cycle: n.billing_cycle,
net_total_up: up, net_total_down: down
};
}).catch(function() {
return {
name: n.name, uuid: n.uuid, limit: n.traffic_limit || 0, used: 0,
next_update: null, reset_date: calcResetDays(n.expired_at),
price: n.price, currency: n.currency, billing_cycle: n.billing_cycle,
net_total_up: 0, net_total_down: 0
};
});
}));
}).then(function(data) {
dataCache = { time: now, data: data };
cb(data);
}).finally(function() { loading = false; });
}
// 观察器 - 优化防止频繁触发
var observer = null, timer = null, renderPending = false;
function update() { fetchData(render); }
function scheduleRender() {
if (renderPending) return;
renderPending = true;
setTimeout(function() {
renderPending = false;
update();
}, 300);
}
function init() {
if (observer) return;
observer = new MutationObserver(scheduleRender);
observer.observe(document.body, { childList: true, subtree: true });
update();
timer = setInterval(update, CONFIG.interval);
window.addEventListener('beforeunload', function() {
if (observer) observer.disconnect();
if (timer) clearInterval(timer);
barCache.clear();
}, { once: true });
}
// 启动
function tryInit() {
if (document.querySelector('section.grid[class*="grid-cols-"]')) {
requestAnimationFrame(init);
} else {
setTimeout(tryInit, 250);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryInit, { once: true });
} else {
tryInit();
}
})();
</script>2. 自定义 Body 脚本(流量进度条与数据增强)
以下脚本用于动态获取节点流量数据,并在卡片中插入可视化进度条,同时修正价格、流量周期与重置时间展示逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298<script>
(function() {
// 配置
var CONFIG = {
interval: 60000,
apiUrl: '/api/rpc2',
trafficTolerance: 0.10
};
// 注入样式
var style = document.createElement('style');
style.textContent = '.server-footer-name>div:first-child{visibility:hidden!important}.server-footer-theme{display:none!important}';
document.head.appendChild(style);
// 全局配置
window.CustomDesc = "BITJEBE's Node";
window.ShowNetTransfer = true;
window.DisableAnimatedMan = true;
window.FixedTopServerName = true;
// 工具函数
var UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
var UNIT_MAP = { KIB: 1024, MIB: 1048576, GIB: 1073741824, TIB: 1099511627776 };
var CYCLE_MAP = { 30: '月', 92: '季', 184: '半年', 365: '年', 730: '二年', 1095: '三年', 1825: '五年' };
function formatBytes(bytes) {
if (!bytes) return { value: '0', unit: 'B' };
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return { value: (bytes / Math.pow(1024, i)).toFixed(2), unit: UNITS[i] };
}
function formatTime(s) {
if (!s) return 'N/A';
try {
return new Date(s).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) { return 'N/A'; }
}
function getCycleText(c) {
if (!c || c <= 0) return c === -1 || c === 0 ? '一次性' : '年';
for (var days in CYCLE_MAP) {
if (Math.abs(c - days) <= 3) return CYCLE_MAP[days];
}
var y = Math.round(c / 365);
return y >= 1 && y <= 10 ? y + '年' : '年';
}
function calcResetDays(expiredAt) {
if (!expiredAt) return 'N/A';
try {
var day = new Date(expiredAt).getDate(), now = new Date();
var reset = new Date(now.getFullYear(), now.getMonth(), day);
if (reset <= now) reset.setMonth(reset.getMonth() + 1);
return Math.ceil((reset - now) / 86400000) + '日';
} catch (e) { return 'N/A'; }
}
function parseTraffic(text) {
var m = text.match(/([\d.]+)\s*(GiB|MiB|TiB|KiB)/i);
return m ? parseFloat(m[1]) * (UNIT_MAP[m[2].toUpperCase()] || 1) : null;
}
function normName(n) { return n.toUpperCase().replace(/[^A-Z0-9-]/g, ''); }
function getColor(p) { return 'hsl(' + ((100 - p) * 1.4) + ',70%,50%)'; }
// 渲染器
var barCache = new Map();
function createBar(d) {
var uf = formatBytes(d.used), tf = formatBytes(d.limit);
var pc = Math.min(100, d.used / d.limit * 100).toFixed(2);
var div = document.createElement('div');
div.className = 'space-y-1.5 traffic-bar';
div.dataset.uuid = d.uuid;
div.style.width = '100%';
div.innerHTML =
'<div class="flex items-center justify-between">' +
'<div class="flex items-baseline gap-1">' +
'<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-val">' + uf.value + '</span>' +
'<span class="text-[10px] font-medium text-neutral-800 dark:text-neutral-200 used-unit">' + uf.unit + '</span>' +
'<span class="text-[10px] text-neutral-500 dark:text-neutral-400">/ ' + tf.value + ' ' + tf.unit + '</span>' +
'</div>' +
'<div class="text-[10px] font-medium text-neutral-600 dark:text-neutral-300 percent-val">' + pc + '%</div>' +
'</div>' +
'<div class="relative h-1.5" style="width:100%">' +
'<div class="absolute inset-0 bg-neutral-100 dark:bg-neutral-800 rounded-full"></div>' +
'<div class="absolute inset-0 rounded-full transition-all duration-300 progress-bar" style="width:' + pc + '%;background-color:' + getColor(parseFloat(pc)) + '"></div>' +
'</div>' +
'<div class="flex items-center justify-between">' +
'<div class="text-[10px] text-neutral-500 dark:text-neutral-400 update-time">更新于: ' + formatTime(d.next_update) + '</div>' +
'<div class="text-[10px] text-neutral-500 dark:text-neutral-400 reset-date">距离流量重置: ' + d.reset_date + '</div>' +
'</div>';
return div;
}
function updateBar(el, d) {
var uf = formatBytes(d.used);
var pc = Math.min(100, d.used / d.limit * 100).toFixed(2);
el.querySelector('.used-val').textContent = uf.value;
el.querySelector('.used-unit').textContent = uf.unit;
el.querySelector('.percent-val').textContent = pc + '%';
el.querySelector('.update-time').textContent = '更新于: ' + formatTime(d.next_update);
el.querySelector('.reset-date').textContent = '距离流量重置: ' + d.reset_date;
var bar = el.querySelector('.progress-bar');
bar.style.width = pc + '%';
bar.style.backgroundColor = getColor(parseFloat(pc));
}
function fixPrice(container, d) {
if (!d.price || d.billing_cycle == null) return;
var text = '价格: ' + (d.currency || '$') + d.price + '/' + getCycleText(d.billing_cycle);
var ps = container.getElementsByTagName('p');
for (var i = 0; i < ps.length; i++) {
if (ps[i].textContent.indexOf('价格:') !== -1) ps[i].textContent = text;
}
}
function getCardTraffic(container) {
var divs = container.querySelectorAll('.inline-flex');
var up = null, down = null;
for (var i = 0; i < divs.length; i++) {
var t = divs[i].textContent;
if (t.indexOf('上传') !== -1) up = parseTraffic(t);
else if (t.indexOf('下载') !== -1) down = parseTraffic(t);
}
return up && down ? { up: up, down: down } : null;
}
function matchCard(candidates, d) {
if (candidates.length === 1) return candidates[0];
var best = null, bestDiff = Infinity;
for (var i = 0; i < candidates.length; i++) {
var traffic = getCardTraffic(candidates[i].closest('div'));
if (!traffic) continue;
var upDiff = Math.abs(traffic.up - d.net_total_up) / Math.max(d.net_total_up, 1);
var downDiff = Math.abs(traffic.down - d.net_total_down) / Math.max(d.net_total_down, 1);
if (upDiff < CONFIG.trafficTolerance && downDiff < CONFIG.trafficTolerance) {
var avg = (upDiff + downDiff) / 2;
if (avg < bestDiff) { best = candidates[i]; bestDiff = avg; }
}
}
return best || candidates[0];
}
function render(list) {
var sections = document.querySelectorAll('section.grid.items-center.gap-2');
var used = new Set();
for (var i = 0; i < list.length; i++) {
var d = list[i];
if (!d.limit || !d.used) continue;
var norm = normName(d.name);
var candidates = [];
for (var j = 0; j < sections.length; j++) {
if (used.has(sections[j])) continue;
var nameEl = sections[j].querySelector('p');
if (nameEl && normName(nameEl.textContent.trim()) === norm) candidates.push(sections[j]);
}
if (!candidates.length) continue;
var target = matchCard(candidates, d);
used.add(target);
var container = target.closest('div');
fixPrice(container, d);
// 找到上传下载的 section 作为插入点
var uploadDownloadSec = null;
var allSections = container.querySelectorAll('section.flex.items-center.w-full.justify-between.gap-1');
for (var k = 0; k < allSections.length; k++) {
if (allSections[k].textContent.indexOf('上传:') !== -1 && allSections[k].textContent.indexOf('下载:') !== -1) {
uploadDownloadSec = allSections[k];
break;
}
}
if (!uploadDownloadSec) continue;
// 检查是否已存在进度条(防止重复)
var existingBar = container.querySelector('.traffic-bar[data-uuid="' + d.uuid + '"]');
if (existingBar) {
// 已存在,只更新数据
updateBar(existingBar, d);
} else {
// 不存在,创建新的
var bar = createBar(d);
uploadDownloadSec.parentNode.insertBefore(bar, uploadDownloadSec.nextSibling);
barCache.set(d.uuid, bar);
}
}
}
// 数据管理
var dataCache = null, loading = false;
function rpc(method, params) {
return fetch(CONFIG.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id: Date.now(), method: method, params: params || {}, jsonrpc: '2.0' })
}).then(function(r) { return r.json(); });
}
function calcUsed(up, down, type) {
if (type === 'max') return Math.max(up, down);
if (type === 'min') return Math.min(up, down);
if (type === 'up') return up;
if (type === 'down') return down;
return up + down;
}
function fetchData(cb) {
var now = Date.now();
if (dataCache && now - dataCache.time < CONFIG.interval) { cb(dataCache.data); return; }
if (loading) return;
loading = true;
rpc('common:getNodes').then(function(res) {
var nodes = Object.values(res.result || res.data || {});
return Promise.all(nodes.map(function(n) {
return rpc('common:getNodeRecentStatus', { uuid: n.uuid, limit: 1 }).then(function(sr) {
var rec = ((sr.result || sr.data || {}).records || [])[0] || {};
var up = rec.net_total_up || 0, down = rec.net_total_down || 0;
return {
name: n.name, uuid: n.uuid,
limit: n.traffic_limit || 0,
used: calcUsed(up, down, n.traffic_limit_type || 'sum'),
next_update: rec.time,
reset_date: calcResetDays(n.expired_at),
price: n.price, currency: n.currency, billing_cycle: n.billing_cycle,
net_total_up: up, net_total_down: down
};
}).catch(function() {
return {
name: n.name, uuid: n.uuid, limit: n.traffic_limit || 0, used: 0,
next_update: null, reset_date: calcResetDays(n.expired_at),
price: n.price, currency: n.currency, billing_cycle: n.billing_cycle,
net_total_up: 0, net_total_down: 0
};
});
}));
}).then(function(data) {
dataCache = { time: now, data: data };
cb(data);
}).finally(function() { loading = false; });
}
// 观察器 - 优化防止频繁触发
var observer = null, timer = null, renderPending = false;
function update() { fetchData(render); }
function scheduleRender() {
if (renderPending) return;
renderPending = true;
setTimeout(function() {
renderPending = false;
update();
}, 300);
}
function init() {
if (observer) return;
observer = new MutationObserver(scheduleRender);
observer.observe(document.body, { childList: true, subtree: true });
update();
timer = setInterval(update, CONFIG.interval);
window.addEventListener('beforeunload', function() {
if (observer) observer.disconnect();
if (timer) clearInterval(timer);
barCache.clear();
}, { once: true });
}
// 启动
function tryInit() {
if (document.querySelector('section.grid[class*="grid-cols-"]')) {
requestAnimationFrame(init);
} else {
setTimeout(tryInit, 250);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryInit, { once: true });
} else {
tryInit();
}
})();
</script>七、Komari 通知模板(Telegram)
以下为 Komari 事件通知的 Telegram 模板示例,支持:
- 上线 / 离线通知
- 异常告警
- 到期与续费提醒
- 面板与实例详情按钮跳转
消息发送函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139async function sendMessage(message, title, instanceId = null) {
const token = "你的token";
const chatId = "你的id";
const panelUrl = "你的域名";
if (!token || !chatId) return false;
const url = `https://api.telegram.org/bot${token}/sendMessage`;
// 构建交互按钮
let inline_keyboard = [];
let row1 = [{ text: "📊 进入面板", url: panelUrl }];
// 这里的路径修正为 /instance/,匹配你提供的格式
if (instanceId && instanceId !== '未知') {
row1.push({
text: "🌐 实例详情",
url: `${panelUrl}/instance/${instanceId}`
});
}
inline_keyboard.push(row1);
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `<b>${title}</b>\n\n${message}`,
parse_mode: 'HTML',
reply_markup: { inline_keyboard: inline_keyboard }
}),
});
return resp.ok;
}
async function sendEvent(event) {
try {
// ⏰ 时间转换函数 (处理 CST)
const getCSTTime = (timeStr) => {
if (!timeStr || timeStr.startsWith('0001')) return "1-01-01 08:00:00";
const date = new Date(timeStr.replace(/\.\d+Z$/, 'Z'));
const cst = new Date(date.getTime() + 8 * 60 * 60 * 1000);
const f = (n) => n.toString().padStart(2, '0');
return `${cst.getUTCFullYear()}-${f(cst.getUTCMonth() + 1)}-${f(cst.getUTCDate())} ${f(cst.getUTCHours())}:${f(cst.getUTCMinutes())}:${f(cst.getUTCSeconds())}`;
};
// 📦 流量单位转换函数
const formatTraffic = (bytes) => {
if (!bytes || bytes === 0) return '无限制';
const gb = bytes / (1024 ** 3);
if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`;
return `${gb.toFixed(2)} GB`;
};
// 内存和磁盘格式化函数
const formatMemory = (bytes) => {
if (!bytes || bytes === 0) return '0';
const gb = bytes / (1024 ** 3);
return gb < 1 ? `${Math.round(gb * 1024)}MB` : `${Math.round(gb)}G`;
};
const formatSwapMemory = (bytes) => {
if (!bytes || bytes === 0) return '0';
const gb = bytes / (1024 ** 3);
return gb < 1 ? `${Math.round(gb * 1024)}MB` : `${Math.round(gb)}G`;
};
// IP 地址部分隐藏函数
const hideIP = (ip) => {
if (!ip) return '未知';
const parts = ip.split('.');
if (parts.length === 4) {
return `${parts[0]}.${parts[1]}.xxx.xxx`;
}
// 对于 IPv6 地址,只显示前半部分
const v6Parts = ip.split(':');
return v6Parts.slice(0, 3).join(':') + ':xxxx:xxxx:xxxx';
};
// 标题映射
const eventTitles = {
'Online': '🟢 服务器上线',
'Offline': '🔴 服务器离线',
'Alert': '⚠️ 异常警报',
'Renew': '💰 续费通知',
'Expire': '🚨 到期预警',
'Test': '🧪 测试通知'
};
const title = eventTitles[event.event] || '📌 系统通知';
let clientInfo = '';
let targetInstanceId = null;
if (event.clients && event.clients.length > 0) {
const c = event.clients[0];
targetInstanceId = c.uuid;
const region = c.region ? ` [${c.region}]` : '';
const hiddenIPv4 = hideIP(c.ipv4);
const hiddenIPv6 = hideIP(c.ipv6);
const mem = c.mem_total ? formatMemory(c.mem_total) : '0';
const swap = c.swap_total ? formatSwapMemory(c.swap_total) : '0';
const disk = c.disk_total ? formatMemory(c.disk_total) : '0';
clientInfo += `🖥️服务器:${c.name}${region}\n`;
clientInfo += `📝配置:${c.cpu_cores || '0'}C / ${mem}${swap !== '0' ? `+${swap}` : ''} / ${disk}\n`;
clientInfo += `🌐IPv4:${hiddenIPv4}\n`;
clientInfo += `🌐IPv6:${hiddenIPv6}\n`;
// 流量管家
const trafficLimit = formatTraffic(c.traffic_limit);
clientInfo += `📶流量限额:${trafficLimit}${c.traffic_limit_type ? ` (${c.traffic_limit_type})` : ''}\n`;
if (event.event === 'Renew' || event.event === 'Expire') {
clientInfo += `💰账单:${c.currency || '$'}${c.price || '0'} (${c.billing_cycle || '0'}天/付)\n`;
}
} else {
clientInfo += `🖥️服务器:未知设备\n📝配置:未知\n🌐IPv4:未知\n🌐IPv6:未知\n📶流量限额:未知\n`;
}
let message = clientInfo;
message += `\n🗒️状态:${event.event} (${eventTitles[event.event] || '未知'})\n`;
message += `⌚️时间:${getCSTTime(event.time)} (CST)`;
if (event.message && event.message.trim()) {
message += `\n\n📄详细描述:\n<i>${event.message}</i>`;
}
// 发送通知,传入真正的 UUID 以生成正确的按钮链接
return await sendMessage(message, title, targetInstanceId);
} catch (error) {
return await sendMessage(`脚本解析出错: ${error.message}`, '❌ Error');
}
}八、总结
本文完整覆盖了 Komari 的:
- Debian 12 环境部署
- 安装与验证流程
- 管理员密码管理
- 前端 UI 与流量展示增强
- Telegram 通知模板
适合作为 Komari 的长期使用文档或二次定制基础。如后续需要:
- 拆分 JS 模块
- 自定义主题样式
- 对接更多通知渠道
可在此结构上持续扩展。