业务中有时候权限控制的严格,日志目录是单独用1个
目录存放,通过ftp
形式查看;
此时想看日志就得先下载下来,而且想下载新日志时还得重新下载一遍,不是很方便;
所以在python
环境下,简单用flask
搭配socketio
,通过文件内容游标配合ftp
下载的增量内容在web
页面展示出来;
版本
python:3.10.11
Flask-SocketIO:5.5.1
import ftplib
import time
import logging
import threading
from flask import Flask, render_template
from flask_socketio import SocketIO
# ——— 常量区 —————————————————————————————————————————
# FTP服务器配置
FTP_HOST = 'xxx.xxx.xxx.xxx'
FTP_USER = 'xxx'
FTP_PASS = 'xxx'
BASE_DIR = '/' # 扫描此目录下各子目录
LOG_FILENAME = 'info.log' # 要查找的日志文件名
POLL_INTERVAL = 1 # 日志增量读取间隔(秒)
SCAN_INTERVAL = 10 # 目录列表刷新间隔(秒)
# ——————————————————————————————————————————————————————
# 日志配置
logging.basicConfig(
level = logging.DEBUG,
format = '[%(asctime)s] %(levelname)s %(message)s',
datefmt = '%H:%M:%S'
)
app = Flask(__name__)
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*")
# 定义各目录日志文件映射,key 为目录标识,将附加给前端以便区分日志来源
LOG_FILES = {
'cloud-assist': '/cloud-assist/info.log',
'cloud-product': '/cloud-product/info.log'
# "join-purchase": "/join-purchase/info.log"
}
# 动态获取目录下的所有log_files,但是顺序读取很慢,要逐个输出到前端
def get_log_files(ftp, base_dir="/"):
"""
在 FTP 服务器上遍历指定的基础目录,动态获取子目录中含有日志文件的映射。
例如:如果 /logs/dir1 中存在 log.txt,则返回字典中包含
{"dir1": "/logs/dir1/log.txt"}
"""
log_files = {}
try:
# 保存当前目录,便于后续恢复
current_dir = ftp.pwd()
ftp.cwd(base_dir)
# 列出基本目录下的所有条目
items = ftp.nlst()
for item in items:
try:
# 尝试切换到子目录,如果成功说明 item 是目录
ftp.cwd(item)
# 检查当前目录下是否有“log.txt”
files = ftp.nlst()
if "info.log" in files:
log_files[item] = f"{base_dir}/{item}/info.log"
# 返回到基本目录
ftp.cwd("..")
except Exception:
# 如果切换失败,则跳过该条目
continue
ftp.cwd(current_dir)
except Exception as e:
print("列出基础目录时出错:", e)
return log_files
def ftp_login():
"""实时从FTP服务器下载日志新增内容,并通过SocketIO实时推送到前端。"""
try:
ftp = ftplib.FTP(FTP_HOST, timeout=10)
ftp.login(FTP_USER, FTP_PASS)
logging.info("FTP 登录成功")
return ftp
except Exception as e:
logging.error("FTP 登录失败: %s", e)
return None
def ftp_log_stream():
"""
实时轮询 FTP 上动态获取的各日志文件,根据每个文件的偏移量读取新增部分,
并对不完整断行进行拼接,最后通过 SocketIO 向前端发送数据(带上所在目录标识)。
"""
# 对各日志文件保持偏移量与未完整行缓存
offsets = {}
leftovers = {}
ftp = ftp_login()
#log_files = get_log_files(ftp=ftp)
while True:
try:
if ftp is None:
ftp = ftp_login()
# 针对每个检测到的目录进行处理
for dir_key, remote_path in LOG_FILES.items():
# 初始化状态(若是新检测到的日志文件)
if dir_key not in offsets:
offsets[dir_key] = 0
leftovers[dir_key] = ''
try:
current_size = ftp.size(remote_path)
except Exception as e:
print(f"获取 {remote_path} 大小时出错:", e)
continue
if current_size is None:
continue
# 如果日志文件发生截断或轮换,则重置状态
if current_size < offsets[dir_key]:
offsets[dir_key] = 0
leftovers[dir_key] = ''
# 如果文件有新增内容,则仅断点续传读取新增部分
if current_size > offsets[dir_key]:
new_data = bytearray()
def handle_binary(block):
new_data.extend(block)
ftp.retrbinary("RETR " + remote_path, callback=handle_binary, rest=offsets[dir_key])
offsets[dir_key] = current_size
# 解码数据,并拼接上一次未完整的断行
text_chunk = new_data.decode('utf-8', errors='ignore')
text_chunk = leftovers[dir_key] + text_chunk
lines = text_chunk.splitlines(keepends=True)
# 如果最后一行不完整,则保存到 leftovers,下次拼接
if lines and not lines[-1].endswith('\n'):
leftovers[dir_key] = lines.pop()
else:
leftovers[dir_key] = ''
# 逐行发送日志更新消息
for line in lines:
logging.debug(f"[EMIT] dir={dir_key} ln={line!r}")
# 发送的数据中带有所属目录标识,用于前端分区展示
socketio.emit('log_update', {'dir': dir_key, 'log': line})
time.sleep(POLL_INTERVAL)
except Exception as e:
print("读取FTP日志时出现错误:", e)
try:
if ftp:
ftp.close()
except Exception:
pass
ftp = None
time.sleep(POLL_INTERVAL)
@app.route('/')
def index():
return render_template('log_menu.html')
if __name__ == '__main__':
threading.Thread(target=ftp_log_stream, daemon=True).start()
#socketio.start_background_task(ftp_log_stream)
socketio.run(app, host='127.0.0.1', port=5000, debug=True, allow_unsafe_werkzeug=True)
DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>多目录 FTP 日志实时展示title>
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js">script>
<style>
body {
font-family: sans-serif;
margin: 20px;
}
.tabs {
display: flex;
margin-bottom: 10px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border: 1px solid #ccc;
background: #f0f0f0;
margin-right: 5px;
transition: background-color 0.2s;
}
.tab:hover {
background: #e0e0e0;
}
.tab.active {
background: #fff;
border-bottom: 2px solid #fff;
font-weight: bold;
}
.log-container {
border: 1px solid #ccc;
height: 400px;
overflow-y: auto;
padding: 10px;
background: #f9f9f9;
font-family: monospace;
display: none; /* 默认隐藏,只有当前激活的container显示 */
white-space: pre-wrap;
}
.log-container.active {
display: block;
}
style>
head>
<body>
<h1>FTP 日志实时展示h1>
<div class="tabs">
<div class="tab active" data-dir="cloud-assist">cloud-assistdiv>
<div class="tab" data-dir="cloud-product">cloud-productdiv>
<div class="tab" data-dir="join-purchase">join-purchasediv>
div>
<div id="logs">
<div class="log-container active" id="log-cloud-assist">div>
<div class="log-container" id="log-cloud-product">div>
<div class="log-container" id="log-join-purchase">div>
div>
<script>
// 指定后端 WebSocket 地址及端口
const socket = io("http://127.0.0.1:5000");
// 监听 log_update 事件,将日志追加到对应的日志区域
socket.on('log_update', data => {
console.log("收到日志数据:", data);
// data 应包含 data.dir(目录标识)和 data.log(日志内容)
const dir = data.dir;
const logLine = data.log;
const container = document.getElementById('log-' + dir);
if(container) {
container.textContent += logLine;
// 如果当前显示的是此日志区域,则自动滚动到底部
if (container.classList.contains('active')) {
container.scrollTop = container.scrollHeight;
}
}
});
socket.on("connect", () => {
console.log("连接成功!");
});
socket.on("disconnect", () => {
console.log("断开连接!");
});
socket.on('disconnect', () => console.log('⚠️ SOCKET DISCONNECTED'));
socket.on('connect_error', err => console.error(' CONNECT ERROR', err));
// 处理选项卡切换
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// 移除所有 tab 和 log-container 的 active 类
tabs.forEach(t => t.classList.remove('active'));
document.querySelectorAll('.log-container').forEach(c => c.classList.remove('active'));
// 激活当前 tab 以及对应的日志显示区域
tab.classList.add('active');
const dir = tab.getAttribute('data-dir');
const container = document.getElementById('log-' + dir);
if (container) {
container.classList.add('active');
// 若已滚动到底部,则新日志到来时自动滚动
container.scrollTop = container.scrollHeight;
}
});
});
script>
body>
html>