servly 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
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.
- servly/cli.py +81 -89
- servly/logs.py +121 -154
- servly/service.py +139 -3
- {servly-0.2.0.dist-info → servly-0.3.1.dist-info}/METADATA +3 -1
- servly-0.3.1.dist-info/RECORD +9 -0
- servly-0.2.0.dist-info/RECORD +0 -9
- {servly-0.2.0.dist-info → servly-0.3.1.dist-info}/WHEEL +0 -0
- {servly-0.2.0.dist-info → servly-0.3.1.dist-info}/entry_points.txt +0 -0
- {servly-0.2.0.dist-info → servly-0.3.1.dist-info}/top_level.txt +0 -0
servly/cli.py
CHANGED
@@ -1,52 +1,40 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
3
|
Command-line interface for Servly process manager.
|
4
|
+
使用Rich库进行终端输出格式化
|
4
5
|
"""
|
5
6
|
import os
|
6
7
|
import sys
|
7
8
|
import argparse
|
8
9
|
import logging
|
9
10
|
from pathlib import Path
|
10
|
-
from typing import List, Optional
|
11
|
+
from typing import List, Dict, Optional
|
11
12
|
|
12
13
|
from servly.service import ServiceManager
|
13
|
-
from servly.logs import LogManager
|
14
|
-
from servly.logs import
|
15
|
-
from servly.logs import align_colored_text # 导入对齐工具函数
|
14
|
+
from servly.logs import LogManager, Emojis
|
15
|
+
from servly.logs import console, print_header, print_info, print_warning, print_error, print_success, print_service_table
|
16
16
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
def format(self, record):
|
22
|
-
log_message = super().format(record)
|
23
|
-
|
24
|
-
if record.levelno >= logging.ERROR:
|
25
|
-
return f"{Emojis.ERROR} {Colors.BRIGHT_RED}{log_message}{Colors.RESET}"
|
26
|
-
elif record.levelno >= logging.WARNING:
|
27
|
-
return f"{Emojis.WARNING} {Colors.YELLOW}{log_message}{Colors.RESET}"
|
28
|
-
elif record.levelno >= logging.INFO:
|
29
|
-
return f"{Emojis.INFO} {Colors.GREEN}{log_message}{Colors.RESET}"
|
30
|
-
else:
|
31
|
-
return f"{Colors.BRIGHT_BLACK}{log_message}{Colors.RESET}"
|
17
|
+
from rich.logging import RichHandler
|
18
|
+
from rich.traceback import install
|
19
|
+
from rich.markup import escape
|
32
20
|
|
33
|
-
#
|
34
|
-
|
35
|
-
handler.setFormatter(ColoredFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
|
36
|
-
logger = logging.getLogger('servly')
|
37
|
-
logger.setLevel(logging.INFO)
|
38
|
-
logger.addHandler(handler)
|
21
|
+
# 安装Rich的异常格式化器
|
22
|
+
install()
|
39
23
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
24
|
+
# 配置Rich日志处理
|
25
|
+
logging.basicConfig(
|
26
|
+
level=logging.INFO,
|
27
|
+
format="%(message)s",
|
28
|
+
datefmt="[%X]",
|
29
|
+
handlers=[RichHandler(rich_tracebacks=True, markup=True)]
|
30
|
+
)
|
31
|
+
|
32
|
+
logger = logging.getLogger("servly")
|
45
33
|
|
46
34
|
def setup_arg_parser() -> argparse.ArgumentParser:
|
47
35
|
"""设置命令行参数解析器"""
|
48
36
|
parser = argparse.ArgumentParser(
|
49
|
-
description=f"{
|
37
|
+
description=f"{Emojis.SERVICE} Servly - Modern process manager",
|
50
38
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
51
39
|
)
|
52
40
|
|
@@ -91,30 +79,30 @@ def handle_start(manager: ServiceManager, service_name: str) -> bool:
|
|
91
79
|
if service_name == 'all':
|
92
80
|
service_names = manager.get_service_names()
|
93
81
|
if not service_names:
|
94
|
-
|
82
|
+
print_warning("配置中没有定义任何服务。")
|
95
83
|
return False
|
96
84
|
|
97
|
-
print(f"{Emojis.START}
|
85
|
+
console.print(f"{Emojis.START} 正在启动所有服务...", style="running")
|
98
86
|
success = True
|
99
87
|
for name in service_names:
|
100
88
|
if not manager.start(name):
|
101
|
-
|
89
|
+
print_error(f"启动服务 '{name}' 失败")
|
102
90
|
success = False
|
103
91
|
else:
|
104
|
-
|
92
|
+
print_success(f"服务 '{name}' 已成功启动")
|
105
93
|
|
106
94
|
if success:
|
107
|
-
|
95
|
+
print_success("所有服务已成功启动!")
|
108
96
|
else:
|
109
|
-
|
97
|
+
print_warning("有些服务启动失败,请检查日志获取详情。")
|
110
98
|
|
111
99
|
return success
|
112
100
|
else:
|
113
101
|
result = manager.start(service_name)
|
114
102
|
if result:
|
115
|
-
|
103
|
+
print_success(f"服务 '{service_name}' 已成功启动")
|
116
104
|
else:
|
117
|
-
|
105
|
+
print_error(f"启动服务 '{service_name}' 失败")
|
118
106
|
return result
|
119
107
|
|
120
108
|
def handle_stop(manager: ServiceManager, service_name: str) -> bool:
|
@@ -122,30 +110,30 @@ def handle_stop(manager: ServiceManager, service_name: str) -> bool:
|
|
122
110
|
if service_name == 'all':
|
123
111
|
service_names = manager.get_running_services()
|
124
112
|
if not service_names:
|
125
|
-
print(f"{Emojis.INFO}
|
113
|
+
console.print(f"{Emojis.INFO} 当前没有正在运行的服务。", style="dim")
|
126
114
|
return True
|
127
115
|
|
128
|
-
print(f"{Emojis.STOP}
|
116
|
+
console.print(f"{Emojis.STOP} 正在停止所有服务...", style="warning")
|
129
117
|
success = True
|
130
118
|
for name in service_names:
|
131
119
|
if not manager.stop(name):
|
132
|
-
|
120
|
+
print_error(f"停止服务 '{name}' 失败")
|
133
121
|
success = False
|
134
122
|
else:
|
135
|
-
print(f"{Emojis.STOPPED}
|
123
|
+
console.print(f"{Emojis.STOPPED} 服务 '{name}' 已停止", style="stopped")
|
136
124
|
|
137
125
|
if success:
|
138
|
-
print(f"\n{Emojis.STOPPED}
|
126
|
+
console.print(f"\n{Emojis.STOPPED} 所有服务已成功停止!", style="warning")
|
139
127
|
else:
|
140
|
-
|
128
|
+
print_warning("有些服务停止失败,请检查日志获取详情。")
|
141
129
|
|
142
130
|
return success
|
143
131
|
else:
|
144
132
|
result = manager.stop(service_name)
|
145
133
|
if result:
|
146
|
-
print(f"{Emojis.STOPPED}
|
134
|
+
console.print(f"{Emojis.STOPPED} 服务 '{service_name}' 已成功停止", style="warning")
|
147
135
|
else:
|
148
|
-
|
136
|
+
print_error(f"停止服务 '{service_name}' 失败")
|
149
137
|
return result
|
150
138
|
|
151
139
|
def handle_restart(manager: ServiceManager, service_name: str) -> bool:
|
@@ -153,30 +141,30 @@ def handle_restart(manager: ServiceManager, service_name: str) -> bool:
|
|
153
141
|
if service_name == 'all':
|
154
142
|
service_names = manager.get_service_names()
|
155
143
|
if not service_names:
|
156
|
-
|
144
|
+
print_warning("配置中没有定义任何服务。")
|
157
145
|
return False
|
158
146
|
|
159
|
-
print(f"{Emojis.RESTART}
|
147
|
+
console.print(f"{Emojis.RESTART} 正在重启所有服务...", style="restart")
|
160
148
|
success = True
|
161
149
|
for name in service_names:
|
162
150
|
if not manager.restart(name):
|
163
|
-
|
151
|
+
print_error(f"重启服务 '{name}' 失败")
|
164
152
|
success = False
|
165
153
|
else:
|
166
|
-
print(f"{Emojis.RUNNING}
|
154
|
+
console.print(f"{Emojis.RUNNING} 服务 '{name}' 已成功重启", style="restart")
|
167
155
|
|
168
156
|
if success:
|
169
|
-
|
157
|
+
print_success("所有服务已成功重启!")
|
170
158
|
else:
|
171
|
-
|
159
|
+
print_warning("有些服务重启失败,请检查日志获取详情。")
|
172
160
|
|
173
161
|
return success
|
174
162
|
else:
|
175
163
|
result = manager.restart(service_name)
|
176
164
|
if result:
|
177
|
-
print(f"{Emojis.RUNNING}
|
165
|
+
console.print(f"{Emojis.RUNNING} 服务 '{service_name}' 已成功重启", style="restart")
|
178
166
|
else:
|
179
|
-
|
167
|
+
print_error(f"重启服务 '{service_name}' 失败")
|
180
168
|
return result
|
181
169
|
|
182
170
|
def handle_log(manager: ServiceManager, log_manager: LogManager, args) -> bool:
|
@@ -188,15 +176,15 @@ def handle_log(manager: ServiceManager, log_manager: LogManager, args) -> bool:
|
|
188
176
|
if service_name == 'all':
|
189
177
|
services = manager.get_service_names()
|
190
178
|
if not services:
|
191
|
-
|
179
|
+
print_warning("配置中没有定义任何服务。")
|
192
180
|
return False
|
193
181
|
else:
|
194
182
|
if service_name not in manager.get_service_names():
|
195
|
-
|
183
|
+
print_error(f"服务 '{service_name}' 在配置中未找到。")
|
196
184
|
return False
|
197
185
|
services = [service_name]
|
198
186
|
|
199
|
-
#
|
187
|
+
# 使用LogManager查看日志
|
200
188
|
log_manager.tail_logs(services, follow=follow, lines=lines)
|
201
189
|
return True
|
202
190
|
|
@@ -205,50 +193,53 @@ def handle_list(manager: ServiceManager) -> bool:
|
|
205
193
|
service_names = manager.get_service_names()
|
206
194
|
|
207
195
|
if not service_names:
|
208
|
-
|
196
|
+
print_warning("配置中没有定义任何服务。")
|
209
197
|
return True
|
210
198
|
|
211
|
-
|
212
|
-
print(f"\n{Colors.CYAN}{Colors.BOLD}{Emojis.SERVICE} 服务列表{Colors.RESET}")
|
213
|
-
print(f"{Colors.CYAN}{'─' * 60}{Colors.RESET}")
|
214
|
-
|
215
|
-
# 列标题
|
216
|
-
print(f"{Colors.BOLD}{align_colored_text(' 名称', 25)} {align_colored_text('状态', 20)} {align_colored_text('PID', 10)}{Colors.RESET}")
|
217
|
-
print(f"{Colors.BRIGHT_BLACK}{'─' * 60}{Colors.RESET}")
|
199
|
+
print_header("服务列表")
|
218
200
|
|
219
|
-
#
|
201
|
+
# 构建服务列表数据
|
202
|
+
services = []
|
220
203
|
for name in service_names:
|
221
204
|
is_running = manager.is_running(name)
|
222
|
-
pid = manager.get_service_pid(name)
|
205
|
+
pid = manager.get_service_pid(name)
|
223
206
|
|
207
|
+
# 获取服务运行时间
|
208
|
+
uptime_seconds = manager.get_uptime(name) if is_running else None
|
209
|
+
uptime = manager.format_uptime(uptime_seconds) if is_running else "-"
|
210
|
+
|
211
|
+
# 获取 CPU 和内存使用情况
|
212
|
+
cpu_mem_stats = {}
|
224
213
|
if is_running:
|
225
|
-
|
226
|
-
|
214
|
+
stats = manager.get_process_stats(name)
|
215
|
+
cpu_mem_stats["cpu"] = manager.format_cpu_percent(stats["cpu_percent"])
|
216
|
+
cpu_mem_stats["memory"] = manager.format_memory(stats["memory_mb"], stats["memory_percent"])
|
227
217
|
else:
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
# 为不同服务使用不同颜色
|
232
|
-
service_color = Colors.CYAN if is_running else Colors.BRIGHT_BLACK
|
233
|
-
service_text = f"{service_color}{name}{Colors.RESET}"
|
218
|
+
cpu_mem_stats["cpu"] = "0%"
|
219
|
+
cpu_mem_stats["memory"] = "0b"
|
234
220
|
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
221
|
+
services.append({
|
222
|
+
"name": name,
|
223
|
+
"status": "running" if is_running else "stopped",
|
224
|
+
"pid": pid,
|
225
|
+
"uptime": uptime,
|
226
|
+
"cpu": cpu_mem_stats["cpu"],
|
227
|
+
"memory": cpu_mem_stats["memory"]
|
228
|
+
})
|
229
|
+
|
230
|
+
# 使用Rich表格显示服务列表
|
231
|
+
print_service_table(services)
|
241
232
|
|
242
|
-
print(f"
|
243
|
-
print(f"
|
244
|
-
print()
|
233
|
+
console.print(f"[dim]配置文件: {manager.config_path}[/]")
|
234
|
+
console.print(f"[dim]运行中服务: {len(manager.get_running_services())}/{len(service_names)}[/]")
|
235
|
+
console.print()
|
245
236
|
|
246
237
|
return True
|
247
238
|
|
248
239
|
def main():
|
249
240
|
"""CLI 应用程序入口点"""
|
250
241
|
# 显示头部
|
251
|
-
print_header()
|
242
|
+
print_header("SERVLY - Modern Process Manager")
|
252
243
|
|
253
244
|
parser = setup_arg_parser()
|
254
245
|
args = parser.parse_args()
|
@@ -261,7 +252,7 @@ def main():
|
|
261
252
|
try:
|
262
253
|
service_manager = ServiceManager(config_path=args.config)
|
263
254
|
except Exception as e:
|
264
|
-
|
255
|
+
print_error(f"加载配置文件时出错: {escape(str(e))}")
|
265
256
|
return 1
|
266
257
|
|
267
258
|
# 创建日志管理器
|
@@ -283,10 +274,11 @@ def main():
|
|
283
274
|
parser.print_help()
|
284
275
|
return 1
|
285
276
|
except KeyboardInterrupt:
|
286
|
-
print(f"\n{Emojis.STOP}
|
277
|
+
console.print(f"\n{Emojis.STOP} 操作被用户中断", style="dim")
|
287
278
|
return 1
|
288
279
|
except Exception as e:
|
289
|
-
|
280
|
+
print_error(f"执行命令时出错: {escape(str(e))}")
|
281
|
+
logger.exception("命令执行异常")
|
290
282
|
return 1
|
291
283
|
|
292
284
|
return 0 if success else 1
|
servly/logs.py
CHANGED
@@ -1,56 +1,44 @@
|
|
1
1
|
"""
|
2
2
|
Log management functionality for Servly.
|
3
|
+
使用Rich库进行日志格式化和展示,实现PM2风格的日志效果。
|
3
4
|
"""
|
4
5
|
import os
|
5
6
|
import sys
|
6
7
|
import time
|
7
|
-
import select
|
8
|
-
import fcntl
|
9
|
-
import termios
|
10
|
-
import subprocess
|
11
8
|
import re
|
12
9
|
from pathlib import Path
|
13
10
|
from typing import List, Dict, Optional, Tuple
|
14
|
-
import random
|
15
11
|
|
12
|
+
from rich.console import Console
|
13
|
+
from rich.theme import Theme
|
14
|
+
from rich.text import Text
|
15
|
+
from rich.panel import Panel
|
16
|
+
from rich.table import Table
|
17
|
+
from rich.live import Live
|
18
|
+
from rich import box
|
16
19
|
|
17
|
-
#
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
BRIGHT_BLACK = "\033[90m"
|
33
|
-
BRIGHT_RED = "\033[91m"
|
34
|
-
BRIGHT_GREEN = "\033[92m"
|
35
|
-
BRIGHT_YELLOW = "\033[93m"
|
36
|
-
BRIGHT_BLUE = "\033[94m"
|
37
|
-
BRIGHT_MAGENTA = "\033[95m"
|
38
|
-
BRIGHT_CYAN = "\033[96m"
|
39
|
-
BRIGHT_WHITE = "\033[97m"
|
40
|
-
|
41
|
-
# 背景色
|
42
|
-
BG_BLACK = "\033[40m"
|
43
|
-
BG_RED = "\033[41m"
|
44
|
-
BG_GREEN = "\033[42m"
|
45
|
-
BG_YELLOW = "\033[43m"
|
46
|
-
BG_BLUE = "\033[44m"
|
47
|
-
BG_MAGENTA = "\033[45m"
|
48
|
-
BG_CYAN = "\033[46m"
|
49
|
-
BG_WHITE = "\033[47m"
|
20
|
+
# 自定义Rich主题
|
21
|
+
custom_theme = Theme({
|
22
|
+
"warning": "yellow",
|
23
|
+
"error": "bold red",
|
24
|
+
"info": "green",
|
25
|
+
"dim": "dim",
|
26
|
+
"stdout_service": "green",
|
27
|
+
"stderr_service": "red",
|
28
|
+
"header": "cyan bold",
|
29
|
+
"subheader": "bright_black",
|
30
|
+
"running": "green",
|
31
|
+
"stopped": "bright_black",
|
32
|
+
"restart": "magenta",
|
33
|
+
"separator": "cyan",
|
34
|
+
})
|
50
35
|
|
36
|
+
# 创建Rich控制台对象
|
37
|
+
console = Console(theme=custom_theme)
|
51
38
|
|
52
39
|
# 服务相关的 emoji
|
53
40
|
class Emojis:
|
41
|
+
"""服务状态相关的emoji图标"""
|
54
42
|
SERVICE = "🔧"
|
55
43
|
START = "🟢"
|
56
44
|
STOP = "🔴"
|
@@ -66,132 +54,108 @@ class Emojis:
|
|
66
54
|
STOPPED = "⛔"
|
67
55
|
LOADING = "⏳"
|
68
56
|
|
57
|
+
# Rich格式化输出工具函数
|
58
|
+
def print_header(title: str):
|
59
|
+
"""打印美化的标题"""
|
60
|
+
console.print()
|
61
|
+
console.rule(f"[header]{Emojis.SERVICE} {title}[/]", style="separator")
|
62
|
+
console.print()
|
69
63
|
|
70
|
-
|
71
|
-
|
72
|
-
""
|
73
|
-
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
74
|
-
return ansi_escape.sub('', text)
|
64
|
+
def print_info(message: str):
|
65
|
+
"""打印信息消息"""
|
66
|
+
console.print(f"{Emojis.INFO} {message}", style="info")
|
75
67
|
|
76
|
-
def
|
77
|
-
"""
|
78
|
-
|
79
|
-
# 处理中文等宽字符(在大多数终端中占用两个字符宽度)
|
80
|
-
length = 0
|
81
|
-
for char in text:
|
82
|
-
if ord(char) > 127: # 简单判断是否是非ASCII字符
|
83
|
-
length += 2
|
84
|
-
else:
|
85
|
-
length += 1
|
86
|
-
return length
|
68
|
+
def print_warning(message: str):
|
69
|
+
"""打印警告消息"""
|
70
|
+
console.print(f"{Emojis.WARNING} {message}", style="warning")
|
87
71
|
|
88
|
-
def
|
89
|
-
"""
|
90
|
-
|
72
|
+
def print_error(message: str):
|
73
|
+
"""打印错误消息"""
|
74
|
+
console.print(f"{Emojis.ERROR} {message}", style="error")
|
75
|
+
|
76
|
+
def print_success(message: str):
|
77
|
+
"""打印成功消息"""
|
78
|
+
console.print(f"{Emojis.RUNNING} {message}", style="running")
|
79
|
+
|
80
|
+
def print_service_table(services: List[Dict]):
|
81
|
+
"""打印服务状态表格,PM2风格紧凑布局"""
|
82
|
+
table = Table(show_header=True, header_style="header", expand=True, box=box.SIMPLE)
|
91
83
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
84
|
+
# PM2风格紧凑表头
|
85
|
+
table.add_column("name", style="cyan")
|
86
|
+
table.add_column("pid", justify="right")
|
87
|
+
table.add_column("uptime", justify="right")
|
88
|
+
table.add_column("cpu", justify="right")
|
89
|
+
table.add_column("mem", justify="right")
|
90
|
+
|
91
|
+
for service in services:
|
92
|
+
name = service["name"]
|
93
|
+
pid = service["pid"] or "-"
|
94
|
+
uptime = service.get("uptime", "-")
|
95
|
+
cpu = service.get("cpu", "0%")
|
96
|
+
memory = service.get("memory", "0b")
|
97
|
+
|
98
|
+
status_style = "running" if service["status"] == "running" else "stopped"
|
96
99
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
100
|
+
table.add_row(
|
101
|
+
Text(name, style=status_style),
|
102
|
+
Text(str(pid), style=status_style),
|
103
|
+
Text(str(uptime), style=status_style),
|
104
|
+
Text(str(cpu), style=status_style),
|
105
|
+
Text(str(memory), style=status_style)
|
106
|
+
)
|
102
107
|
|
103
|
-
|
104
|
-
|
105
|
-
elif align == 'center':
|
106
|
-
left_padding = padding // 2
|
107
|
-
right_padding = padding - left_padding
|
108
|
-
return ' ' * left_padding + text + ' ' * right_padding
|
109
|
-
else: # left alignment
|
110
|
-
return text + ' ' * padding
|
108
|
+
console.print(table)
|
109
|
+
console.print()
|
111
110
|
|
112
111
|
|
113
112
|
class LogManager:
|
114
|
-
"""
|
113
|
+
"""管理和显示服务日志"""
|
115
114
|
|
116
115
|
def __init__(self, log_dir: Path):
|
117
116
|
self.log_dir = log_dir
|
118
|
-
#
|
119
|
-
self.service_colors = {}
|
120
|
-
self.available_colors = [
|
121
|
-
Colors.GREEN, Colors.YELLOW, Colors.BLUE, Colors.MAGENTA, Colors.CYAN,
|
122
|
-
Colors.BRIGHT_GREEN, Colors.BRIGHT_YELLOW, Colors.BRIGHT_BLUE,
|
123
|
-
Colors.BRIGHT_MAGENTA, Colors.BRIGHT_CYAN
|
124
|
-
]
|
117
|
+
self.default_tail_lines = 15 # 默认展示最后15行日志
|
125
118
|
|
126
119
|
def get_log_files(self, service_name: str) -> Dict[str, Path]:
|
127
|
-
"""
|
120
|
+
"""获取服务的stdout和stderr日志文件路径"""
|
128
121
|
return {
|
129
122
|
'stdout': self.log_dir / f"{service_name}-out.log",
|
130
123
|
'stderr': self.log_dir / f"{service_name}-error.log"
|
131
124
|
}
|
132
125
|
|
133
|
-
def
|
134
|
-
"""
|
135
|
-
|
136
|
-
|
137
|
-
# 如果颜色用完了,就随机分配
|
138
|
-
self.service_colors[service_name] = random.choice([
|
139
|
-
Colors.GREEN, Colors.YELLOW, Colors.BLUE, Colors.MAGENTA, Colors.CYAN
|
140
|
-
])
|
141
|
-
else:
|
142
|
-
# 从可用颜色中选择一个
|
143
|
-
self.service_colors[service_name] = self.available_colors.pop(0)
|
144
|
-
return self.service_colors[service_name]
|
145
|
-
|
146
|
-
def _format_log_header(self, service: str, log_type: str) -> str:
|
147
|
-
"""格式化日志头部,带颜色和 emoji"""
|
148
|
-
service_color = self._get_service_color(service)
|
149
|
-
emoji = Emojis.STDOUT if log_type == 'stdout' else Emojis.STDERR
|
150
|
-
type_color = Colors.GREEN if log_type == 'stdout' else Colors.YELLOW
|
151
|
-
|
152
|
-
return (f"\n{Colors.BOLD}{Colors.WHITE}{Emojis.LOG} Log Stream: "
|
153
|
-
f"{service_color}{service}{Colors.RESET} "
|
154
|
-
f"{Colors.BRIGHT_BLACK}({type_color}{emoji} {log_type}{Colors.BRIGHT_BLACK}){Colors.RESET}\n"
|
155
|
-
f"{Colors.BRIGHT_BLACK}{'─' * 60}{Colors.RESET}")
|
156
|
-
|
157
|
-
def _format_log_line(self, service: str, log_type: str, line: str, show_timestamp: bool = True) -> str:
|
158
|
-
"""格式化单行日志,带颜色和 emoji"""
|
159
|
-
service_color = self._get_service_color(service)
|
160
|
-
timestamp = time.strftime("%Y-%m-%d %H:%M:%S") if show_timestamp else ""
|
126
|
+
def _parse_log_line(self, line: str) -> Tuple[str, str]:
|
127
|
+
"""解析日志行,提取时间戳和内容"""
|
128
|
+
timestamp = ""
|
129
|
+
content = line.rstrip()
|
161
130
|
|
162
|
-
#
|
163
|
-
|
164
|
-
if
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
prefix = f"{Emojis.WARNING} "
|
169
|
-
line_color = Colors.YELLOW
|
131
|
+
# 尝试提取时间戳
|
132
|
+
timestamp_match = re.search(r'(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})', line)
|
133
|
+
if timestamp_match:
|
134
|
+
timestamp = timestamp_match.group(1)
|
135
|
+
# 移除行中已有的时间戳部分
|
136
|
+
content = line.replace(timestamp, "", 1).lstrip().rstrip()
|
170
137
|
else:
|
171
|
-
|
138
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
172
139
|
|
173
|
-
|
174
|
-
if show_timestamp:
|
175
|
-
return (f"{Colors.BRIGHT_BLACK}[{Emojis.TIME} {timestamp}]{Colors.RESET} "
|
176
|
-
f"{service_color}{service}{Colors.RESET} "
|
177
|
-
f"{prefix}{line_color}{line.rstrip()}{Colors.RESET}")
|
178
|
-
else:
|
179
|
-
return f"{line_color}{line.rstrip()}{Colors.RESET}"
|
140
|
+
return timestamp, content
|
180
141
|
|
181
|
-
def tail_logs(self, service_names: List[str], follow: bool = True, lines: int =
|
142
|
+
def tail_logs(self, service_names: List[str], follow: bool = True, lines: int = None):
|
182
143
|
"""
|
183
|
-
|
144
|
+
显示服务日志
|
184
145
|
|
185
146
|
Args:
|
186
|
-
service_names:
|
187
|
-
follow:
|
188
|
-
lines:
|
147
|
+
service_names: 要查看的服务名称列表
|
148
|
+
follow: 是否实时跟踪日志(类似tail -f)
|
149
|
+
lines: 初始显示的行数,默认为self.default_tail_lines
|
189
150
|
"""
|
151
|
+
if lines is None:
|
152
|
+
lines = self.default_tail_lines
|
153
|
+
|
190
154
|
if not service_names:
|
191
|
-
|
155
|
+
print_warning("未指定要查看日志的服务。")
|
192
156
|
return
|
193
157
|
|
194
|
-
#
|
158
|
+
# 检查日志文件是否存在
|
195
159
|
log_files = []
|
196
160
|
for service in service_names:
|
197
161
|
service_logs = self.get_log_files(service)
|
@@ -199,52 +163,53 @@ class LogManager:
|
|
199
163
|
if log_path.exists():
|
200
164
|
log_files.append((service, log_type, log_path))
|
201
165
|
else:
|
202
|
-
|
203
|
-
print(f"{Emojis.WARNING} {
|
166
|
+
style = "stderr_service" if log_type == "stderr" else "stdout_service"
|
167
|
+
console.print(f"{Emojis.WARNING} 未找到服务 [{style}]{service}[/] 的 {log_type} 日志。", style="warning")
|
204
168
|
|
205
169
|
if not log_files:
|
206
|
-
|
170
|
+
print_warning("未找到指定服务的日志文件。")
|
207
171
|
return
|
208
172
|
|
209
173
|
if follow:
|
174
|
+
# 首先显示最后几行,然后再开始跟踪
|
175
|
+
self._display_recent_logs(log_files, lines)
|
210
176
|
self._follow_logs(log_files)
|
211
177
|
else:
|
212
178
|
self._display_recent_logs(log_files, lines)
|
213
179
|
|
214
180
|
def _display_recent_logs(self, log_files: List[Tuple[str, str, Path]], lines: int):
|
215
|
-
"""
|
181
|
+
"""显示最近的日志行"""
|
216
182
|
for service, log_type, log_path in log_files:
|
217
|
-
|
183
|
+
# PM2风格的标题
|
184
|
+
console.print(f"\n[dim]{log_path} last {lines} lines:[/]")
|
185
|
+
|
218
186
|
try:
|
219
187
|
# 读取最后N行
|
220
188
|
with open(log_path, 'r') as f:
|
221
189
|
content = f.readlines()
|
222
190
|
last_lines = content[-lines:] if len(content) >= lines else content
|
223
191
|
|
224
|
-
#
|
192
|
+
# 打印每一行,PM2格式
|
225
193
|
for line in last_lines:
|
226
|
-
|
194
|
+
timestamp, message = self._parse_log_line(line)
|
195
|
+
style = "stderr_service" if log_type == "stderr" else "stdout_service"
|
196
|
+
console.print(f"[{style}]{service}[/] | {timestamp}: {message}")
|
227
197
|
except Exception as e:
|
228
|
-
|
198
|
+
print_error(f"读取日志文件出错: {str(e)}")
|
229
199
|
|
230
200
|
def _follow_logs(self, log_files: List[Tuple[str, str, Path]]):
|
231
|
-
"""
|
232
|
-
# Dictionary to keep track of file positions
|
201
|
+
"""实时跟踪日志(类似tail -f)"""
|
233
202
|
file_handlers = {}
|
234
203
|
|
235
204
|
try:
|
236
|
-
#
|
205
|
+
# 打开所有日志文件
|
237
206
|
for service, log_type, log_path in log_files:
|
238
207
|
f = open(log_path, 'r')
|
239
|
-
#
|
208
|
+
# 移动到文件末尾
|
240
209
|
f.seek(0, os.SEEK_END)
|
241
210
|
file_handlers[(service, log_type)] = f
|
242
211
|
|
243
|
-
|
244
|
-
print(self._format_log_header(service, log_type))
|
245
|
-
|
246
|
-
print(f"\n{Emojis.LOADING} {Colors.BRIGHT_BLACK}Following logs... (Ctrl+C to stop){Colors.RESET}")
|
247
|
-
print(f"{Colors.BRIGHT_BLACK}{'─' * 60}{Colors.RESET}")
|
212
|
+
console.print(f"\n[dim]正在跟踪日志... (按Ctrl+C停止)[/]")
|
248
213
|
|
249
214
|
while True:
|
250
215
|
has_new_data = False
|
@@ -253,14 +218,16 @@ class LogManager:
|
|
253
218
|
line = f.readline()
|
254
219
|
if line:
|
255
220
|
has_new_data = True
|
256
|
-
|
221
|
+
timestamp, message = self._parse_log_line(line)
|
222
|
+
style = "stderr_service" if log_type == "stderr" else "stdout_service"
|
223
|
+
console.print(f"[{style}]{service}[/] | {timestamp}: {message}")
|
257
224
|
|
258
225
|
if not has_new_data:
|
259
226
|
time.sleep(0.1)
|
260
227
|
|
261
228
|
except KeyboardInterrupt:
|
262
|
-
print(f"\n
|
229
|
+
console.print(f"\n[dim]已停止日志跟踪[/]")
|
263
230
|
finally:
|
264
|
-
#
|
231
|
+
# 关闭所有文件
|
265
232
|
for f in file_handlers.values():
|
266
233
|
f.close()
|
servly/service.py
CHANGED
@@ -7,8 +7,10 @@ import signal
|
|
7
7
|
import yaml
|
8
8
|
import logging
|
9
9
|
import time
|
10
|
+
import psutil
|
10
11
|
from pathlib import Path
|
11
|
-
from
|
12
|
+
from datetime import datetime
|
13
|
+
from typing import Dict, Union, List, Optional, Any, Tuple
|
12
14
|
|
13
15
|
logger = logging.getLogger(__name__)
|
14
16
|
|
@@ -17,16 +19,40 @@ class ServiceManager:
|
|
17
19
|
|
18
20
|
def __init__(self, config_path: str = "servly.yml", servly_dir: str = ".servly"):
|
19
21
|
self.config_path = config_path
|
20
|
-
|
22
|
+
# 修改为使用配置文件所在目录
|
23
|
+
config_dir = os.path.dirname(os.path.abspath(config_path))
|
24
|
+
self.servly_dir = Path(config_dir) / servly_dir
|
21
25
|
self.pid_dir = self.servly_dir / "pids"
|
22
26
|
self.log_dir = self.servly_dir / "logs"
|
27
|
+
# 存储服务启动时间,用于计算运行时长
|
28
|
+
self.start_times = {}
|
23
29
|
self._ensure_dirs()
|
24
30
|
self.services = self._load_config()
|
31
|
+
# 加载现有的服务启动时间
|
32
|
+
self._load_start_times()
|
25
33
|
|
26
34
|
def _ensure_dirs(self):
|
27
35
|
"""Create required directories if they don't exist."""
|
28
36
|
self.pid_dir.mkdir(parents=True, exist_ok=True)
|
29
37
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
38
|
+
|
39
|
+
def _load_start_times(self):
|
40
|
+
"""加载已运行服务的启动时间"""
|
41
|
+
for service_name in self.get_service_names():
|
42
|
+
if self.is_running(service_name):
|
43
|
+
# 尝试从文件获取启动时间,如果没有则使用当前时间
|
44
|
+
start_time_file = self.pid_dir / f"{service_name}.time"
|
45
|
+
if start_time_file.exists():
|
46
|
+
try:
|
47
|
+
with open(start_time_file, "r") as f:
|
48
|
+
timestamp = float(f.read().strip())
|
49
|
+
self.start_times[service_name] = timestamp
|
50
|
+
except (ValueError, IOError):
|
51
|
+
# 如果文件无法读取或格式不正确,使用当前时间
|
52
|
+
self.start_times[service_name] = time.time()
|
53
|
+
else:
|
54
|
+
# 如果没有时间文件,使用当前时间
|
55
|
+
self.start_times[service_name] = time.time()
|
30
56
|
|
31
57
|
def _load_config(self) -> Dict[str, Any]:
|
32
58
|
"""Load service configurations from servly.yml."""
|
@@ -81,10 +107,45 @@ class ServiceManager:
|
|
81
107
|
else:
|
82
108
|
# Clean up stale PID file
|
83
109
|
os.remove(pid_file)
|
110
|
+
# 删除相关的启动时间记录
|
111
|
+
if service_name in self.start_times:
|
112
|
+
del self.start_times[service_name]
|
113
|
+
start_time_file = self.pid_dir / f"{service_name}.time"
|
114
|
+
if start_time_file.exists():
|
115
|
+
os.remove(start_time_file)
|
84
116
|
return None
|
85
117
|
except (ValueError, FileNotFoundError):
|
86
118
|
return None
|
87
119
|
|
120
|
+
def get_uptime(self, service_name: str) -> Optional[float]:
|
121
|
+
"""获取服务运行时间(以秒为单位)"""
|
122
|
+
if service_name in self.start_times and self.is_running(service_name):
|
123
|
+
return time.time() - self.start_times[service_name]
|
124
|
+
return None
|
125
|
+
|
126
|
+
def format_uptime(self, uptime_seconds: Optional[float]) -> str:
|
127
|
+
"""将运行时间格式化为易读格式"""
|
128
|
+
if uptime_seconds is None:
|
129
|
+
return "-"
|
130
|
+
|
131
|
+
# 转换为整数秒
|
132
|
+
seconds = int(uptime_seconds)
|
133
|
+
|
134
|
+
# 计算天、小时、分钟、秒
|
135
|
+
days, seconds = divmod(seconds, 86400)
|
136
|
+
hours, seconds = divmod(seconds, 3600)
|
137
|
+
minutes, seconds = divmod(seconds, 60)
|
138
|
+
|
139
|
+
# 格式化为易读字符串
|
140
|
+
if days > 0:
|
141
|
+
return f"{days}d {hours}h"
|
142
|
+
elif hours > 0:
|
143
|
+
return f"{hours}h {minutes}m"
|
144
|
+
elif minutes > 0:
|
145
|
+
return f"{minutes}m {seconds}s"
|
146
|
+
else:
|
147
|
+
return f"{seconds}s"
|
148
|
+
|
88
149
|
def _is_process_running(self, pid: int) -> bool:
|
89
150
|
"""Check if process with given PID is running."""
|
90
151
|
try:
|
@@ -158,6 +219,15 @@ class ServiceManager:
|
|
158
219
|
with open(self.get_pid_file(service_name), 'w') as f:
|
159
220
|
f.write(str(process.pid))
|
160
221
|
|
222
|
+
# 记录启动时间
|
223
|
+
start_time = time.time()
|
224
|
+
self.start_times[service_name] = start_time
|
225
|
+
|
226
|
+
# 保存启动时间到文件
|
227
|
+
start_time_file = self.pid_dir / f"{service_name}.time"
|
228
|
+
with open(start_time_file, 'w') as f:
|
229
|
+
f.write(str(start_time))
|
230
|
+
|
161
231
|
logger.info(f"Started service '{service_name}' with PID {process.pid}")
|
162
232
|
return True
|
163
233
|
|
@@ -192,6 +262,15 @@ class ServiceManager:
|
|
192
262
|
if os.path.exists(pid_file):
|
193
263
|
os.remove(pid_file)
|
194
264
|
|
265
|
+
# 清理启动时间记录
|
266
|
+
if service_name in self.start_times:
|
267
|
+
del self.start_times[service_name]
|
268
|
+
|
269
|
+
# 删除启动时间文件
|
270
|
+
start_time_file = self.pid_dir / f"{service_name}.time"
|
271
|
+
if start_time_file.exists():
|
272
|
+
os.remove(start_time_file)
|
273
|
+
|
195
274
|
logger.info(f"Stopped service '{service_name}'")
|
196
275
|
return True
|
197
276
|
|
@@ -200,6 +279,16 @@ class ServiceManager:
|
|
200
279
|
pid_file = self.get_pid_file(service_name)
|
201
280
|
if os.path.exists(pid_file):
|
202
281
|
os.remove(pid_file)
|
282
|
+
|
283
|
+
# 清理启动时间记录
|
284
|
+
if service_name in self.start_times:
|
285
|
+
del self.start_times[service_name]
|
286
|
+
|
287
|
+
# 删除启动时间文件
|
288
|
+
start_time_file = self.pid_dir / f"{service_name}.time"
|
289
|
+
if start_time_file.exists():
|
290
|
+
os.remove(start_time_file)
|
291
|
+
|
203
292
|
logger.info(f"Service '{service_name}' was not running")
|
204
293
|
return True
|
205
294
|
|
@@ -210,4 +299,51 @@ class ServiceManager:
|
|
210
299
|
def restart(self, service_name: str) -> bool:
|
211
300
|
"""Restart a service."""
|
212
301
|
self.stop(service_name)
|
213
|
-
return self.start(service_name)
|
302
|
+
return self.start(service_name)
|
303
|
+
|
304
|
+
def get_process_stats(self, service_name: str) -> Dict[str, Any]:
|
305
|
+
"""获取进程的 CPU 和内存使用情况"""
|
306
|
+
pid = self.get_service_pid(service_name)
|
307
|
+
stats = {"cpu_percent": None, "memory_percent": None, "memory_mb": None}
|
308
|
+
|
309
|
+
if pid:
|
310
|
+
try:
|
311
|
+
process = psutil.Process(pid)
|
312
|
+
# 获取 CPU 使用百分比 (非阻塞模式)
|
313
|
+
stats["cpu_percent"] = process.cpu_percent(interval=0)
|
314
|
+
|
315
|
+
# 获取内存使用情况
|
316
|
+
memory_info = process.memory_info()
|
317
|
+
stats["memory_mb"] = memory_info.rss / (1024 * 1024) # 转换为 MB
|
318
|
+
|
319
|
+
# 计算内存使用百分比
|
320
|
+
stats["memory_percent"] = process.memory_percent()
|
321
|
+
|
322
|
+
return stats
|
323
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
324
|
+
# 如果进程已不存在或无法访问,返回默认值
|
325
|
+
pass
|
326
|
+
|
327
|
+
return stats
|
328
|
+
|
329
|
+
def format_cpu_percent(self, cpu_percent: Optional[float]) -> str:
|
330
|
+
"""格式化 CPU 使用百分比"""
|
331
|
+
if cpu_percent is None:
|
332
|
+
return "0%"
|
333
|
+
return f"{cpu_percent:.1f}%"
|
334
|
+
|
335
|
+
def format_memory(self, memory_mb: Optional[float], memory_percent: Optional[float]) -> str:
|
336
|
+
"""格式化内存使用情况"""
|
337
|
+
if memory_mb is None:
|
338
|
+
return "0b"
|
339
|
+
|
340
|
+
# 如果小于 1MB,显示为 KB
|
341
|
+
if memory_mb < 1:
|
342
|
+
return f"{int(memory_mb * 1024)}kb"
|
343
|
+
|
344
|
+
# 如果大于 1GB,显示为 GB
|
345
|
+
if memory_mb > 1024:
|
346
|
+
return f"{memory_mb/1024:.1f}gb"
|
347
|
+
|
348
|
+
# 否则显示为 MB
|
349
|
+
return f"{int(memory_mb)}mb"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: servly
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.1
|
4
4
|
Summary: simple process manager
|
5
5
|
Author-email: simpxx <simpxx@gmail.com>
|
6
6
|
License: MIT
|
@@ -10,7 +10,9 @@ Classifier: License :: OSI Approved :: MIT License
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
11
11
|
Requires-Python: >=3.12
|
12
12
|
Description-Content-Type: text/markdown
|
13
|
+
Requires-Dist: psutil>=7.0.0
|
13
14
|
Requires-Dist: pyyaml>=6.0.2
|
15
|
+
Requires-Dist: rich>=14.0.0
|
14
16
|
|
15
17
|
# SERVLY
|
16
18
|
|
@@ -0,0 +1,9 @@
|
|
1
|
+
servly/__init__.py,sha256=HT8I94RyP6_gNoVJWOVSkeOEQIl3QnT60zCP2PJVon0,73
|
2
|
+
servly/cli.py,sha256=2x8jI8k24_U6JiNgTbHcoJDjRXuC7fgVDG1rEprc-tw,10337
|
3
|
+
servly/logs.py,sha256=MWnQbSR_PjLRDm9JLIO3uvEkw57DZey0Ocbu6glFJpc,8109
|
4
|
+
servly/service.py,sha256=-qm7slEELm8PG_NErviezI6iKuG08FID2wvXSc1NBYg,13722
|
5
|
+
servly-0.3.1.dist-info/METADATA,sha256=NHsI2XN3X13y_8NmLjj-KNJeWzX16U278tzSlzEHtoI,2687
|
6
|
+
servly-0.3.1.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
|
7
|
+
servly-0.3.1.dist-info/entry_points.txt,sha256=enHpE2d9DYdzfIWBcnIjuIkcNVk118xghL0AeTUL0Yg,43
|
8
|
+
servly-0.3.1.dist-info/top_level.txt,sha256=JLi7GX0KwWF-mh8i1-lVJ2q4SO1KfI2CNxQk_VNfNZE,7
|
9
|
+
servly-0.3.1.dist-info/RECORD,,
|
servly-0.2.0.dist-info/RECORD
DELETED
@@ -1,9 +0,0 @@
|
|
1
|
-
servly/__init__.py,sha256=HT8I94RyP6_gNoVJWOVSkeOEQIl3QnT60zCP2PJVon0,73
|
2
|
-
servly/cli.py,sha256=HeOOsnaJGMaiSWQ5Z-hkCLjxpmoUfES37hgfTHBuDMk,12142
|
3
|
-
servly/logs.py,sha256=HfC_EByTbgz6MpW8IpemEwpRPPE0xME46zUy2CyJvqU,9779
|
4
|
-
servly/service.py,sha256=5xhSD0HkfQw8xnY5J0GRxyFE8_CHBq5yfatbhL625PM,8123
|
5
|
-
servly-0.2.0.dist-info/METADATA,sha256=YICRthT2IW92tOXYTUmZN1cCGZ3LtWBhtLPIIZADKeA,2630
|
6
|
-
servly-0.2.0.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
|
7
|
-
servly-0.2.0.dist-info/entry_points.txt,sha256=enHpE2d9DYdzfIWBcnIjuIkcNVk118xghL0AeTUL0Yg,43
|
8
|
-
servly-0.2.0.dist-info/top_level.txt,sha256=JLi7GX0KwWF-mh8i1-lVJ2q4SO1KfI2CNxQk_VNfNZE,7
|
9
|
-
servly-0.2.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|