diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..35410cacd --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..c4379ba70 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..6a4ddcc51 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..e623d0283 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/supervisor.iml b/.idea/supervisor.iml new file mode 100644 index 000000000..f0e2c75c0 --- /dev/null +++ b/.idea/supervisor.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/INSTALL_CN.md b/INSTALL_CN.md new file mode 100644 index 000000000..3f66bcfe4 --- /dev/null +++ b/INSTALL_CN.md @@ -0,0 +1,73 @@ +# Supervisor 安装指南 + +## 安装方法 + +### 1. 使用 pip 安装(推荐) + +```bash +pip install supervisor +``` + +### 2. 从源码安装 + +```bash +# 克隆仓库 +git clone https://github.com/你的用户名/supervisor.git +cd supervisor + +# 安装 +pip install -e . +``` + +## 基本配置 + +1. 生成默认配置文件: + +```bash +echo_supervisord_conf > supervisord.conf +``` + +2. 编辑配置文件,根据需要修改: + +```bash +# 设置HTTP服务器端口 +[inet_http_server] +port=0.0.0.0:9001 +username=admin +password=123456 + +# 添加您的程序 +[program:yourapp] +command=/path/to/your/program +``` + +## 启动 Supervisor + +```bash +# 启动 Supervisor +supervisord -c supervisord.conf + +# 使用 supervisorctl 控制进程 +supervisorctl status +supervisorctl start all +supervisorctl stop all +supervisorctl restart all +``` + +## Web 界面 + +安装成功后,访问 http://localhost:9001 即可打开 Supervisor 的 Web 界面。 + +用户名:admin +密码:123456 (根据您的配置) + +## 组操作 + +本版本支持对进程组进行操作: + +- 重启组:点击组名旁边的"重启组"按钮 +- 停止组:点击组名旁边的"停止组"按钮 + +## 日志查看 + +点击进程名后的"查看日志"按钮,可以查看该进程的日志输出。 \ No newline at end of file diff --git a/PACKAGING_GUIDE_CN.md b/PACKAGING_GUIDE_CN.md new file mode 100644 index 000000000..d5061697c --- /dev/null +++ b/PACKAGING_GUIDE_CN.md @@ -0,0 +1,83 @@ +# Supervisor 打包与发布指南 + +## 打包过程 + +### 1. 环境准备 + +确保安装了必要的打包工具: + +```bash +pip install setuptools wheel twine +``` + +### 2. 修改版本号 + +1. 编辑 `supervisor/version.txt` 文件,设置正确的版本号 +2. 修改配置文件中的端口设置(如需要) + +### 3. 构建包 + +运行打包脚本生成分发包: + +```bash +./build_package.sh +``` + +打包完成后,会在 `dist/` 目录下生成以下文件: +- `supervisor-4.3.0.tar.gz` - 源码分发包 +- `supervisor-4.3.0-py2.py3-none-any.whl` - Python wheel 包 + +### 4. 测试安装包 + +在测试环境中测试生成的包: + +```bash +# 从源码包安装 +pip install dist/supervisor-4.3.0.tar.gz + +# 或从 wheel 包安装 +pip install dist/supervisor-4.3.0-py2.py3-none-any.whl +``` + +### 5. 上传到 PyPI(可选) + +如果你有 PyPI 帐号并想公开发布,可以使用: + +```bash +python -m twine upload dist/* +``` + +## 文档结构 + +发布版本应包含以下文档: + +- `README_CN.md` - 中文项目说明 +- `INSTALL_CN.md` - 中文安装指南 +- `RELEASE_NOTES_CN.md` - 中文发布说明 +- `PACKAGING_GUIDE_CN.md` - 中文打包指南(本文档) + +## 本地部署 + +如果只需要本地部署,可以将打包好的分发包复制到目标机器并安装: + +```bash +# 在目标机器上 +pip install supervisor-4.3.0.tar.gz +``` + +## 打包脚本说明 + +`build_package.sh` 脚本执行以下操作: + +1. 清理之前的构建文件 +2. 构建源码分发包和 wheel 包 +3. 显示生成的包文件列表 +4. 提供上传到 PyPI 的命令提示 + +## 故障排除 + +如果在打包过程中遇到问题: + +1. 检查 Python 版本是否兼容 +2. 确认所有依赖已正确安装 +3. 检查文件权限和路径是否正确 \ No newline at end of file diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 000000000..ff0eab824 --- /dev/null +++ b/README_CN.md @@ -0,0 +1,40 @@ +# Supervisor - 进程控制系统 + +Supervisor 是一个客户端/服务器系统,允许用户在类 UNIX 操作系统上控制多个进程。 + +## 特性 + +- **进程管理**:启动、停止、重启进程 +- **自动重启**:进程崩溃时自动重启 +- **状态监控**:监控进程状态 +- **日志管理**:收集和管理进程输出 +- **Web界面**:通过Web界面管理进程 +- **组操作**:对进程组进行批量操作 + +## 增强功能 + +本版本在原始 Supervisor 基础上增加了以下功能: + +1. **组操作按钮**:在Web界面中直接操作进程组 + - 重启组:一键重启整个组中的所有进程 + - 停止组:一键停止整个组中的所有进程 + +2. **日志查看增强**:美化了日志查看页面 + - 语法高亮 + - 行号显示 + - 自动滚动 + - 搜索功能 + - 复制功能 + +## 安装 + +详细安装说明请参考 [INSTALL_CN.md](INSTALL_CN.md)。 + +## 许可证 + +Supervisor 是根据类 BSD 许可证发布的,详见 [LICENSE.txt](LICENSES.txt)。 + +## 更多信息 + +- 官方文档:http://supervisord.org/ +- 源代码:https://github.com/Supervisor/supervisor \ No newline at end of file diff --git a/RELEASE_NOTES_CN.md b/RELEASE_NOTES_CN.md new file mode 100644 index 000000000..0c96cb2f1 --- /dev/null +++ b/RELEASE_NOTES_CN.md @@ -0,0 +1,48 @@ +# Supervisor 4.3.0 发布说明 + +## 版本亮点 + +Supervisor 4.3.0 版本是一个重要的增强版本,在标准 Supervisor 功能基础上增加了更多实用功能。 + +### 主要新特性 + +1. **进程组操作按钮** + - 在 Web 界面中直接添加了"重启组"和"停止组"按钮 + - 可以一键操作整个进程组,提高管理效率 + - 优化了按钮布局和交互体验 + +2. **增强的日志查看界面** + - 全新设计的日志查看页面 + - 添加了语法高亮显示 + - 支持行号显示 + - 提供自动滚动功能 + - 增加日志搜索功能 + - 一键复制日志内容 + +3. **用户界面改进** + - 优化了组和进程的显示层次结构 + - 改进了按钮布局和样式 + - 增强了整体视觉体验 + +### 错误修复 + +- 修复了组操作功能中的异步回调处理问题 +- 解决了多个界面显示和布局问题 +- 修复了 RPC 接口调用相关的错误 + +## 安装指南 + +详细安装说明请参阅 [INSTALL_CN.md](INSTALL_CN.md)。 + +## 升级说明 + +如果您正在从之前的版本升级,只需按照安装指南重新安装即可。配置文件格式保持兼容。 + +## 兼容性 + +- 支持 Python 2.7 及 Python 3.4+ +- 兼容所有主流 UNIX/Linux 系统及 macOS + +## 致谢 + +特别感谢所有为此版本贡献代码、测试和反馈的开发者。 \ No newline at end of file diff --git a/SUPERVISOR_USAGE_CN.md b/SUPERVISOR_USAGE_CN.md new file mode 100644 index 000000000..5e616258f --- /dev/null +++ b/SUPERVISOR_USAGE_CN.md @@ -0,0 +1,163 @@ +# Supervisor 使用说明 + +## 配置文件位置 + +当前项目使用的配置文件位于: +``` +/Users/feiwentao/tianhei_projects/python_projests/supervisor/supervisord.conf +``` + +## 常用命令 + +为了更方便地操作 Supervisor,我们创建了一个命令辅助脚本 `supervisor_cmd.sh`。使用此脚本可以避免认证错误和路径问题。 + +### 基本命令 + +```bash +# 查看所有进程状态 +./supervisor_cmd.sh status + +# 启动特定进程 +./supervisor_cmd.sh start <进程名> + +# 停止特定进程 +./supervisor_cmd.sh stop <进程名> + +# 重启特定进程 +./supervisor_cmd.sh restart <进程名> + +# 关闭 Supervisor +./supervisor_cmd.sh shutdown +``` + +### 组操作命令 + +```bash +# 启动整个组 +./supervisor_cmd.sh start <组名>:* + +# 停止整个组 +./supervisor_cmd.sh stop <组名>:* + +# 重启整个组 +./supervisor_cmd.sh restart <组名>:* +``` + +### 配置管理命令 + +```bash +# 重新读取配置文件 +./supervisor_cmd.sh reread + +# 更新配置(应用新的配置) +./supervisor_cmd.sh update + +# 显示所有命令帮助 +./supervisor_cmd.sh help +``` + +## 修改配置文件 + +使用文本编辑器打开配置文件: + +```bash +vim supervisord.conf +# 或者 +open -a TextEdit supervisord.conf +``` + +### 配置文件主要部分 + +1. **HTTP 服务器设置**: + ```ini + [inet_http_server] + port=0.0.0.0:9001 # 端口设置 + username=admin # 登录用户名 + password=123456 # 登录密码 + ``` + +2. **进程配置**: + ```ini + [program:程序名称] + command=要执行的命令 # 必填,程序启动命令 + directory=工作目录 # 程序的工作目录 + autostart=true # 是否自动启动 + autorestart=true # 是否自动重启 + redirect_stderr=true # 是否重定向错误输出 + stdout_logfile=日志路径 # 日志文件位置 + ``` + +3. **进程组配置**: + ```ini + [group:组名] + programs=程序1,程序2 # 组内的程序列表 + priority=999 # 启动优先级 + ``` + +### 添加新程序 + +1. 在配置文件末尾添加新的程序段: + ```ini + [program:新程序名] + command=python /path/to/your/script.py + directory=/path/to/working/dir + autostart=true + autorestart=true + redirect_stderr=true + stdout_logfile=./logs/新程序名.log + environment=变量1="值1",变量2="值2" + ``` + +2. 如果要将程序添加到组中,先添加程序配置,然后添加或修改组配置: + ```ini + [group:组名] + programs=现有程序1,现有程序2,新程序名 + priority=999 + ``` + +### 修改现有程序配置 + +找到对应的 `[program:xxx]` 部分,修改相应的参数。常用参数包括: + +- **command**: 启动命令 +- **directory**: 工作目录 +- **autostart**: 是否自动启动(true/false) +- **autorestart**: 自动重启(true/false/unexpected) +- **redirect_stderr**: 是否将错误输出重定向到标准输出(true/false) +- **stdout_logfile**: 标准输出日志文件路径 +- **environment**: 环境变量设置 + +## Web 界面 + +Supervisor 提供了一个 Web 界面来管理您的进程: + +- 地址:http://localhost:9001 +- 用户名:admin +- 密码:123456 (根据配置文件设置) + +通过 Web 界面,您可以: +- 查看所有进程的状态 +- 启动/停止/重启单个进程 +- 启动/停止/重启整个进程组 +- 查看进程日志 + +## 常见问题解决 + +### 1. 认证错误 +问题:`Server requires authentication: error: 401 Unauthorized` +解决:使用 `./supervisor_cmd.sh` 脚本执行命令,或指定配置文件路径: +```bash +supervisorctl -c $(pwd)/supervisord.conf -u admin -p 123456 status +``` + +### 2. 无法连接到 Supervisor +问题:`unix:///tmp/supervisor.sock no such file` +解决:确保 Supervisor 已启动,并且 socket 文件路径正确。 + +### 3. 进程启动后立即退出 +问题:进程状态显示为 `FATAL` 或 `BACKOFF` +解决: +- 检查命令是否正确 +- 查看进程日志了解详细错误信息 +- 确保工作目录正确 +- 检查环境变量设置 \ No newline at end of file diff --git a/build_package.sh b/build_package.sh new file mode 100755 index 000000000..66a83606d --- /dev/null +++ b/build_package.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# 打包和发布Supervisor +set -e + +# 清理之前的构建 +echo "清理之前的构建..." +rm -rf build/ dist/ *.egg-info/ + +# 构建源码包和wheel包 +echo "构建源码包和wheel包..." +python setup.py sdist bdist_wheel + +echo "构建完成!" +echo "生成的包在dist/目录下" +ls -la dist/ + +echo "" +echo "如需上传到PyPI,请运行:" +echo "python -m twine upload dist/*" \ No newline at end of file diff --git a/scripts/counter.py b/scripts/counter.py new file mode 100644 index 000000000..356bbd1b5 --- /dev/null +++ b/scripts/counter.py @@ -0,0 +1,9 @@ +import time + +print("计数器脚本已启动") + +count = 0 +while True: + count += 1 + print(f"当前计数: {count}") + time.sleep(2) \ No newline at end of file diff --git a/scripts/cpu_monitor.py b/scripts/cpu_monitor.py new file mode 100644 index 000000000..28d5938f9 --- /dev/null +++ b/scripts/cpu_monitor.py @@ -0,0 +1,10 @@ +import time +import psutil + +print("CPU监控脚本已启动") + +while True: + print(f"CPU使用率: {psutil.cpu_percent(interval=1)}%") + for i, percentage in enumerate(psutil.cpu_percent(interval=1, percpu=True)): + print(f"CPU {i}: {percentage}%") + time.sleep(8) \ No newline at end of file diff --git a/scripts/memory_monitor.py b/scripts/memory_monitor.py new file mode 100644 index 000000000..c283c07f7 --- /dev/null +++ b/scripts/memory_monitor.py @@ -0,0 +1,10 @@ +import time +import psutil + +print("内存监控脚本已启动") + +while True: + memory = psutil.virtual_memory() + print(f"内存使用率: {memory.percent}%") + print(f"可用内存: {memory.available / (1024 * 1024):.2f} MB") + time.sleep(10) \ No newline at end of file diff --git a/scripts/time_printer.py b/scripts/time_printer.py new file mode 100644 index 000000000..c3fc2e15f --- /dev/null +++ b/scripts/time_printer.py @@ -0,0 +1,9 @@ +import time +import datetime + +print("时间打印脚本已启动") + +while True: + now = datetime.datetime.now() + print(f"当前时间: {now.strftime('%Y-%m-%d %H:%M:%S')}") + time.sleep(5) \ No newline at end of file diff --git a/supervisor/http.py b/supervisor/http.py index af3e3da87..9a4e370c8 100644 --- a/supervisor/http.py +++ b/supervisor/http.py @@ -1,3 +1,9 @@ +# -*- coding: utf-8 -*- +"""Medusa HTTP server implementation + +This module contains the HTTP server implementation used by supervisor. +""" + import os import stat import time @@ -6,6 +12,7 @@ import errno import weakref import traceback +import supervisor.options try: import pwd @@ -635,10 +642,11 @@ def checkused(self, socketname): return True class tail_f_producer: - def __init__(self, request, filename, head): + def __init__(self, request, filename, head, is_html=False): self.request = weakref.ref(request) self.filename = filename self.delay = 0.1 + self.is_html = is_html self._open() sz = self._fsize() @@ -658,14 +666,45 @@ def more(self): bytes_added = newsz - self.sz if bytes_added < 0: self.sz = 0 - return "==> File truncated <==\n" + return "==> File truncated <==\n" if not self.is_html else "==> File truncated <==\n" if bytes_added > 0: self.file.seek(-bytes_added, 2) - bytes = self.file.read(bytes_added) + data = self.file.read(bytes_added) self.sz = newsz - return bytes + + if self.is_html and data: + # HTML escape to prevent breaking HTML structure + data = data.replace(b'&', b'&').replace(b'<', b'<').replace(b'>', b'>') + # Highlight log levels + data = self._highlight_log_levels(data) + + return data return NOT_DONE_YET + def _highlight_log_levels(self, data): + """Highlight different log levels""" + import re + + # Convert byte data to string for regular expression + if isinstance(data, bytes): + data_str = data.decode('utf-8', errors='replace') + else: + data_str = data + + # Define regex patterns for log levels and corresponding CSS classes + patterns = [ + (r'\b(ERROR|CRITICAL|FATAL)\b', 'log-error'), + (r'\b(WARN|WARNING)\b', 'log-warn'), + (r'\b(INFO|NOTICE)\b', 'log-info'), + (r'\b(DEBUG|TRACE)\b', 'log-debug') + ] + + # Add span tags for each match + for pattern, css_class in patterns: + data_str = re.sub(pattern, r'\1' % css_class, data_str) + + return data_str.encode('utf-8') + def _open(self): self.file = open(self.filename, 'rb') self.ino = os.fstat(self.file.fileno())[stat.ST_INO] @@ -680,7 +719,7 @@ def _follow(self): except (OSError, ValueError): # file was unlinked return - + if self.ino != ino: # log rotation occurred self._close() self._open() @@ -744,18 +783,168 @@ def handle_request(self, request): request.error(404) # not found return - mtime = os.stat(logfile)[stat.ST_MTIME] - request['Last-Modified'] = http_date.build_http_date(mtime) - request['Content-Type'] = 'text/plain;charset=utf-8' - # the lack of a Content-Length header makes the outputter - # send a 'Transfer-Encoding: chunked' response - request['X-Accel-Buffering'] = 'no' - # tell reverse proxy server (e.g., nginx) to disable proxy buffering - # (see also http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) - - request.push(tail_f_producer(request, logfile, 1024)) - - request.done() + # Get the format parameter from request to decide whether to return HTML or plain text + is_html = False + if query: + import cgi + parsed_query = cgi.parse_qs(query) + is_html = parsed_query.get('format', ['plain'])[0] == 'html' + + if is_html: + # Return a beautified HTML page + mtime = os.stat(logfile)[stat.ST_MTIME] + request['Last-Modified'] = http_date.build_http_date(mtime) + request['Content-Type'] = 'text/html;charset=utf-8' + request['X-Accel-Buffering'] = 'no' + + # Create the full process name + full_process_name = process_name + if group_name != process_name: + full_process_name = "%s:%s" % (group_name, process_name) + + # Build HTML header + html_head = ''' + + + + Process %s Log + + + +
+
+

