本文最后更新于:2025年4月9日 下午

日志信息复杂时需要统一查看,鉴于查看文件不方便,我实现了 Python 的简易 Web 端 Log 查看服务,本文记录实现思路。

简介

需求:

  • 对于一个统一的文本 log 日志文件夹,需要方便查看
  • 在局域网内可以随意查看
  • 分页加载
  • 界面简介快速

实现思路

  • 后端使用 Flask 构建,输入参数为日志文件夹路径
  • 构建简易 html 模板用于展示文件夹与日志文件

文件结构

1
2
3
4
5
6
7
8
9
10
.
├── app.py
├── config.py
├── static
│ └── style.css
└── templates
├── 404.html
├── base.html
├── browse.html
└── view.html

templates

base.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}日志管理系统{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fa-solid fa-file-lines logo-icon"></i> 日志管理系统</h1>
<div class="breadcrumb">{% block breadcrumb %}{% endblock %}</div>
</div>
{% block content %}{% endblock %}
</div>
<div class="loading-overlay" id="loader" style="display: none;">
<div class="spinner">
<i class="fa-solid fa-spinner fa-2x"></i>
</div>
</div>
</body>
</html>

browse.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<!-- templates/browse.html -->
{% extends "base.html" %}

{% block title %}浏览 - {{ current_path }}{% endblock %}

{% block breadcrumb %}
<a href="/">根目录</a>
{% if current_path %}
{% for part in current_path.split('/') %}
/ <a href="{{ url_for('browse', subpath='/'.join(current_path.split('/')[:loop.index])) }}">{{ part }}</a>
{% endfor %}
{% endif %}
{% endblock %}

{% block content %}
<table class="file-table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>大小</th>
<!-- <th>创建时间</th> -->
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries|sort(attribute='is_dir', reverse=True) %}
<tr>
<td>
<a href="
{% if entry.is_dir %}
{{ url_for('browse', subpath=entry.path) }}
{% else %}
{{ url_for('view_file', filepath=entry.path) }}
{% endif %}"
class="file-link">
{% if entry.is_dir %}
<i class="fa-solid fa-folder"></i>
{% else %}
<i class="fa-solid fa-file-lines"></i>
{% endif %}
<span class="filename">{{ entry.name }}</span>
</a>
</td>
<td>{{ "目录" if entry.is_dir else "文件" }}</td>
<td>{{ entry.size|filesizeformat if not entry.is_dir else '-' }}</td>
<!-- <td>{{ entry.created_time }}</td> -->
<td>{{ entry.modify_time }}</td>
<td>
{% if entry.is_dir %}
<a href="{{ url_for('browse', subpath=entry.path) }}" class="btn">
<i class="fa-solid fa-folder-open"></i> 浏览
</a>
{% else %}
<a href="{{ url_for('view_file', filepath=entry.path) }}" class="btn">
<i class="fa-solid fa-magnifying-glass"></i> 查看
</a>
{% endif %}
</td>
</tr>

{% endfor %}
</tbody>
</table>
{% endblock %}

view.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!-- templates/view.html -->
{% extends "base.html" %}

{% block title %}查看日志 - {{ filename }}{% endblock %}

{% block breadcrumb %}
<a href="/">根目录</a> /
{% for part in filepath.split('/')[:-1] %}
<a href="{{ url_for('browse', subpath='/'.join(filepath.split('/')[:loop.index])) }}">{{ part }}</a> /
{% endfor %}
{{ filename }}
{% endblock %}

{% block content %}
<div class="log-toolbar">
<form class="search-form">
<div class="form-group">
<input type="text" name="search" placeholder="输入搜索关键词..."
value="{{ request.args.get('search', '') }}">
<button type="submit" class="btn">
<i class="fa-solid fa-magnifying-glass"></i> 搜索
</button>
</div>
</form>
<div class="pagination">
<a href="{{ url_for('view_file', filepath=filepath, page=1) }}" class="btn">
<i class="fa-solid fa-angles-left"></i>
</a>
{% if page > 1 %}
<a href="{{ url_for('view_file', filepath=filepath, page=page-1) }}"
class="btn">
<i class="fa-solid fa-chevron-left"></i>
</a>
{% else %}
<a href="{{ url_for('view_file', filepath=filepath, page=1) }}"
class="btn">
<i class="fa-solid fa-chevron-left"></i>
</a>
{% endif %}
<span>第 {{ page }} 页/共 {{ total_pages }} 页</span>
{% if page < total_pages %}
<a href="{{ url_for('view_file', filepath=filepath, page=page+1) }}"
class="btn">
<i class="fa-solid fa-chevron-right"></i>
</a>
{% else %}
<a href="{{ url_for('view_file', filepath=filepath, page=total_pages) }}"
class="btn">
<i class="fa-solid fa-chevron-right"></i>
</a>
{% endif %}
<a href="{{ url_for('view_file', filepath=filepath, page=total_pages) }}" class="btn">
<i class="fa-solid fa-angles-right"></i>
</a>
</div>
</div>

