@oxiaom/adoremix 1.0.37 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import re
4
+ import json
5
+ import subprocess
6
+ from datetime import datetime, timezone
7
+ from flask import Flask, render_template, request, jsonify
8
+
9
+ app = Flask(__name__)
10
+
11
+ # 工作目录(由 adoremix config-manager 服务的环境变量指定,默认 /opt/adoremix)
12
+ WORKDIR = os.environ.get('ADOREMIX_WORKDIR', '/opt/adoremix')
13
+
14
+ # 配置文件路径
15
+ CONFIG_INI = os.path.join(WORKDIR, 'config.ini')
16
+ OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json"
17
+
18
+ # 常用模型列表
19
+ POPULAR_MODELS = [
20
+ # 智谱 GLM 系列 (完整)
21
+ {"id": "zai/glm-4.7", "name": "智谱 GLM-4.7", "provider": "zhipu"},
22
+ {"id": "zai/glm-4-plus", "name": "智谱 GLM-4 Plus", "provider": "zhipu"},
23
+ {"id": "zai/glm-4-0520", "name": "智谱 GLM-4-0520", "provider": "zhipu"},
24
+ {"id": "zai/glm-4-air", "name": "智谱 GLM-4 Air", "provider": "zhipu"},
25
+ {"id": "zai/glm-4-airx", "name": "智谱 GLM-4 AirX", "provider": "zhipu"},
26
+ {"id": "zai/glm-4-flash", "name": "智谱 GLM-4 Flash", "provider": "zhipu"},
27
+ {"id": "zai/glm-4-long", "name": "智谱 GLM-4 Long (长文本)", "provider": "zhipu"},
28
+ {"id": "zai/glm-4", "name": "智谱 GLM-4", "provider": "zhipu"},
29
+ {"id": "zai/glm-4v", "name": "智谱 GLM-4V (视觉)", "provider": "zhipu"},
30
+ {"id": "zai/glm-4v-plus", "name": "智谱 GLM-4V Plus (视觉)", "provider": "zhipu"},
31
+ {"id": "zai/glm-3-turbo", "name": "智谱 GLM-3 Turbo", "provider": "zhipu"},
32
+ {"id": "zai/glm-5", "name": "智谱 GLM-5", "provider": "zhipu"},
33
+ {"id": "zai/glm-5-turbo", "name": "智谱 GLM-5 Turbo", "provider": "zhipu"},
34
+ # OpenAI
35
+ {"id": "openai/gpt-4o", "name": "OpenAI GPT-4o", "provider": "openai"},
36
+ {"id": "openai/gpt-4o-mini", "name": "OpenAI GPT-4o Mini", "provider": "openai"},
37
+ {"id": "openai/gpt-4-turbo", "name": "OpenAI GPT-4 Turbo", "provider": "openai"},
38
+ {"id": "openai/gpt-4", "name": "OpenAI GPT-4", "provider": "openai"},
39
+ {"id": "openai/o1-preview", "name": "OpenAI O1 Preview", "provider": "openai"},
40
+ {"id": "openai/o1-mini", "name": "OpenAI O1 Mini", "provider": "openai"},
41
+ # Claude
42
+ {"id": "anthropic/claude-3-5-sonnet", "name": "Claude 3.5 Sonnet", "provider": "anthropic"},
43
+ {"id": "anthropic/claude-3-5-haiku", "name": "Claude 3.5 Haiku", "provider": "anthropic"},
44
+ {"id": "anthropic/claude-3-opus", "name": "Claude 3 Opus", "provider": "anthropic"},
45
+ {"id": "anthropic/claude-3-sonnet", "name": "Claude 3 Sonnet", "provider": "anthropic"},
46
+ {"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "provider": "anthropic"},
47
+ # Google
48
+ {"id": "google/gemini-1.5-pro", "name": "Google Gemini 1.5 Pro", "provider": "google"},
49
+ {"id": "google/gemini-1.5-flash", "name": "Google Gemini 1.5 Flash", "provider": "google"},
50
+ {"id": "google/gemini-2.0-flash", "name": "Google Gemini 2.0 Flash", "provider": "google"},
51
+ {"id": "google/gemini-pro", "name": "Google Gemini Pro", "provider": "google"},
52
+ # DeepSeek
53
+ {"id": "deepseek/deepseek-chat", "name": "DeepSeek Chat", "provider": "deepseek"},
54
+ {"id": "deepseek/deepseek-coder", "name": "DeepSeek Coder", "provider": "deepseek"},
55
+ {"id": "deepseek/deepseek-reasoner", "name": "DeepSeek Reasoner", "provider": "deepseek"},
56
+ # Moonshot
57
+ {"id": "moonshot/moonshot-v1-8k", "name": "Moonshot Kimi 8K", "provider": "moonshot"},
58
+ {"id": "moonshot/moonshot-v1-32k", "name": "Moonshot Kimi 32K", "provider": "moonshot"},
59
+ {"id": "moonshot/moonshot-v1-128k", "name": "Moonshot Kimi 128K", "provider": "moonshot"},
60
+ # 通义千问
61
+ {"id": "qwen/qwen-turbo", "name": "通义千问 Turbo", "provider": "qwen"},
62
+ {"id": "qwen/qwen-plus", "name": "通义千问 Plus", "provider": "qwen"},
63
+ {"id": "qwen/qwen-max", "name": "通义千问 Max", "provider": "qwen"},
64
+ {"id": "qwen/qwen-long", "name": "通义千问 Long", "provider": "qwen"},
65
+ # Ollama 本地
66
+ {"id": "ollama/llama3", "name": "Ollama Llama3 (本地)", "provider": "ollama"},
67
+ {"id": "ollama/llama3.1", "name": "Ollama Llama3.1 (本地)", "provider": "ollama"},
68
+ {"id": "ollama/qwen2", "name": "Ollama Qwen2 (本地)", "provider": "ollama"},
69
+ {"id": "ollama/qwen2.5", "name": "Ollama Qwen2.5 (本地)", "provider": "ollama"},
70
+ {"id": "ollama/deepseek-v2", "name": "Ollama DeepSeek V2 (本地)", "provider": "ollama"},
71
+ {"id": "ollama/glm4", "name": "Ollama GLM4 (本地)", "provider": "ollama"},
72
+ ]
73
+
74
+ def parse_ini(filepath):
75
+ """解析 INI 文件"""
76
+ config = {}
77
+ current_section = None
78
+
79
+ if not os.path.exists(filepath):
80
+ return config
81
+
82
+ with open(filepath, 'r', encoding='utf-8') as f:
83
+ for line in f:
84
+ line = line.strip()
85
+ if not line or line.startswith(';') or line.startswith('#'):
86
+ continue
87
+ if line.startswith('[') and line.endswith(']'):
88
+ current_section = line[1:-1]
89
+ config[current_section] = {}
90
+ elif '=' in line and current_section:
91
+ key, value = line.split('=', 1)
92
+ config[current_section][key.strip()] = value.strip()
93
+
94
+ return config
95
+
96
+ def write_ini(filepath, config):
97
+ """写入 INI 文件"""
98
+ with open(filepath, 'w', encoding='utf-8') as f:
99
+ for section, values in config.items():
100
+ f.write(f'[{section}]\n')
101
+ for key, value in values.items():
102
+ f.write(f'{key}={value}\n')
103
+ f.write('\n')
104
+
105
+ def get_network_info():
106
+ """获取当前网络信息"""
107
+ result = subprocess.run(['ip', 'addr', 'show'], capture_output=True, text=True)
108
+
109
+ interfaces = []
110
+ current_iface = None
111
+
112
+ for line in result.stdout.split('\n'):
113
+ if ': ' in line and not line.startswith(' '):
114
+ parts = line.split(': ')
115
+ if len(parts) >= 2:
116
+ iface = parts[1].split('@')[0]
117
+ if iface != 'lo':
118
+ current_iface = {'name': iface, 'ip': '', 'netmask': '', 'gateway': '', 'dhcp': False}
119
+ interfaces.append(current_iface)
120
+ elif 'inet ' in line and current_iface:
121
+ match = re.search(r'inet (\d+\.\d+\.\d+\.\d+)/(\d+)', line)
122
+ if match:
123
+ current_iface['ip'] = match.group(1)
124
+ current_iface['netmask'] = match.group(2)
125
+
126
+ # 获取网关
127
+ result = subprocess.run(['ip', 'route'], capture_output=True, text=True)
128
+ for line in result.stdout.split('\n'):
129
+ if 'default' in line:
130
+ match = re.search(r'via (\d+\.\d+\.\d+\.\d+)', line)
131
+ if match:
132
+ for iface in interfaces:
133
+ iface['gateway'] = match.group(1)
134
+
135
+ # 通过 systemd-networkd 配置检测 DHCP/静态状态
136
+ for iface in interfaces:
137
+ net_file = f'/etc/systemd/network/10-{iface["name"]}.network'
138
+ if os.path.exists(net_file):
139
+ try:
140
+ with open(net_file, 'r') as f:
141
+ cfg = f.read()
142
+ iface['dhcp'] = 'DHCP=yes' in cfg
143
+ except:
144
+ iface['dhcp'] = True
145
+ else:
146
+ iface['dhcp'] = True
147
+
148
+ return interfaces
149
+
150
+ def set_static_ip(interface, ip, netmask, gateway, dns='8.8.8.8', use_dhcp=False):
151
+ """使用 systemd-networkd 设置静态IP或DHCP"""
152
+ net_file = f'/etc/systemd/network/10-{interface}.network'
153
+
154
+ if use_dhcp:
155
+ config = f"""[Match]
156
+ Name={interface}
157
+
158
+ [Network]
159
+ DHCP=yes
160
+ """
161
+ else:
162
+ config = f"""[Match]
163
+ Name={interface}
164
+
165
+ [Network]
166
+ Address={ip}/{netmask}
167
+ Gateway={gateway}
168
+ DNS={dns}
169
+ """
170
+
171
+ with open(net_file, 'w') as f:
172
+ f.write(config)
173
+
174
+ return net_file
175
+
176
+ def apply_network():
177
+ """应用网络配置"""
178
+ result = subprocess.run(['systemctl', 'restart', 'systemd-networkd'], capture_output=True, text=True, timeout=15)
179
+ return result
180
+
181
+ @app.route('/')
182
+ def index():
183
+ return render_template('index.html')
184
+
185
+ @app.route('/api/network', methods=['GET'])
186
+ def get_network():
187
+ """获取网络配置"""
188
+ interfaces = get_network_info()
189
+ return jsonify({'interfaces': interfaces})
190
+
191
+ @app.route('/api/network', methods=['POST'])
192
+ def set_network():
193
+ """设置网络配置"""
194
+ data = request.json
195
+ interface = data.get('interface')
196
+ ip = data.get('ip')
197
+ netmask = data.get('netmask', '24')
198
+ gateway = data.get('gateway')
199
+ dns = data.get('dns', '8.8.8.8')
200
+ use_dhcp = data.get('useDhcp', False)
201
+ apply_now = data.get('applyNow', False)
202
+
203
+ if not interface:
204
+ return jsonify({'error': '缺少网络接口参数'}), 400
205
+
206
+ # DHCP 模式不需要 IP/网关
207
+ if not use_dhcp and not all([ip, gateway]):
208
+ return jsonify({'error': '静态 IP 模式需要填写 IP 地址和网关'}), 400
209
+
210
+ try:
211
+ netplan_file = set_static_ip(interface, ip, netmask, gateway, dns, use_dhcp)
212
+
213
+ if apply_now:
214
+ # 应用网络配置
215
+ result = apply_network()
216
+ if result.returncode != 0:
217
+ return jsonify({'error': f'配置已保存但应用失败: {result.stderr}'}), 500
218
+
219
+ # 等待网络恢复
220
+ import time
221
+ time.sleep(2)
222
+
223
+ # 重启依赖服务
224
+ services = ['config-manager.service', 'broadcast-panel.service', 'openclaw-gateway.service']
225
+ restarted = []
226
+ for svc in services:
227
+ try:
228
+ subprocess.run(['systemctl', 'restart', svc], capture_output=True, text=True, timeout=30)
229
+ restarted.append(svc)
230
+ except:
231
+ pass
232
+
233
+ return jsonify({
234
+ 'success': True,
235
+ 'message': f'配置已保存并应用,已重启服务: {", ".join(restarted) if restarted else "无"}',
236
+ 'restarted': restarted
237
+ })
238
+
239
+ return jsonify({
240
+ 'success': True,
241
+ 'message': f'配置已保存到 {netplan_file}',
242
+ })
243
+ except Exception as e:
244
+ return jsonify({'error': str(e)}), 500
245
+
246
+ @app.route('/api/config', methods=['GET'])
247
+ def get_config():
248
+ """获取 config.ini"""
249
+ config = parse_ini(CONFIG_INI)
250
+ return jsonify({'config': config})
251
+
252
+ @app.route('/api/config', methods=['POST'])
253
+ def set_config():
254
+ """保存 config.ini"""
255
+ data = request.json
256
+ config = data.get('config')
257
+
258
+ if not config:
259
+ return jsonify({'error': '无配置数据'}), 400
260
+
261
+ try:
262
+ # 备份
263
+ subprocess.run(['cp', CONFIG_INI, f'{CONFIG_INI}.bak'], stderr=subprocess.DEVNULL)
264
+ write_ini(CONFIG_INI, config)
265
+ return jsonify({'success': True, 'message': '配置已保存'})
266
+ except Exception as e:
267
+ return jsonify({'error': str(e)}), 500
268
+
269
+ @app.route('/api/qqbot', methods=['GET'])
270
+ def get_qqbot():
271
+ """获取 QQBot 配置"""
272
+ try:
273
+ with open(OPENCLAW_CONFIG, 'r') as f:
274
+ config = json.load(f)
275
+ channels = config.get('channels', {}).get('qqbot', {})
276
+ plugin_enabled = config.get('plugins', {}).get('entries', {}).get('openclaw-qqbot', {}).get('enabled', True)
277
+ return jsonify({
278
+ 'config': {
279
+ 'uin': channels.get('appId', ''),
280
+ 'password': channels.get('clientSecret', ''),
281
+ 'enabled': plugin_enabled
282
+ }
283
+ })
284
+ except Exception as e:
285
+ return jsonify({'config': {'uin': '', 'password': ''}, 'error': str(e)})
286
+
287
+ @app.route('/api/qqbot', methods=['POST'])
288
+ def set_qqbot():
289
+ """保存 QQBot 配置到 OpenClaw 配置文件"""
290
+ data = request.json
291
+ uin = data.get('uin', '')
292
+ password = data.get('password', '')
293
+
294
+ try:
295
+ with open(OPENCLAW_CONFIG, 'r') as f:
296
+ config = json.load(f)
297
+
298
+ if 'channels' not in config:
299
+ config['channels'] = {}
300
+ if 'qqbot' not in config['channels']:
301
+ config['channels']['qqbot'] = {}
302
+
303
+ config['channels']['qqbot']['appId'] = uin
304
+ config['channels']['qqbot']['clientSecret'] = password
305
+
306
+ with open(OPENCLAW_CONFIG, 'w') as f:
307
+ json.dump(config, f, indent=2)
308
+
309
+ return jsonify({
310
+ 'success': True,
311
+ 'message': 'QQBot 配置已保存,需要重启 OpenClaw Gateway 才能生效'
312
+ })
313
+ except Exception as e:
314
+ return jsonify({'error': str(e)}), 500
315
+
316
+ @app.route('/api/qqbot-toggle', methods=['POST'])
317
+ def toggle_qqbot():
318
+ """启用/禁用 QQBot"""
319
+ data = request.json
320
+ enabled = data.get('enabled', True)
321
+
322
+ try:
323
+ with open(OPENCLAW_CONFIG, 'r') as f:
324
+ config = json.load(f)
325
+
326
+ if 'plugins' not in config:
327
+ config['plugins'] = {}
328
+ if 'entries' not in config['plugins']:
329
+ config['plugins']['entries'] = {}
330
+ if 'openclaw-qqbot' not in config['plugins']['entries']:
331
+ config['plugins']['entries']['openclaw-qqbot'] = {}
332
+
333
+ config['plugins']['entries']['openclaw-qqbot']['enabled'] = enabled
334
+
335
+ with open(OPENCLAW_CONFIG, 'w') as f:
336
+ json.dump(config, f, indent=2)
337
+
338
+ return jsonify({
339
+ 'success': True,
340
+ 'message': f'QQBot 已{"启用" if enabled else "禁用"},需要重启 Gateway 才能生效'
341
+ })
342
+ except Exception as e:
343
+ return jsonify({'error': str(e)}), 500
344
+
345
+ @app.route('/api/netplan-apply', methods=['POST'])
346
+ def apply_netplan():
347
+ """应用网络配置"""
348
+ try:
349
+ result = apply_network()
350
+ if result.returncode == 0:
351
+ return jsonify({'success': True, 'message': '网络配置已应用'})
352
+ else:
353
+ return jsonify({'error': result.stderr}), 500
354
+ except Exception as e:
355
+ return jsonify({'error': str(e)}), 500
356
+
357
+ @app.route('/api/adoremix-restart', methods=['POST'])
358
+ def restart_adoremix():
359
+ """重启小播鼠服务"""
360
+ try:
361
+ result = subprocess.run(['systemctl', 'restart', 'adoremix'], capture_output=True, text=True, timeout=30)
362
+ if result.returncode == 0:
363
+ return jsonify({'success': True, 'message': '小播鼠服务已重启'})
364
+ else:
365
+ return jsonify({'error': result.stderr}), 500
366
+ except Exception as e:
367
+ return jsonify({'error': str(e)}), 500
368
+
369
+ @app.route('/api/openclaw-restart', methods=['POST'])
370
+ def restart_openclaw():
371
+ """重启 OpenClaw Gateway"""
372
+ try:
373
+ result = subprocess.run(['openclaw', 'gateway', 'restart'], capture_output=True, text=True, timeout=300)
374
+ return jsonify({'success': True, 'message': 'OpenClaw Gateway 正在重启'})
375
+ except Exception as e:
376
+ return jsonify({'error': str(e)}), 500
377
+
378
+ @app.route('/api/models', methods=['GET'])
379
+ def get_models():
380
+ """获取模型配置"""
381
+ try:
382
+ with open(OPENCLAW_CONFIG, 'r') as f:
383
+ config = json.load(f)
384
+
385
+ agents = config.get('agents', {}).get('defaults', {})
386
+ model_val = agents.get('model', '')
387
+ primary = model_val if isinstance(model_val, str) else model_val.get('primary', '')
388
+ models_config = agents.get('models', {})
389
+
390
+ # 获取各提供商的 API key
391
+ api_keys = {}
392
+ for model_id, model_cfg in models_config.items():
393
+ if isinstance(model_cfg, dict) and 'apiKey' in model_cfg:
394
+ api_keys[model_id] = model_cfg['apiKey']
395
+
396
+ return jsonify({
397
+ 'primary': primary,
398
+ 'models': models_config,
399
+ 'apiKeys': api_keys,
400
+ 'availableModels': POPULAR_MODELS
401
+ })
402
+ except Exception as e:
403
+ return jsonify({'error': str(e)}), 500
404
+
405
+ @app.route('/api/models', methods=['POST'])
406
+ def set_models():
407
+ """保存模型配置"""
408
+ data = request.json
409
+ primary = data.get('primary', '')
410
+ model_configs = data.get('modelConfigs', {})
411
+
412
+ try:
413
+ with open(OPENCLAW_CONFIG, 'r') as f:
414
+ config = json.load(f)
415
+
416
+ if 'agents' not in config:
417
+ config['agents'] = {}
418
+ if 'defaults' not in config['agents']:
419
+ config['agents']['defaults'] = {}
420
+ if 'models' not in config['agents']['defaults']:
421
+ config['agents']['defaults']['models'] = {}
422
+
423
+ # 设置主模型(直接存字符串)
424
+ config['agents']['defaults']['model'] = primary
425
+
426
+ # 确保主模型在 models 列表中
427
+ if primary not in config['agents']['defaults']['models']:
428
+ config['agents']['defaults']['models'][primary] = {}
429
+
430
+ # 保存模型配置 (apiKey + baseUrl)
431
+ for model_id, model_cfg in model_configs.items():
432
+ if model_id not in config['agents']['defaults']['models']:
433
+ config['agents']['defaults']['models'][model_id] = {}
434
+
435
+ if isinstance(model_cfg, dict):
436
+ if 'apiKey' in model_cfg:
437
+ config['agents']['defaults']['models'][model_id]['apiKey'] = model_cfg['apiKey']
438
+ if 'baseUrl' in model_cfg:
439
+ config['agents']['defaults']['models'][model_id]['baseUrl'] = model_cfg['baseUrl']
440
+
441
+ with open(OPENCLAW_CONFIG, 'w') as f:
442
+ json.dump(config, f, indent=2)
443
+
444
+ return jsonify({
445
+ 'success': True,
446
+ 'message': '模型配置已保存,需要重启 Gateway 才能生效'
447
+ })
448
+ except Exception as e:
449
+ return jsonify({'error': str(e)}), 500
450
+
451
+ # ==================== 实时日志查看 ====================
452
+
453
+ # 常用服务预设(优先列在前面)
454
+ LOG_SERVICES_PRESET = [
455
+ 'adoremix',
456
+ 'config-manager',
457
+ 'openclaw-gateway',
458
+ 'smbd',
459
+ 'nmbd',
460
+ 'ssh',
461
+ ]
462
+
463
+ # 文件日志源(不是 systemd 服务,是应用自己的日志文件)
464
+ LOG_FILE_SOURCES = {
465
+ 'adoremix-app': os.path.join(WORKDIR, 'var', 'app.log'),
466
+ 'config-manager-out': '/var/log/config-manager.log', # 预留,可能不存在
467
+ }
468
+
469
+ @app.route('/api/logs', methods=['GET'])
470
+ def api_logs():
471
+ """拉取 systemd journal 日志 或 文件日志"""
472
+ service = request.args.get('service', 'adoremix').strip()
473
+ try:
474
+ lines = int(request.args.get('lines', 200))
475
+ except ValueError:
476
+ lines = 200
477
+ lines = max(1, min(lines, 2000))
478
+
479
+ # 时间范围过滤:since 形如 "5min" / "30min" / "1h" / "2h" / "today" / "all" / ISO timestamp
480
+ since = request.args.get('since', 'all').strip()
481
+
482
+ if not service:
483
+ return jsonify({'error': '缺少 service 参数', 'lines': []}), 400
484
+
485
+ # 验证服务名(防注入:只允许字母数字下划线短横线点)
486
+ if not re.match(r'^[A-Za-z0-9._-]+$', service):
487
+ return jsonify({'error': '非法服务名', 'lines': []}), 400
488
+
489
+ # === 文件源 ===
490
+ if service in LOG_FILE_SOURCES:
491
+ file_path = LOG_FILE_SOURCES[service]
492
+ try:
493
+ if not os.path.exists(file_path):
494
+ return jsonify({
495
+ 'service': service,
496
+ 'source': 'file',
497
+ 'path': file_path,
498
+ 'lines': [],
499
+ 'count': 0,
500
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
501
+ 'warning': f'文件不存在: {file_path}'
502
+ })
503
+ # tail 取最后 N 行
504
+ result = subprocess.run(
505
+ ['tail', '-n', str(lines), file_path],
506
+ capture_output=True, text=True, timeout=5
507
+ )
508
+ log_lines = result.stdout.splitlines() if result.stdout else []
509
+ return jsonify({
510
+ 'service': service,
511
+ 'source': 'file',
512
+ 'path': file_path,
513
+ 'lines': log_lines,
514
+ 'count': len(log_lines),
515
+ 'timestamp': datetime.now(timezone.utc).isoformat()
516
+ })
517
+ except subprocess.TimeoutExpired:
518
+ return jsonify({'error': 'tail timeout (>5s)', 'lines': []}), 500
519
+ except Exception as e:
520
+ return jsonify({'error': str(e), 'lines': []}), 500
521
+
522
+ # === journalctl 源 ===
523
+ # 映射快捷时间
524
+ since_map = {
525
+ '5min': '5 min ago',
526
+ '10min': '10 min ago',
527
+ '30min': '30 min ago',
528
+ '1h': '1 hour ago',
529
+ '2h': '2 hours ago',
530
+ '6h': '6 hours ago',
531
+ '12h': '12 hours ago',
532
+ 'today': 'today',
533
+ 'yesterday': 'yesterday',
534
+ }
535
+ since_value = since_map.get(since, since)
536
+ use_since = since_value and since_value != 'all'
537
+
538
+ try:
539
+ cmd = ['journalctl', '-u', service, '--no-pager', '-n', str(lines)]
540
+ if use_since:
541
+ cmd += ['--since', since_value]
542
+
543
+ result = subprocess.run(
544
+ cmd,
545
+ capture_output=True,
546
+ text=True,
547
+ timeout=10
548
+ )
549
+
550
+ log_lines = result.stdout.splitlines() if result.stdout else []
551
+
552
+ return jsonify({
553
+ 'service': service,
554
+ 'source': 'journalctl',
555
+ 'since': since,
556
+ 'lines': log_lines,
557
+ 'count': len(log_lines),
558
+ 'timestamp': datetime.now(timezone.utc).isoformat()
559
+ })
560
+ except subprocess.TimeoutExpired:
561
+ return jsonify({'error': 'journalctl timeout (>10s)', 'lines': []}), 500
562
+ except FileNotFoundError:
563
+ return jsonify({'error': 'journalctl 未安装', 'lines': []}), 500
564
+ except Exception as e:
565
+ return jsonify({'error': str(e), 'lines': []}), 500
566
+
567
+
568
+ @app.route('/api/logs/services', methods=['GET'])
569
+ def api_logs_services():
570
+ """列出常用服务(用于下拉菜单)"""
571
+ services = list(LOG_SERVICES_PRESET)
572
+ try:
573
+ # 额外获取当前 running 的服务
574
+ result = subprocess.run(
575
+ ['systemctl', 'list-units', '--type=service', '--no-pager', '--no-legend', '--state=running'],
576
+ capture_output=True, text=True, timeout=5
577
+ )
578
+ for line in result.stdout.splitlines():
579
+ parts = line.split()
580
+ if parts and parts[0].endswith('.service'):
581
+ name = parts[0][:-len('.service')]
582
+ if name not in services:
583
+ services.append(name)
584
+ except Exception:
585
+ pass
586
+ return jsonify({'services': services})
587
+
588
+ if __name__ == '__main__':
589
+ app.run(host='::', port=9877, debug=False)