Log for Process %s

+
+ +
+
+ +
+
''' % (full_process_name, full_process_name, request.args[0])
+
+            html_foot = '''
+
+ + +
+ +''' % (supervisor.options.VERSION) + + # Send response with beautified page + request.push(html_head) + request.push(tail_f_producer(request, logfile, 1024, is_html=True)) + request.push(html_foot) + request.done() + + else: + # Original plain text response + # Set content type and necessary headers + request['Content-Type'] = 'text/plain;charset=utf-8' + mtime = os.stat(logfile)[stat.ST_MTIME] + request['Last-Modified'] = http_date.build_http_date(mtime) + request['X-Accel-Buffering'] = 'no' + + # Directly push log content + request.push(tail_f_producer(request, logfile, 1024, is_html=False)) + request.done() class mainlogtail_handler: IDENT = 'Main Logtail HTTP Request Handler' diff --git a/supervisor/ui/status.html b/supervisor/ui/status.html index 166e3e32f..5dc7e2685 100644 --- a/supervisor/ui/status.html +++ b/supervisor/ui/status.html @@ -3,66 +3,81 @@ + Supervisor Status
-
-
+ - - - - - - - - - +
+
+
+
+

Group Name

+ +
+
+
StateDescriptionNameAction
+ + + + + + - - - - - - - - -
StateDescriptionNameAction
nominalInfoName - -
+ + + Normal + + + Info + + + Name + + + + + + +
+
- - - -
diff --git a/supervisor/ui/stylesheets/supervisor.css b/supervisor/ui/stylesheets/supervisor.css index 12aba9288..e553bf90b 100644 --- a/supervisor/ui/stylesheets/supervisor.css +++ b/supervisor/ui/stylesheets/supervisor.css @@ -1,215 +1,428 @@ -/* =ORDER - 1. display - 2. float and position - 3. width and height - 4. Specific element properties - 5. margin - 6. border - 7. padding - 8. background - 9. color -10. font related properties ------------------------------------------------ */ - -/* =MAIN ------------------------------------------------ */ -body, td, input, select, textarea, a { - font: 12px/1.5em arial, helvetica, verdana, sans-serif; - color: #333; -} -html, body, form, fieldset, h1, h2, h3, h4, h5, h6, -p, pre, blockquote, ul, ol, dl, address { +/* Supervisor Status Page Modern Style */ +/* Reset and Base Styles */ +* { margin: 0; padding: 0; -} -form label { - cursor: pointer; -} -fieldset { - border: none; -} -img, table { - border-width: 0; + box-sizing: border-box; } -/* =COLORS ------------------------------------------------ */ body { - background-color: #FFFFF3; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; color: #333; + background: #f5f7fa; } -a:link, -a:visited { - color: #333; + +a { + color: #2c7be5; + text-decoration: none; + transition: color 0.2s, background-color 0.2s; } + a:hover { - color: #000; + color: #1a56a8; } -/* =FLOATS ------------------------------------------------ */ -.left { - float: left; -} -.right { - text-align: right; - float: right; -} -/* clear float */ -.clr:after { - content: "."; - display: block; - height: 0; - clear: both; - visibility: hidden; +/* Layout */ +#wrapper { + max-width: 1400px; + margin: 0 auto; + padding: 20px; + min-height: 100vh; } -.clr {display: inline-block;} -/* Hides from IE-mac \*/ -* html .clr {height: 1%;} -.clr {display: block;} -/* End hide from IE-mac */ -/* =LAYOUT ------------------------------------------------ */ -html, body { - height: 100%; +/* Header */ +#header { + display: flex; + align-items: center; + margin-bottom: 30px; + padding: 20px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } -#wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - width: 850px; - margin: 0 auto -31px; + +#header img { + height: 40px; + margin-right: 15px; } -#footer, -.push { - height: 30px; + +#header h1 { + font-size: 24px; + color: #2c3e50; + margin: 0; } .hidden { display: none; } -/* =STATUS ------------------------------------------------ */ -#header { - margin-bottom: 13px; - padding: 10px 0 13px 0; - background: url("../images/rule.gif") left bottom repeat-x; -} +/* Status Message */ .status_msg { - padding: 5px 10px; - border: 1px solid #919191; - background-color: #FBFBFB; - color: #000000; + padding: 15px; + margin-bottom: 20px; + background: #e1f5fe; + border-left: 4px solid #03a9f4; + border-radius: 4px; + transition: opacity 0.5s ease-out; } +/* Action Buttons */ #buttons { - margin: 13px 0; -} -#buttons li { - float: left; - display: block; - margin: 0 7px 0 0; -} -#buttons a { - float: left; - display: block; - padding: 1px 0 0 0; -} -#buttons a, #buttons a:link { - text-decoration: none; + list-style: none; + display: flex; + gap: 10px; + margin-bottom: 20px; } -.action-button { - border: 1px solid #919191; - text-transform: uppercase; - padding: 0 5px; +#buttons li a { + display: inline-flex; + align-items: center; + padding: 8px 16px; + background: #1976d2; + color: white; + text-decoration: none; border-radius: 4px; - color: #50504d; - font-size: 12px; - background: #fbfbfb; - font-weight: 600; + font-weight: 500; + transition: all 0.2s ease; } -.action-button:hover { - border: 1px solid #88b0f2; - background: #ffffff; +#buttons li a:hover { + background: #1565c0; + transform: translateY(-1px); } +/* Tables */ table { width: 100%; - border: 1px solid #919191; + border-collapse: collapse; + margin: 0; + table-layout: fixed; } + th { - background-color: #919191; - color: #fff; text-align: left; + padding: 12px 15px; + background-color: #f3f4f6; + border-bottom: 1px solid #ddd; + font-weight: 600; + color: #4b5563; } -th.state { - text-align: center; - width: 44px; -} -th.desc { - width: 200px; + +th.state, td.status { + width: 120px; } -th.name { - width: 200px; + +th.desc, td:nth-child(2) { + width: 250px; } -th.action { + +th.name, td:nth-child(3) { + width: auto; + min-width: 250px; } -td, th { - padding: 4px 8px; - border-bottom: 1px solid #fff; + +th.action, td.action { + width: 300px; } -tr td { - background-color: #FBFBFB; + +td { + padding: 10px 15px; + border-bottom: 1px solid #eee; + vertical-align: middle; } -tr.shade td { - background-color: #F0F0F0; + +td:nth-child(3) a { + word-break: break-word; + display: inline-block; + width: 100%; + white-space: normal; } -.action ul { - list-style: none; - display: inline; + +tr:last-child td { + border-bottom: none; } -.action li { - margin-right: 10px; - display: inline; + +tr.shade { + background-color: #f9f9f9; } -/* status message */ +/* Status Indicators */ .status span { - display: block; - width: 60px; - height: 16px; - border: 1px solid #fff; + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; text-align: center; - font-size: 95%; - line-height: 1.4em; -} -.statusnominal { - background-image: url("../images/state0.gif"); + min-width: 90px; } + .statusrunning { - background-image: url("../images/state2.gif"); + background: #e3f2fd; + color: #1976d2; } + +.statusnominal { + background: #e8f5e9; + color: #2e7d32; +} + .statuserror { - background-image: url("../images/state3.gif"); + background: #ffebee; + color: #c62828; +} + +.statusstopped { + background: #fff3e0; + color: #e65100; +} + +/* Action Links */ +.action ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: nowrap; + gap: 10px; +} + +.action ul li { + display: inline-block; +} + +.action ul li a { + display: inline-block; + white-space: nowrap; + padding: 5px 10px; + font-size: 13px; + color: #1976d2; + text-decoration: none; + border: 1px solid #e0e0e0; + border-radius: 4px; + background-color: #f5f5f5; + transition: all 0.2s ease; +} + +.action ul li a:hover { + background: #1976d2; + color: white; + border-color: #1976d2; } +/* 移除分隔符,使用边框样式替代 */ +td.action ul li:not(:last-child):after { + content: none !important; + margin-left: 0; + display: none; +} + +/* Footer */ #footer { - width: 760px; - margin: 0 auto; - padding: 0 10px; - line-height: 30px; - border: 1px solid #C8C8C2; - border-bottom-width: 0; - background-color: #FBFBFB; + margin-top: 40px; + padding: 20px; + border-top: 1px solid #e9ecef; + color: #6c757d; + font-size: 14px; +} + +/* Utilities */ +.left { + float: left; +} + +.right { + float: right; +} + +.clr:after { + content: ''; + display: table; + clear: both; +} + +/* 进程组样式 */ +.process-group { + margin-bottom: 30px; + border: 1px solid #e0e0e0; + border-radius: 6px; overflow: hidden; - opacity: 0.7; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.group-header { + padding: 10px 15px; + background-color: #f0f0f0; + border-bottom: 1px solid #ddd; +} + +.title-with-actions { + display: flex; + align-items: center; +} + +.group-title { + margin: 0; + padding: 0; + font-size: 16px; + font-weight: 600; +} + +.group-actions { + display: flex; + margin-left: 20px; +} + +.group-actions ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 10px; +} + +.group-actions ul li a { + display: inline-block; + padding: 4px 12px; + background: #f5f5f5; + color: #333; + text-decoration: none; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + transition: all 0.2s ease; +} + +.group-actions ul li a:hover { + background: #e0e0e0; color: #000; - font-size: 95%; } -#footer a { - font-size: inherit; + +/* 为组操作按钮添加特定颜色 */ +.group-actions ul li:nth-child(1) a { + background: #17a2b8; + color: white; + border-color: #138496; +} + +.group-actions ul li:nth-child(2) a { + background: #dc3545; + color: white; + border-color: #c82333; +} + +.group-actions ul li:nth-child(1) a:hover { + background: #138496; +} + +.group-actions ul li:nth-child(2) a:hover { + background: #c82333; +} + +.group-summary { + display: flex; + gap: 10px; +} + +.group-status { + font-size: 14px; + padding: 4px 10px; + border-radius: 20px; + background-color: #f5f5f5; +} + +.group-status.running { + background-color: #e1f8f0; + color: #0abb87; +} + +.group-status.error { + background-color: #ffe8ef; + color: #fd397a; +} + +.group-status.partial { + background-color: #fff4de; + color: #ffb822; +} + +.group-icon { + margin-right: 5px; + transition: transform 0.2s; +} + +.group-content { + width: 100%; +} + +/* Responsive Design */ +@media (max-width: 768px) { + #wrapper { + padding: 10px; + } + + #buttons { + flex-direction: column; + } + + .action-button a { + width: 100%; + justify-content: center; + } + + .action ul { + flex-direction: column; + } + + .action a { + text-align: center; + } + + table { + display: block; + overflow-x: auto; + } + + .group-header { + flex-direction: column; + align-items: flex-start; + } + + .group-summary { + width: 100%; + margin-top: 8px; + justify-content: space-between; + } +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.status_msg { + animation: fadeIn 0.3s ease-out; +} + +td.action ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + gap: 8px; +} + +td.action ul li { + display: inline; +} + +td.action a { + color: #0066cc; + text-decoration: none; +} + +td.action a:hover { + text-decoration: underline; } diff --git a/supervisor/ui/tail.html b/supervisor/ui/tail.html index 117cb5f5d..d0a80b9b3 100644 --- a/supervisor/ui/tail.html +++ b/supervisor/ui/tail.html @@ -3,25 +3,252 @@ - Supervisor Status + + Process Log + + + -
- -

-
-
- -
- - - +
+
+

Process Log

+
+ + + + + +
+
+ + + +
+

+  
+ +
- - -
+ diff --git a/supervisor/version.txt b/supervisor/version.txt index b75a1c1c0..80895903a 100644 --- a/supervisor/version.txt +++ b/supervisor/version.txt @@ -1 +1 @@ -4.3.0.dev0 +4.3.0 diff --git a/supervisor/web.py b/supervisor/web.py index 926e8d43f..16159d7b1 100644 --- a/supervisor/web.py +++ b/supervisor/web.py @@ -1,3 +1,9 @@ +# -*- coding: utf-8 -*- +"""Web interface implementation + +This module contains the web interface implementation used by supervisor. +""" + import os import re import time @@ -176,6 +182,17 @@ def __call__(self): response = self.context.response headers = response['headers'] + + # Handle direct HTML string return + if isinstance(body, str) or isinstance(body, unicode): + headers['Content-Type'] = self.content_type + headers['Pragma'] = 'no-cache' + headers['Cache-Control'] = 'no-cache' + headers['Expires'] = http_date.build_http_date(0) + response['body'] = as_bytes(body) + return response + + # Original handling logic headers['Content-Type'] = self.content_type headers['Pragma'] = 'no-cache' headers['Cache-Control'] = 'no-cache' @@ -193,7 +210,8 @@ class TailView(MeldView): def render(self): supervisord = self.context.supervisord form = self.context.form - + root = self.clone() + if not 'processname' in form: tail = 'No process name found' processname = None @@ -216,67 +234,77 @@ def render(self): tail = 'ERROR: unexpected rpc fault [%d] %s' % ( e.code, e.text) - root = self.clone() - - title = root.findmeld('title') - title.content('Supervisor tail of process %s' % processname) - tailbody = root.findmeld('tailbody') - tailbody.content(tail) - - refresh_anchor = root.findmeld('refresh_anchor') + title_text = 'Process Log' if processname is None else 'Log for Process %s' % processname + refresh_url = '' if processname is not None: - refresh_anchor.attributes( - href='tail.html?processname=%s&limit=%s' % ( + refresh_url = 'tail.html?processname=%s&limit=%s' % ( urllib.quote(processname), urllib.quote(str(abs(limit))) ) - ) - else: - refresh_anchor.deparent() - - return as_string(root.write_xhtmlstring()) + + # Set values in the template + root.findmeld('title').content(title_text) + root.findmeld('header_title').content(title_text) + refresh_anchor = root.findmeld('refresh_anchor') + refresh_anchor.attributes(href=refresh_url) + tailbody = root.findmeld('tailbody') + tailbody.content(tail) + + return root.write_xhtmlstring() class StatusView(MeldView): def actions_for_process(self, process): - state = process.get_state() - processname = urllib.quote(make_namespec(process.group.config.name, - process.config.name)) - start = { - 'name': 'Start', - 'href': 'index.html?processname=%s&action=start' % processname, - 'target': None, - } - restart = { - 'name': 'Restart', + state = process['state'] + processname = urllib.quote(make_namespec(process['group'], process['name'])) + actions = [] + + if state == ProcessStates.RUNNING: + actions.extend([ + { + 'name': 'restart', 'href': 'index.html?processname=%s&action=restart' % processname, - 'target': None, - } - stop = { - 'name': 'Stop', + }, + { + 'name': 'stop', 'href': 'index.html?processname=%s&action=stop' % processname, - 'target': None, - } - clearlog = { - 'name': 'Clear Log', + }, + { + 'name': 'clearlog', 'href': 'index.html?processname=%s&action=clearlog' % processname, - 'target': None, - } - tailf_stdout = { - 'name': 'Tail -f Stdout', + }, + { + 'name': 'view output', 'href': 'logtail/%s' % processname, 'target': '_blank' } - tailf_stderr = { - 'name': 'Tail -f Stderr', - 'href': 'logtail/%s/stderr' % processname, + ]) + elif state in (ProcessStates.STOPPED, ProcessStates.EXITED, ProcessStates.FATAL): + actions.extend([ + { + 'name': 'start', + 'href': 'index.html?processname=%s&action=start' % processname, + }, + { + 'name': 'clearlog', + 'href': 'index.html?processname=%s&action=clearlog' % processname, + }, + { + 'name': 'view output', + 'href': 'logtail/%s' % processname, 'target': '_blank' } - if state == ProcessStates.RUNNING: - actions = [restart, stop, clearlog, tailf_stdout, tailf_stderr] - elif state in (ProcessStates.STOPPED, ProcessStates.EXITED, - ProcessStates.FATAL): - actions = [start, None, clearlog, tailf_stdout, tailf_stderr] + ]) else: - actions = [None, None, clearlog, tailf_stdout, tailf_stderr] + actions.extend([ + { + 'name': 'clearlog', + 'href': 'index.html?processname=%s&action=clearlog' % processname, + }, + { + 'name': 'view output', + 'href': 'logtail/%s' % processname, + 'target': '_blank' + } + ]) return actions def css_class_for_state(self, state): @@ -284,6 +312,8 @@ def css_class_for_state(self, state): return 'statusrunning' elif state in (ProcessStates.FATAL, ProcessStates.BACKOFF): return 'statuserror' + elif state == ProcessStates.STOPPED: + return 'statusstopped' else: return 'statusnominal' @@ -327,6 +357,16 @@ def restartall(): return 'All restarted at %s' % time.ctime() restartall.delay = 0.05 return restartall + + elif action == 'startgroup': + # Start the entire group + return self.start_group(namespec) + elif action == 'stopgroup': + # Stop the entire group + return self.stop_group(namespec) + elif action == 'restartgroup': + # Restart the entire group + return self.restart_group(namespec) elif namespec: def wrong(): @@ -471,15 +511,13 @@ def render(self): if not self.callback: self.callback = self.make_callback(processname, action) return NOT_DONE_YET - else: - message = self.callback() + message = self.callback() if message is NOT_DONE_YET: return NOT_DONE_YET if message is not None: server_url = form['SERVER_URL'] - location = server_url + "/" + '?message=%s' % urllib.quote( - message) + location = server_url + "/" + '?message=%s' % urllib.quote(message) response['headers']['Location'] = location supervisord = self.context.supervisord @@ -488,73 +526,102 @@ def render(self): SupervisorNamespaceRPCInterface(supervisord))] ) - processnames = [] - for group in supervisord.process_groups.values(): - for gprocname in group.processes.keys(): - processnames.append((group.config.name, gprocname)) - - processnames.sort() - - data = [] - for groupname, processname in processnames: - actions = self.actions_for_process( - supervisord.process_groups[groupname].processes[processname]) - sent_name = make_namespec(groupname, processname) - info = rpcinterface.supervisor.getProcessInfo(sent_name) - data.append({ - 'status':info['statename'], - 'name':processname, - 'group':groupname, - 'actions':actions, - 'state':info['state'], - 'description':info['description'], - }) + # Organize by groups and standalone processes + groups = {} # group name -> process list + ungrouped = [] # ungrouped process list + + # First build a set of all group names + group_names = set() + for process_group in supervisord.process_groups.values(): + # Check if it's a real group (has multiple processes) + processes = process_group.processes + if len(processes) > 1: + # It's a group + group_names.add(process_group.config.name) + + # Now determine if the process belongs to a group and categorize + for process_group in supervisord.process_groups.values(): + group_name = process_group.config.name + + if group_name in group_names and len(process_group.processes) > 1: + # This is a group + if group_name not in groups: + groups[group_name] = [] + + for process in process_group.processes.values(): + groups[group_name].append((group_name, process.config.name)) + else: + # Standalone process (not in a group) + for process in process_group.processes.values(): + ungrouped.append((group_name, process.config.name)) + + # Sort ungrouped processes + ungrouped.sort() root = self.clone() - if message is not None: statusarea = root.findmeld('statusmessage') statusarea.attrib['class'] = 'status_msg' statusarea.content(message) - if data: - iterator = root.findmeld('tr').repeat(data) - shaded_tr = False - - for tr_element, item in iterator: - status_text = tr_element.findmeld('status_text') - status_text.content(item['status'].lower()) - status_text.attrib['class'] = self.css_class_for_state( - item['state']) - - info_text = tr_element.findmeld('info_text') - info_text.content(item['description']) - - anchor = tr_element.findmeld('name_anchor') - processname = make_namespec(item['group'], item['name']) - anchor.attributes(href='tail.html?processname=%s' % - urllib.quote(processname)) - anchor.content(processname) - - actions = item['actions'] - actionitem_td = tr_element.findmeld('actionitem_td') - - for li_element, actionitem in actionitem_td.repeat(actions): - anchor = li_element.findmeld('actionitem_anchor') - if actionitem is None: - anchor.attrib['class'] = 'hidden' - else: - anchor.attributes(href=actionitem['href'], - name=actionitem['name']) - anchor.content(actionitem['name']) - if actionitem['target']: - anchor.attributes(target=actionitem['target']) - if shaded_tr: - tr_element.attrib['class'] = 'shade' - shaded_tr = not shaded_tr - else: + if not (sorted(groups.items()) or ungrouped): table = root.findmeld('statustable') - table.replace('No programs to manage') + table.replace('No processes') + else: + content_div = root.findmeld('content') + process_groups = root.findmeld('process_groups') + template_group = process_groups.findmeld('template_group') + + # Remove template group + template_group.deparent() + + # Handle ungrouped processes (if any) + if ungrouped: + group_div = template_group.clone() + group_title = group_div.findmeld('group_title') + group_title.content('Standalone Processes') + + # Hide group action buttons - add error handling + group_actions = group_div.findmeld('group_actions') + if group_actions is not None: # Ensure element exists + group_actions.attrib['style'] = 'display: none;' + + table = group_div.findmeld('statustable') + template_row = table.findmeld('tr') + + # Remove template row + template_row.deparent() + + for i, (groupname, processname) in enumerate(ungrouped): + self._render_row(table, template_row, i, groupname, processname, rpcinterface) + + process_groups.append(group_div) + + # Handle each group + for group_name, processes in sorted(groups.items()): + group_div = template_group.clone() + group_title = group_div.findmeld('group_title') + group_title.content('Group: ' + group_name) + + # Restore group action buttons and add error handling + group_stop = group_div.findmeld('group_stop_anchor') + if group_stop is not None: + group_stop.attributes(href='index.html?action=stopgroup&processname=' + group_name) + + group_restart = group_div.findmeld('group_restart_anchor') + if group_restart is not None: + group_restart.attributes(href='index.html?action=restartgroup&processname=' + group_name) + + table = group_div.findmeld('statustable') + template_row = table.findmeld('tr') + + # Remove template row + template_row.deparent() + + for i, (groupname, processname) in enumerate(processes): + self._render_row(table, template_row, i, groupname, processname, rpcinterface) + + process_groups.append(group_div) root.findmeld('supervisor_version').content(VERSION) copyright_year = str(datetime.date.today().year) @@ -562,6 +629,141 @@ def render(self): return as_string(root.write_xhtmlstring()) + def _render_row(self, table, template_row, i, groupname, processname, rpcinterface): + row = template_row.clone() + sent_name = make_namespec(groupname, processname) + info = rpcinterface.supervisor.getProcessInfo(sent_name) + actions = self.actions_for_process(info) + + status_text = row.findmeld('status_text') + info_text = row.findmeld('info_text') + name_anchor = row.findmeld('name_anchor') + + if i % 2: + row.attrib['class'] = 'shade' + else: + row.attrib['class'] = '' + + status_text.content(info['statename']) + status_text.attrib['class'] = self.css_class_for_state(info['state']) + info_text.content(info['description']) + name_anchor.attributes(href='tail.html?processname=%s' % urllib.quote(sent_name)) + name_anchor.content(sent_name) + + actionitem_td = row.findmeld('actionitem_td') + template_action = actionitem_td.findmeld('actionitem') + + # Remove template action items + template_action.deparent() + + for action in actions: + action_item = template_action.clone() + action_anchor = action_item.findmeld('actionitem_anchor') + action_anchor.attributes(href=action['href']) + if 'target' in action: + action_anchor.attributes(target=action['target']) + action_anchor.content(action['name']) + actionitem_td.append(action_item) + + table.append(row) + + def start_group(self, group_name): + """Start all processes in the group""" + supervisord = self.context.supervisord + rpcinterface = SupervisorNamespaceRPCInterface(supervisord) + + # First stop all processes, then start them again + try: + stop_callback = rpcinterface.stopProcessGroup(group_name) + except RPCError as e: + msg = 'Cannot start group %s: [%d] %s' % (group_name, e.code, e.text) + def startgrperr(): + return msg + startgrperr.delay = 0.05 + return startgrperr + + def start_group_cont(): + if stop_callback() is NOT_DONE_YET: + return NOT_DONE_YET + + # Stop completed, now start + try: + start_callback = rpcinterface.startProcessGroup(group_name) + except RPCError as e: + return 'Group %s stopped, but cannot restart: [%d] %s' % (group_name, e.code, e.text) + + def start_group_cont(): + if start_callback() is NOT_DONE_YET: + return NOT_DONE_YET + return 'All processes in group %s restarted' % group_name + + start_group_cont.delay = 0.05 + return start_group_cont() + + start_group_cont.delay = 0.05 + return start_group_cont + + def stop_group(self, group_name): + """Stop all processes in the group""" + supervisord = self.context.supervisord + rpcinterface = SupervisorNamespaceRPCInterface(supervisord) + + try: + callback = rpcinterface.stopProcessGroup(group_name) + except RPCError as e: + msg = 'Cannot stop group %s: [%d] %s' % (group_name, e.code, e.text) + def stopgrperr(): + return msg + stopgrperr.delay = 0.05 + return stopgrperr + + def stopgroup(): + if callback() is NOT_DONE_YET: + return NOT_DONE_YET + return 'All processes in group %s stopped' % group_name + stopgroup.delay = 0.05 + return stopgroup + + def restart_group(self, group_name): + """Restart all processes in the group""" + supervisord = self.context.supervisord + + # Create the correct RPC interface + main = ('supervisor', SupervisorNamespaceRPCInterface(supervisord)) + system = ('system', SystemNamespaceRPCInterface([main])) + rpcinterface = RootRPCInterface([main, system]) + + # Use multicall to execute stop and start operations in one call + try: + callback = rpcinterface.system.multicall([ + {'methodName': 'supervisor.stopProcessGroup', 'params': [group_name]}, + {'methodName': 'supervisor.startProcessGroup', 'params': [group_name]} + ]) + except RPCError as e: + msg = 'Cannot restart group %s: [%d] %s' % (group_name, e.code, e.text) + def restartgrperr(): + return msg + restartgrperr.delay = 0.05 + return restartgrperr + + def restart_result(): + result = callback() + if result is NOT_DONE_YET: + return NOT_DONE_YET + + # Check result + stop_result, start_result = result + if isinstance(stop_result, dict) and 'faultString' in stop_result: + return 'Group %s restart failed: %s' % (group_name, stop_result['faultString']) + + if isinstance(start_result, dict) and 'faultString' in start_result: + return 'Group %s stopped, but cannot restart: %s' % (group_name, start_result['faultString']) + + return 'All processes in group %s restarted' % group_name + + restart_result.delay = 0.05 + return restart_result + class OKView: delay = 0 def __init__(self, context): diff --git a/supervisor_cmd.sh b/supervisor_cmd.sh new file mode 100755 index 000000000..ebe944168 --- /dev/null +++ b/supervisor_cmd.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Supervisor 命令辅助脚本 + +# 如果没有提供参数,则显示帮助 +if [ $# -eq 0 ]; then + echo "Supervisor 命令辅助脚本" + echo "用法: $0 <命令>" + echo "" + echo "常用命令:" + echo " status - 查看所有进程状态" + echo " start - 启动进程" + echo " stop - 停止进程" + echo " restart - 重启进程" + echo " shutdown - 关闭 Supervisor" + echo " reread - 重新读取配置" + echo " update - 更新配置" + echo " help - 显示更多命令" + exit 1 +fi + +# 使用配置文件路径运行 supervisorctl +supervisorctl -c $(pwd)/supervisord.conf "$@" \ No newline at end of file