<div class="log-container">
<pre class="log-content">{{ content }}</pre>
</div>
{% endblock %}

404.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- templates/404.html -->
{% extends "base.html" %}

{% block title %}页面未找到{% endblock %}

{% block content %}
<div class="error-page">
<h2><i class="fa-solid fa-triangle-exclamation"></i> 404 错误</h2>
<p>请求的资源不存在</p>
<a href="/" class="btn">
<i class="fa-solid fa-house"></i> 返回首页
</a>
</div>
{% endblock %}

static

style.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
/* static/style.css */
:root {
--primary-color: #2c3e50;
--secondary-color: #3498db;
--hover-color: #2980b9;
--bg-color: #f8f9fa;
--text-color: #333;
}

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
font-family: 'Segoe UI', system-ui, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--bg-color);
}

.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 15px;
}

/* Header 样式 */
.header {
padding: 2rem 0;
margin-bottom: 1.5rem;
border-bottom: 2px solid var(--primary-color);
}

.logo-icon {
color: var(--secondary-color);
margin-right: 0.5rem;
}

.breadcrumb {
font-size: 0.9em;
color: #666;
margin-top: 0.5rem;
}

.breadcrumb a {
color: var(--secondary-color);
text-decoration: none;
}

.breadcrumb a:hover {
text-decoration: underline;
}

/* 表格样式 */
.file-table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}

.file-table th {
background-color: var(--primary-color);
color: white;
padding: 12px;
text-align: left;
}

.file-table td {
padding: 12px;
border-bottom: 1px solid #eee;
}

.file-table tr:hover {
background-color: #f5f5f5;
}

/* 按钮样式 */
.btn {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background-color: var(--secondary-color);
color: white;
border-radius: 4px;
text-decoration: none;
transition: background-color 0.3s;
}

.btn:hover {
background-color: var(--hover-color);
}

.btn i {
margin-right: 5px;
}

/* 日志查看样式 */
.log-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 1rem;
overflow-x: auto;
}

.log-content {
white-space: pre-wrap;
font-family: 'Courier New', monospace;
line-height: 1.8;
}

/* 分页样式 */
.pagination {
display: flex;
gap: 10px;
align-items: center;
margin-top: 1rem;
}

/* 错误页面 */
.error-page {
text-align: center;
padding: 4rem 0;
}

.error-page h2 {
color: #e74c3c;
margin-bottom: 1rem;
}

/* 移动端优化 */
@media (max-width: 768px) {
.file-table td {
padding: 8px;
}

.file-table td:first-child {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}

.btn {
padding: 4px 8px;
font-size: 0.9em;
}
}

/* 加载动画 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}

.fa-spinner {
animation: rotate 1.5s linear infinite;
}

@keyframes rotate {
100% { transform: rotate(360deg); }
}

/* 分页控件 */
.pagination {
display: flex;
gap: 8px;
align-items: center;
margin: 1.5rem 0;
flex-wrap: wrap;
}

.pagination span {
padding: 6px 12px;
background: #f0f0f0;
border-radius: 4px;
}

/* 双箭头按钮特效 */
.btn:hover .fa-angles-right {
transform: translateX(2px);
transition: transform 0.2s ease;
}

.btn:hover .fa-angles-left {
transform: translateX(-2px);
transition: transform 0.2s ease;
}

/* 移动端适配 */
@media (max-width: 480px) {
.pagination .btn {
padding: 5px 8px;
}

.pagination .btn i {
margin: 0;
}

.pagination span {
order: -1;
width: 100%;
text-align: center;
}
}

/* 文件名链接样式 */
.file-link {
display: block;
color: inherit;
text-decoration: none;
padding: 8px 0;
transition: all 0.2s ease;
}

.file-link:hover {
color: #3498db;
transform: translateX(3px);
}

.file-link .fa-folder {
color: var(--secondary-color); /* 文件夹图标颜色 */
margin-right: 8px;
}

.file-link .fa-file-lines {
color: var(--secondary-color); /* 文件图标颜色 */
margin-right: 8px;
}

/* 移动端优化 */
@media (max-width: 768px) {
.file-link {
padding: 6px 0;
}

.btn {
margin-top: 4px;
display: block;
text-align: center;
}
}

