日志实时观看
This commit is contained in:
parent
aa2a7acaf1
commit
561c29fd25
@ -1,4 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -6,10 +6,9 @@ from typing import Any, Dict, Optional
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests import Response
|
from requests import Response
|
||||||
|
|
||||||
from app.extensions import db
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
from app.models import ApiCallLog, ApiConfig
|
from app.models import ApiCallLog, ApiConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -27,6 +26,7 @@ def _parse_json_field(raw: Optional[str]) -> Optional[Dict[str, Any]]:
|
|||||||
def execute_api(api_config: ApiConfig) -> None:
|
def execute_api(api_config: ApiConfig) -> None:
|
||||||
"""
|
"""
|
||||||
根据配置调用 API,带重试,并将每次尝试都记录为日志(保留历史)。
|
根据配置调用 API,带重试,并将每次尝试都记录为日志(保留历史)。
|
||||||
|
支持流式响应:边接收边写入 response_body,便于前端实时查看。
|
||||||
"""
|
"""
|
||||||
request_time = datetime.utcnow()
|
request_time = datetime.utcnow()
|
||||||
headers = _parse_json_field(api_config.headers) or {}
|
headers = _parse_json_field(api_config.headers) or {}
|
||||||
@ -46,9 +46,11 @@ def execute_api(api_config: ApiConfig) -> None:
|
|||||||
|
|
||||||
while attempt < total_attempts:
|
while attempt < total_attempts:
|
||||||
last_response: Optional[Response] = None
|
last_response: Optional[Response] = None
|
||||||
|
log_entry: Optional[ApiCallLog] = None
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
attempt += 1
|
attempt += 1
|
||||||
try:
|
try:
|
||||||
|
# stream=True 便于流式响应实时写日志;非流式也兼容
|
||||||
last_response = requests.request(
|
last_response = requests.request(
|
||||||
method=api_config.http_method,
|
method=api_config.http_method,
|
||||||
url=api_config.url,
|
url=api_config.url,
|
||||||
@ -57,29 +59,74 @@ def execute_api(api_config: ApiConfig) -> None:
|
|||||||
data=None if json_body is not None else data,
|
data=None if json_body is not None else data,
|
||||||
json=json_body,
|
json=json_body,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
stream=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 创建进行中的日志,前端可以立即看到“执行中”
|
||||||
|
log_entry = ApiCallLog(
|
||||||
|
api_id=api_config.id,
|
||||||
|
request_time=request_time,
|
||||||
|
success=None, # 进行中
|
||||||
|
http_status_code=last_response.status_code,
|
||||||
|
response_body="",
|
||||||
|
)
|
||||||
|
db.session.add(log_entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
max_body_length = 20000 # 避免日志过大
|
||||||
|
last_flush = time.time()
|
||||||
|
encoding = last_response.encoding or "utf-8"
|
||||||
|
for chunk in last_response.iter_content(chunk_size=1024):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text_part = chunk.decode(encoding, errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
text_part = ""
|
||||||
|
if not text_part:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_body = log_entry.response_body or ""
|
||||||
|
if len(current_body) < max_body_length:
|
||||||
|
log_entry.response_body = (current_body + text_part)[:max_body_length]
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
# 每秒刷新一次数据库,兼顾实时性与写入开销
|
||||||
|
if now - last_flush >= 1:
|
||||||
|
db.session.commit()
|
||||||
|
last_flush = now
|
||||||
|
|
||||||
|
# 流结束后,根据状态码判定是否成功
|
||||||
if 200 <= last_response.status_code < 300:
|
if 200 <= last_response.status_code < 300:
|
||||||
last_error = None
|
last_error = None
|
||||||
else:
|
else:
|
||||||
body_snippet = (last_response.text or "")[:200]
|
body_snippet = (log_entry.response_body or "")[:200]
|
||||||
last_error = f"Non-2xx status: {last_response.status_code}, body: {body_snippet}"
|
last_error = f"Non-2xx status: {last_response.status_code}, body: {body_snippet}"
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
last_error = str(exc)
|
last_error = str(exc)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = str(exc)
|
||||||
|
|
||||||
duration_ms = int((time.time() - start_ts) * 1000)
|
end_time = datetime.utcnow()
|
||||||
|
duration_ms = int((end_time - request_time).total_seconds() * 1000)
|
||||||
success = last_error is None
|
success = last_error is None
|
||||||
|
|
||||||
log_entry = ApiCallLog(
|
# 更新日志最终状态;如果之前没有创建(例如请求异常前失败),则创建一条
|
||||||
api_id=api_config.id,
|
if log_entry is None:
|
||||||
request_time=request_time,
|
log_entry = ApiCallLog(
|
||||||
response_time=datetime.utcnow(),
|
api_id=api_config.id,
|
||||||
success=success,
|
request_time=request_time,
|
||||||
http_status_code=last_response.status_code if last_response else None,
|
)
|
||||||
error_message=(f"[第{attempt}次尝试/{total_attempts}] {last_error}" if last_error else None),
|
db.session.add(log_entry)
|
||||||
response_body=(last_response.text[:2000] if last_response and last_response.text else None),
|
|
||||||
duration_ms=duration_ms,
|
log_entry.response_time = end_time
|
||||||
)
|
log_entry.duration_ms = duration_ms
|
||||||
db.session.add(log_entry)
|
log_entry.success = success
|
||||||
|
if last_response:
|
||||||
|
log_entry.http_status_code = last_response.status_code
|
||||||
|
if last_error:
|
||||||
|
log_entry.error_message = f"[第{attempt}次尝试/{total_attempts}] {last_error}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -1,19 +1,73 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}日志详情{% endblock %}
|
{% block title %}日志详情{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3 class="mb-3">日志详情</h3>
|
<h3 class="mb-3">日志详情</h3>
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">{{ log.api.name }}</h5>
|
<h5 class="card-title">{{ log.api.name }}</h5>
|
||||||
<p class="card-text mb-1"><strong>请求时间:</strong> {{ log.request_time|to_cst }}</p>
|
<p class="card-text mb-1"><strong>请求时间:</strong> <span id="log-req-time">{{ log.request_time|to_cst }}</span></p>
|
||||||
<p class="card-text mb-1"><strong>响应时间:</strong> {{ log.response_time|to_cst }}</p>
|
<p class="card-text mb-1"><strong>响应时间:</strong> <span id="log-resp-time">{{ log.response_time|to_cst or '-' }}</span></p>
|
||||||
<p class="card-text mb-1"><strong>是否成功:</strong> {{ log.success }}</p>
|
<p class="card-text mb-1"><strong>状态:</strong> <span id="log-status">
|
||||||
<p class="card-text mb-1"><strong>HTTP 状态码:</strong> {{ log.http_status_code or '-' }}</p>
|
{% if log.success is none %}
|
||||||
<p class="card-text mb-1"><strong>耗时 (ms):</strong> {{ log.duration_ms or '-' }}</p>
|
<span class="badge bg-secondary">进行中</span>
|
||||||
<p class="card-text mb-1"><strong>错误信息:</strong> {{ log.error_message or '-' }}</p>
|
{% elif log.success %}
|
||||||
|
<span class="badge bg-success">成功</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">失败</span>
|
||||||
|
{% endif %}
|
||||||
|
</span></p>
|
||||||
|
<p class="card-text mb-1"><strong>HTTP 状态码:</strong> <span id="log-http">{{ log.http_status_code if log.http_status_code is not none else '-' }}</span></p>
|
||||||
|
<p class="card-text mb-1"><strong>耗时 (ms):</strong> <span id="log-duration">{{ log.duration_ms if log.duration_ms is not none else '-' }}</span></p>
|
||||||
|
<p class="card-text mb-1"><strong>错误信息:</strong> <span id="log-error">{{ log.error_message or '-' }}</span></p>
|
||||||
<p class="card-text mb-1"><strong>响应内容:</strong></p>
|
<p class="card-text mb-1"><strong>响应内容:</strong></p>
|
||||||
<pre class="bg-light p-2" style="max-height: 300px; overflow:auto;">{{ log.response_body or '-' }}</pre>
|
<pre class="bg-light p-2" style="max-height: 300px; overflow:auto;" id="log-body">{{ log.response_body or '-' }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-secondary" href="{{ url_for('logs.list_logs') }}">返回</a>
|
<a class="btn btn-secondary" href="{{ url_for('logs.list_logs') }}">返回</a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const statusEl = document.getElementById('log-status');
|
||||||
|
const httpEl = document.getElementById('log-http');
|
||||||
|
const durationEl = document.getElementById('log-duration');
|
||||||
|
const errorEl = document.getElementById('log-error');
|
||||||
|
const bodyEl = document.getElementById('log-body');
|
||||||
|
const reqEl = document.getElementById('log-req-time');
|
||||||
|
const respEl = document.getElementById('log-resp-time');
|
||||||
|
|
||||||
|
const badge = (state) => {
|
||||||
|
if (state === null || state === undefined) return '<span class="badge bg-secondary">进行中</span>';
|
||||||
|
if (state === true) return '<span class="badge bg-success">成功</span>';
|
||||||
|
return '<span class="badge bg-danger">失败</span>';
|
||||||
|
};
|
||||||
|
const safe = (val, fallback='-') => (val === null || val === undefined || val === '') ? fallback : val;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`{{ url_for('logs.log_stream', log_id=log.id) }}`);
|
||||||
|
if (!res.ok) return schedule();
|
||||||
|
const data = await res.json();
|
||||||
|
statusEl.innerHTML = badge(data.success);
|
||||||
|
httpEl.textContent = safe(data.http_status_code, '-');
|
||||||
|
durationEl.textContent = safe(data.duration_ms, '-');
|
||||||
|
errorEl.textContent = safe(data.error_message, '-');
|
||||||
|
bodyEl.textContent = data.response_body || '-';
|
||||||
|
reqEl.textContent = safe(data.request_time, '-');
|
||||||
|
respEl.textContent = safe(data.response_time, '-');
|
||||||
|
if (!data.finished) {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function schedule() {
|
||||||
|
setTimeout(poll, 1500);
|
||||||
|
}
|
||||||
|
// 进行中时才轮询
|
||||||
|
{% if log.success is none %}
|
||||||
|
schedule();
|
||||||
|
{% endif %}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}调用日志{% endblock %}
|
{% block title %}调用日志{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3 class="mb-3">API 调用日志</h3>
|
<h3 class="mb-3">API 调用日志</h3>
|
||||||
@ -41,19 +41,21 @@
|
|||||||
<td>{{ log.api.name }}</td>
|
<td>{{ log.api.name }}</td>
|
||||||
<td>{{ log.request_time|to_cst }}</td>
|
<td>{{ log.request_time|to_cst }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if log.success %}
|
{% if log.success is none %}
|
||||||
|
<span class="badge bg-secondary">进行中</span>
|
||||||
|
{% elif log.success %}
|
||||||
<span class="badge bg-success">成功</span>
|
<span class="badge bg-success">成功</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger">失败</span>
|
<span class="badge bg-danger">失败</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ log.http_status_code or '-' }}</td>
|
<td>{{ log.http_status_code if log.http_status_code is not none else '-' }}</td>
|
||||||
<td>{{ log.duration_ms or '-' }}</td>
|
<td>{{ log.duration_ms if log.duration_ms is not none else '-' }}</td>
|
||||||
<td class="text-truncate" style="max-width: 200px;">{{ log.error_message or '' }}</td>
|
<td class="text-truncate" style="max-width: 200px;">{{ log.error_message or '' }}</td>
|
||||||
<td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('logs.log_detail', log_id=log.id) }}">详情</a></td>
|
<td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('logs.log_detail', log_id=log.id) }}">详情</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" class="text-center">暂无日志。</td></tr>
|
<tr><td colspan="7" class="text-center">暂无日志</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import Blueprint, render_template, request
|
from flask import Blueprint, render_template, request, jsonify, current_app
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
|
||||||
from app.extensions import db
|
|
||||||
from app.forms import LogFilterForm
|
from app.forms import LogFilterForm
|
||||||
from app.models import ApiCallLog, ApiConfig
|
from app.models import ApiCallLog, ApiConfig
|
||||||
|
|
||||||
@ -44,3 +43,32 @@ def list_logs():
|
|||||||
def log_detail(log_id: int):
|
def log_detail(log_id: int):
|
||||||
log_entry = ApiCallLog.query.get_or_404(log_id)
|
log_entry = ApiCallLog.query.get_or_404(log_id)
|
||||||
return render_template("logs/detail.html", log=log_entry)
|
return render_template("logs/detail.html", log=log_entry)
|
||||||
|
|
||||||
|
|
||||||
|
@logs_bp.route("/<int:log_id>/stream", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def log_stream(log_id: int):
|
||||||
|
log_entry = ApiCallLog.query.get_or_404(log_id)
|
||||||
|
to_cst = current_app.jinja_env.filters.get("to_cst")
|
||||||
|
|
||||||
|
def fmt(dt):
|
||||||
|
return to_cst(dt) if to_cst else (dt.isoformat() if dt else "")
|
||||||
|
|
||||||
|
duration_ms = log_entry.duration_ms
|
||||||
|
if duration_ms is None and log_entry.request_time:
|
||||||
|
duration_ms = int((datetime.utcnow() - log_entry.request_time).total_seconds() * 1000)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"id": log_entry.id,
|
||||||
|
"api_name": log_entry.api.name if log_entry.api else "",
|
||||||
|
"success": log_entry.success,
|
||||||
|
"http_status_code": log_entry.http_status_code,
|
||||||
|
"error_message": log_entry.error_message,
|
||||||
|
"response_body": log_entry.response_body or "",
|
||||||
|
"request_time": fmt(log_entry.request_time),
|
||||||
|
"response_time": fmt(log_entry.response_time) or "",
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"finished": log_entry.success is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user