Flask

config.py

1
2
3
4
5
6
7
8
9
10
11
import os

# 日志根目录(按需修改)
LOG_ROOT = os.path.abspath("./logs")
# 允许查看的文件扩展名
ALLOWED_EXTENSIONS = {'log', 'txt'}
# 单页最大显示行数
MAX_LINES_PER_PAGE = 10 # 优化为100行/页
# 缓存时间(秒)
CACHE_TIMEOUT = 300

app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
from flask import Flask, render_template, abort, request
import os
from config import LOG_ROOT, ALLOWED_EXTENSIONS, MAX_LINES_PER_PAGE
import vvdutils as vv
import pathlib


app = Flask(__name__)
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 10 # 静态文件缓存10s

def is_safe_path(path):
"""增强路径安全检查"""
target = os.path.abspath(os.path.join(LOG_ROOT, path))
return os.path.commonpath([LOG_ROOT, target]) == LOG_ROOT

@app.route('/')
def index():
return browse('')

@app.route('/browse/<path:subpath>')
def browse(subpath):
if not is_safe_path(subpath):
abort(403)

abs_path = os.path.join(LOG_ROOT, subpath)
if not os.path.exists(abs_path):
abort(404)

entries = []
try:
for entry in sorted(os.listdir(abs_path), key=lambda x: (not os.path.isdir(os.path.join(abs_path, x)), x.lower())):
entry_path = os.path.join(subpath, entry)
full_path = os.path.join(abs_path, entry)
# 最后修改时间(所有系统通用)

if os.path.isdir(full_path):
time_info = vv.get_file_time(full_path, datetime_res=True)
entries.append({
'name': entry,
'path': entry_path,
'is_dir': True,
'size': '-',
'modify_time': vv.time_string(datetime_obj=time_info['modify_time']),
'created_time': vv.time_string(datetime_obj=time_info['create_time'])
})
else:
ext = pathlib.Path(entry).suffix[1:].lower()
if ext not in ALLOWED_EXTENSIONS:
continue
time_info = vv.get_file_time(full_path, datetime_res=True)
entries.append({
'name': entry,
'path': entry_path,
'is_dir': False,
'size': os.path.getsize(full_path),
'modify_time': vv.time_string(datetime_obj=time_info['modify_time']),
'created_time': vv.time_string(datetime_obj=time_info['create_time'])
})
except PermissionError:
abort(403)

parent = os.path.dirname(subpath)
return render_template('browse.html',
entries=entries,
current_path=subpath,
parent=parent)

# @lru_cache(maxsize=100) # 由于时刻在变化,不能缓存
def get_cached_line_count(filepath):
return vv.get_file_line_number(filepath)

@app.route('/view/<path:filepath>')
def view_file(filepath):
if not is_safe_path(filepath):
abort(403)

page = request.args.get('page', 1, type=int)
search_term = request.args.get('search', '').lower()
abs_path = os.path.join(LOG_ROOT, filepath)

if not os.path.isfile(abs_path):
abort(404)

try:
total_lines = get_cached_line_count(abs_path)
except Exception as e:
app.logger.error(f"读取文件行数失败: {str(e)}")
abort(500)

total_pages = max(1, (total_lines + MAX_LINES_PER_PAGE - 1) // MAX_LINES_PER_PAGE)
page = max(1, min(page, total_pages))

try:
with open(abs_path, 'r', encoding='utf-8', errors='ignore') as f:
start_line = (page - 1) * MAX_LINES_PER_PAGE
content = []
for _ in range(start_line):
if not f.readline():
break
for _ in range(MAX_LINES_PER_PAGE):
line = f.readline()
if not line:
break
if search_term in line.lower():
content.append(line)
except IOError as e:
app.logger.error(f"文件读取失败: {str(e)}")
abort(500)

return render_template('view.html',
filename=os.path.basename(filepath),
content=''.join(content),
filepath=filepath,
page=page,
total_pages=total_pages)

@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404

@app.errorhandler(403)
def forbidden(e):
return render_template('403.html'), 403

if __name__ == '__main__':
app.run(debug=True)

运行展示

文件夹页面

日志查看页面

仓库地址



文章链接:
https://www.zywvvd.com/notes/coding/python/python-log-viewer/python-log-viewer/


“觉得不错的话,给点打赏吧 ୧(๑•̀⌄•́๑)૭”

微信二维码

微信支付

支付宝二维码

支付宝支付

Python 实现 Web 日志查看服务
https://www.zywvvd.com/notes/coding/python/python-log-viewer/python-log-viewer/
作者
Yiwei Zhang
发布于
2025年4月9日
许可协议