06/04 Improve system logs UI/UX
This commit is contained in:
+19
-2
@@ -49,13 +49,26 @@ def _parse_line(raw):
|
||||
@logs_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
import datetime
|
||||
log_file = current_app.config.get('LOG_FILE_PATH', '')
|
||||
file_exists = bool(log_file) and os.path.isfile(log_file)
|
||||
file_size = os.path.getsize(log_file) if file_exists else 0
|
||||
file_mtime = None
|
||||
if file_exists:
|
||||
ts = os.path.getmtime(log_file)
|
||||
file_mtime = datetime.datetime.fromtimestamp(ts).strftime('%b %d, %H:%M')
|
||||
|
||||
def fmt_size(b):
|
||||
if b < 1024: return f'{b} B'
|
||||
if b < 1024**2: return f'{b/1024:.1f} KB'
|
||||
return f'{b/1024**2:.1f} MB'
|
||||
|
||||
return render_template('logs/index.html',
|
||||
log_file=log_file,
|
||||
file_exists=file_exists,
|
||||
file_size=file_size)
|
||||
file_size=file_size,
|
||||
file_size_fmt=fmt_size(file_size),
|
||||
file_mtime=file_mtime)
|
||||
|
||||
|
||||
@logs_bp.route('/api')
|
||||
@@ -87,17 +100,21 @@ def api():
|
||||
# Most-recent first, capped at limit
|
||||
entries = list(reversed(entries))[:limit]
|
||||
|
||||
# Count per level across ALL unfiltered lines (for the stats bar)
|
||||
# Count per level + unique modules across ALL unfiltered lines
|
||||
all_entries = [_parse_line(l) for l in raw_lines if l.strip()]
|
||||
counts = {}
|
||||
modules_seen = set()
|
||||
for e in all_entries:
|
||||
counts[e['level']] = counts.get(e['level'], 0) + 1
|
||||
if e['name']:
|
||||
modules_seen.add(e['name'])
|
||||
|
||||
return jsonify({
|
||||
'entries': entries,
|
||||
'counts': counts,
|
||||
'total_raw': len(raw_lines),
|
||||
'log_file': log_file,
|
||||
'modules': sorted(modules_seen),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -303,15 +303,9 @@
|
||||
>
|
||||
<i class="bi bi-tags"></i><span class="lt">Categories</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('logs.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'logs' %}active{% endif %}"
|
||||
>
|
||||
<i class="bi bi-terminal"></i><span class="lt">System Logs</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('settings.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'settings' %}active{% endif %}"
|
||||
class="sb-link {% if request.blueprint in ('settings', 'logs') %}active{% endif %}"
|
||||
>
|
||||
<i class="bi bi-gear"></i><span class="lt">Settings</span>
|
||||
</a>
|
||||
|
||||
+433
-203
@@ -4,269 +4,499 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.log-toolbar {
|
||||
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||
.log-header {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.log-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||
}
|
||||
.log-meta-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; color: var(--muted); font-family: 'DM Mono', monospace;
|
||||
background: #f1f5f9; border-radius: 4px; padding: 2px 8px;
|
||||
}
|
||||
.log-meta-chip i { font-size: 10px; }
|
||||
|
||||
/* ── Level pills / filter bar ────────────────────────────────────────────────── */
|
||||
.level-pills {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px;
|
||||
}
|
||||
.lvl-pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; border: 1.5px solid transparent; transition: all .15s;
|
||||
user-select: none;
|
||||
}
|
||||
.lvl-pill .cnt { font-size: 11px; opacity: .8; }
|
||||
.lvl-pill:not(.active) { opacity: .6; }
|
||||
.lvl-pill:hover { opacity: 1; }
|
||||
.lvl-pill.active { border-color: currentColor; opacity: 1; }
|
||||
.lp-ALL { background:#f1f5f9; color:#475569; }
|
||||
.lp-ERROR { background:#fee2e2; color:#991b1b; }
|
||||
.lp-CRITICAL { background:#fce7f3; color:#9d174d; }
|
||||
.lp-WARNING { background:#fef9c3; color:#854d0e; }
|
||||
.lp-INFO { background:#dbeafe; color:#1e40af; }
|
||||
.lp-DEBUG { background:#f0fdf4; color:#166534; }
|
||||
|
||||
/* ── Toolbar ────────────────────────────────────────────────────────────────── */
|
||||
.log-toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
|
||||
}
|
||||
.log-toolbar .sep { flex: 1; }
|
||||
border-radius: 10px; padding: 10px 14px; margin-bottom: 14px;
|
||||
}
|
||||
.toolbar-sep { flex: 1; min-width: 8px; }
|
||||
|
||||
.level-badge {
|
||||
display: inline-block; font-size: 11px; font-weight: 600;
|
||||
padding: 2px 7px; border-radius: 4px; font-family: 'DM Mono', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.level-INFO { background: #dbeafe; color: #1e40af; }
|
||||
.level-WARNING { background: #fef9c3; color: #854d0e; }
|
||||
.level-ERROR { background: #fee2e2; color: #991b1b; }
|
||||
.level-CRITICAL { background: #fce7f3; color: #9d174d; }
|
||||
.level-DEBUG { background: #f0fdf4; color: #166534; }
|
||||
.level-RAW { background: #f1f5f9; color: #475569; }
|
||||
/* ── Auto-refresh indicator ──────────────────────────────────────────────────── */
|
||||
.ar-wrap {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: var(--muted); cursor: pointer;
|
||||
padding: 4px 8px; border-radius: 6px; transition: background .15s;
|
||||
user-select: none;
|
||||
}
|
||||
.ar-wrap:hover { background: #f1f5f9; }
|
||||
.ar-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: #cbd5e1;
|
||||
flex-shrink: 0; transition: background .3s;
|
||||
}
|
||||
.ar-dot.on { background: #10b981; animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
.ar-countdown { font-family:'DM Mono',monospace; font-size:11px; min-width:16px; }
|
||||
|
||||
.stat-pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px; border-radius: 20px; font-size: 12px;
|
||||
font-weight: 600; cursor: pointer; border: 1.5px solid transparent;
|
||||
transition: all .15s;
|
||||
}
|
||||
.stat-pill:hover, .stat-pill.active { border-color: currentColor; opacity: 1; }
|
||||
.stat-pill { opacity: .75; }
|
||||
.pill-ALL { background: #f1f5f9; color: #475569; }
|
||||
.pill-INFO { background: #dbeafe; color: #1e40af; }
|
||||
.pill-WARNING { background: #fef9c3; color: #854d0e; }
|
||||
.pill-ERROR { background: #fee2e2; color: #991b1b; }
|
||||
.pill-CRITICAL { background: #fce7f3; color: #9d174d; }
|
||||
.pill-DEBUG { background: #f0fdf4; color: #166534; }
|
||||
|
||||
#log-table-wrap {
|
||||
/* ── Log table ───────────────────────────────────────────────────────────────── */
|
||||
#log-wrap {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
#log-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
#log-table thead th {
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: .07em; color: var(--muted); padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||
background: #f8fafc;
|
||||
}
|
||||
#log-table tbody td {
|
||||
padding: 7px 14px; border-bottom: 1px solid var(--border);
|
||||
vertical-align: top; word-break: break-word;
|
||||
}
|
||||
#log-table tbody tr:last-child td { border-bottom: none; }
|
||||
#log-table tbody tr:hover { background: #f8fafc; }
|
||||
#log-table tbody tr.row-ERROR td { background: #fff5f5; }
|
||||
#log-table tbody tr.row-CRITICAL td { background: #fdf2f8; }
|
||||
#log-table tbody tr.row-WARNING td { background: #fffbeb; }
|
||||
}
|
||||
#log-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
#log-table thead th {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .07em; color: var(--muted); padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--border); background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#log-table tbody tr { cursor: pointer; transition: background .1s; }
|
||||
#log-table tbody tr:hover > td { background: #f1f5f9 !important; }
|
||||
#log-table tbody td {
|
||||
padding: 6px 14px; border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: top;
|
||||
}
|
||||
#log-table tbody tr:last-child > td { border-bottom: none; }
|
||||
tr.row-ERROR > td { background: #fff8f8; }
|
||||
tr.row-CRITICAL > td { background: #fdf4fb; }
|
||||
tr.row-WARNING > td { background: #fffdf0; }
|
||||
tr.row-RAW > td { background: #fafafa; }
|
||||
|
||||
.ts-col { white-space: nowrap; color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 148px; }
|
||||
.name-col { color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 220px; }
|
||||
.msg-col { font-family: 'DM Mono', monospace; }
|
||||
/* ── Column widths ───────────────────────────────────────────────────────────── */
|
||||
.col-ts { width: 145px; white-space: nowrap; }
|
||||
.col-lvl { width: 86px; }
|
||||
.col-mod { width: 160px; }
|
||||
.col-msg { }
|
||||
|
||||
#empty-state {
|
||||
text-align: center; padding: 60px 20px; color: var(--muted);
|
||||
}
|
||||
.ts-text { font-family:'DM Mono',monospace; font-size:11px; color:var(--muted); }
|
||||
.mod-text { font-family:'DM Mono',monospace; font-size:11px; color:#64748b;
|
||||
max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.msg-text { font-family:'DM Mono',monospace; font-size:12px; word-break:break-word; }
|
||||
.msg-text.nowrap { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:580px; }
|
||||
|
||||
.auto-refresh-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #94a3b8; display: inline-block;
|
||||
transition: background .3s;
|
||||
}
|
||||
.auto-refresh-dot.on { background: #10b981; animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; } 50% { opacity: .4; }
|
||||
}
|
||||
/* ── Level badge ─────────────────────────────────────────────────────────────── */
|
||||
.lvl-badge {
|
||||
display: inline-block; font-size: 10px; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 4px; font-family:'DM Mono',monospace;
|
||||
letter-spacing: .03em; white-space: nowrap;
|
||||
}
|
||||
.lb-INFO { background:#dbeafe; color:#1e40af; }
|
||||
.lb-WARNING { background:#fef9c3; color:#854d0e; }
|
||||
.lb-ERROR { background:#fee2e2; color:#991b1b; }
|
||||
.lb-CRITICAL { background:#fce7f3; color:#9d174d; }
|
||||
.lb-DEBUG { background:#f0fdf4; color:#166534; }
|
||||
.lb-RAW { background:#f1f5f9; color:#64748b; }
|
||||
|
||||
#spinner { display: none; }
|
||||
#spinner.on { display: inline-block; }
|
||||
/* ── Expanded row ────────────────────────────────────────────────────────────── */
|
||||
tr.expanded-row > td {
|
||||
background: #0f172a !important; padding: 0;
|
||||
}
|
||||
.expand-panel {
|
||||
padding: 12px 16px; font-family:'DM Mono',monospace; font-size: 12px;
|
||||
color: #e2e8f0; white-space: pre-wrap; word-break: break-all;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.expand-panel .ep-label {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: #64748b; display: block; margin-bottom: 4px;
|
||||
}
|
||||
.expand-panel .ep-val { color: #e2e8f0; }
|
||||
.expand-panel .ep-mod { color: #7dd3fc; }
|
||||
.expand-panel .ep-ts { color: #a3e635; }
|
||||
.expand-panel .ep-err { color: #fca5a5; }
|
||||
.copy-btn {
|
||||
font-size:11px; padding:2px 8px; border-radius:4px;
|
||||
background:#1e293b; color:#94a3b8; border:1px solid #334155;
|
||||
cursor:pointer; transition:all .15s; margin-top:8px;
|
||||
}
|
||||
.copy-btn:hover { background:#334155; color:#e2e8f0; }
|
||||
|
||||
/* ── Empty / loading states ──────────────────────────────────────────────────── */
|
||||
.log-empty { text-align:center; padding:56px 20px; color:var(--muted); }
|
||||
.log-empty i { font-size:2.5rem; opacity:.3; display:block; margin-bottom:10px; }
|
||||
|
||||
/* ── Spinner ─────────────────────────────────────────────────────────────────── */
|
||||
#spinner { display:none; }
|
||||
#spinner.on { display:inline-block; }
|
||||
|
||||
/* ── Wrap toggle ─────────────────────────────────────────────────────────────── */
|
||||
.wrap-btn.active { background:#e0f2fe; color:#0369a1; border-color:#7dd3fc; }
|
||||
|
||||
/* ── Footer bar ──────────────────────────────────────────────────────────────── */
|
||||
.log-footer {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
font-size:11px; color:var(--muted); padding:6px 2px; margin-top:8px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('logs.download') }}" class="btn btn-sm btn-outline-secondary" title="Download log file">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h5 class="mb-0 fw-semibold">Application Logs</h5>
|
||||
<span id="spinner" class="spinner-border spinner-border-sm text-secondary ms-1"></span>
|
||||
<span class="small text-muted ms-auto mono" id="log-file-path">
|
||||
{% if file_exists %}{{ log_file }}{% else %}Log file not found{% endif %}
|
||||
</span>
|
||||
<!-- Header -->
|
||||
<div class="log-header">
|
||||
<div>
|
||||
<a href="{{ url_for('settings.index') }}" class="btn btn-sm btn-outline-secondary me-2" style="font-size:12px;">
|
||||
<i class="bi bi-arrow-left me-1"></i>Settings
|
||||
</a>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
{% if file_exists %}
|
||||
<span class="log-meta-chip"><i class="bi bi-hdd"></i>{{ file_size_fmt }}</span>
|
||||
<span class="log-meta-chip"><i class="bi bi-clock"></i>{{ file_mtime }}</span>
|
||||
<span class="log-meta-chip" style="max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ log_file }}">
|
||||
<i class="bi bi-file-text"></i>{{ log_file }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="log-meta-chip" style="color:#ef4444;"><i class="bi bi-exclamation-triangle"></i>Log file not found</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<span id="spinner" class="spinner-border spinner-border-sm text-secondary"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-3" id="stats-bar">
|
||||
<span class="stat-pill pill-ALL active" data-level="ALL">All <span class="ms-1" id="cnt-ALL">—</span></span>
|
||||
<span class="stat-pill pill-ERROR" data-level="ERROR">Error <span class="ms-1" id="cnt-ERROR">0</span></span>
|
||||
<span class="stat-pill pill-WARNING" data-level="WARNING">Warning <span class="ms-1" id="cnt-WARNING">0</span></span>
|
||||
<span class="stat-pill pill-INFO" data-level="INFO">Info <span class="ms-1" id="cnt-INFO">0</span></span>
|
||||
<span class="stat-pill pill-DEBUG" data-level="DEBUG">Debug <span class="ms-1" id="cnt-DEBUG">0</span></span>
|
||||
<!-- Level filter pills -->
|
||||
<div class="level-pills" id="level-pills">
|
||||
<span class="lvl-pill lp-ALL active" data-level="ALL">All <span class="cnt" id="cnt-ALL">—</span></span>
|
||||
<span class="lvl-pill lp-ERROR" data-level="ERROR">Error <span class="cnt" id="cnt-ERROR">0</span></span>
|
||||
<span class="lvl-pill lp-CRITICAL" data-level="CRITICAL">Critical <span class="cnt" id="cnt-CRITICAL">0</span></span>
|
||||
<span class="lvl-pill lp-WARNING" data-level="WARNING">Warning <span class="cnt" id="cnt-WARNING">0</span></span>
|
||||
<span class="lvl-pill lp-INFO" data-level="INFO">Info <span class="cnt" id="cnt-INFO">0</span></span>
|
||||
<span class="lvl-pill lp-DEBUG" data-level="DEBUG">Debug <span class="cnt" id="cnt-DEBUG">0</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="log-toolbar">
|
||||
<input id="search-input" type="text" class="form-control form-control-sm"
|
||||
placeholder="Search message or module…" style="max-width:240px;">
|
||||
<input id="module-input" type="text" class="form-control form-control-sm"
|
||||
placeholder="Module filter (e.g. teller)" style="max-width:180px;">
|
||||
<select id="limit-select" class="form-select form-select-sm" style="max-width:110px;">
|
||||
<option value="100">Last 100</option>
|
||||
<option value="200" selected>Last 200</option>
|
||||
<option value="500">Last 500</option>
|
||||
<option value="1000">Last 1000</option>
|
||||
</select>
|
||||
<div class="sep"></div>
|
||||
<label class="d-flex align-items-center gap-2 small text-muted mb-0" style="cursor:pointer;">
|
||||
<span class="auto-refresh-dot" id="ar-dot"></span>
|
||||
<input type="checkbox" id="auto-refresh" class="d-none"> Auto-refresh
|
||||
</label>
|
||||
<button class="btn btn-sm btn-outline-danger" id="clear-btn" title="Clear log file">
|
||||
<i class="bi bi-trash"></i> Clear
|
||||
</button>
|
||||
<div class="input-group input-group-sm" style="max-width:240px;">
|
||||
<span class="input-group-text" style="background:#f8fafc;"><i class="bi bi-search" style="font-size:11px;"></i></span>
|
||||
<input id="search-input" type="text" class="form-control form-control-sm"
|
||||
placeholder="Search message…" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<select id="module-select" class="form-select form-select-sm" style="max-width:180px;">
|
||||
<option value="">All modules</option>
|
||||
</select>
|
||||
|
||||
<select id="limit-select" class="form-select form-select-sm" style="max-width:110px;">
|
||||
<option value="100">Last 100</option>
|
||||
<option value="200" selected>Last 200</option>
|
||||
<option value="500">Last 500</option>
|
||||
<option value="1000">Last 1 000</option>
|
||||
<option value="2000">Last 2 000</option>
|
||||
</select>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary wrap-btn" id="wrap-btn" title="Toggle message wrapping">
|
||||
<i class="bi bi-text-wrap"></i>
|
||||
</button>
|
||||
|
||||
<div class="toolbar-sep"></div>
|
||||
|
||||
<div class="ar-wrap" id="ar-toggle" title="Toggle auto-refresh every 5 seconds">
|
||||
<span class="ar-dot" id="ar-dot"></span>
|
||||
<span>Auto-refresh</span>
|
||||
<span class="ar-countdown" id="ar-countdown"></span>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('logs.download') }}" class="btn btn-sm btn-outline-secondary" title="Download full log file">
|
||||
<i class="bi bi-download me-1"></i><span class="d-none d-sm-inline">Download</span>
|
||||
</a>
|
||||
|
||||
<button class="btn btn-sm btn-outline-danger" id="clear-btn" title="Clear log file">
|
||||
<i class="bi bi-trash me-1"></i><span class="d-none d-sm-inline">Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Log table -->
|
||||
<div id="log-table-wrap">
|
||||
<table id="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ts-col">Timestamp</th>
|
||||
<th style="width:90px">Level</th>
|
||||
<th class="name-col">Module</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log-body">
|
||||
<tr><td colspan="4" id="empty-state">
|
||||
<i class="bi bi-hourglass-split fs-3 d-block mb-2 opacity-25"></i>
|
||||
Loading…
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="log-wrap">
|
||||
<table id="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ts">Timestamp</th>
|
||||
<th class="col-lvl">Level</th>
|
||||
<th class="col-mod d-mob-none">Module</th>
|
||||
<th class="col-msg">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log-body">
|
||||
<tr><td colspan="4"><div class="log-empty">
|
||||
<i class="bi bi-hourglass-split"></i>Loading…
|
||||
</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-2 small text-muted px-1">
|
||||
<span id="result-count"></span>
|
||||
<span id="last-refresh"></span>
|
||||
<!-- Footer -->
|
||||
<div class="log-footer">
|
||||
<span id="result-count"></span>
|
||||
<span id="last-refresh"></span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
let activeLevel = 'ALL';
|
||||
let arTimer = null;
|
||||
'use strict';
|
||||
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
// ── fetch & render ────────────────────────────────────────────────────────
|
||||
function load() {
|
||||
let activeLevel = 'ALL';
|
||||
let wrapMessages = false;
|
||||
let arEnabled = false;
|
||||
let arTimer = null;
|
||||
let arCountdown = 0;
|
||||
let arTick = null;
|
||||
let allModules = [];
|
||||
let expandedRows = new Set(); // track which row indices are expanded
|
||||
|
||||
// ── Fetch & render ──────────────────────────────────────────────────────────
|
||||
function load() {
|
||||
const search = document.getElementById('search-input').value.trim();
|
||||
const module = document.getElementById('module-input').value.trim();
|
||||
const module = document.getElementById('module-select').value;
|
||||
const limit = document.getElementById('limit-select').value;
|
||||
const spinner = document.getElementById('spinner');
|
||||
spinner.classList.add('on');
|
||||
document.getElementById('spinner').classList.add('on');
|
||||
|
||||
const params = new URLSearchParams({ level: activeLevel, search, module, limit });
|
||||
fetch(`{{ url_for('logs.api') }}?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
spinner.classList.remove('on');
|
||||
renderTable(data.entries || []);
|
||||
renderCounts(data.counts || {});
|
||||
document.getElementById('last-refresh').textContent =
|
||||
'Updated ' + new Date().toLocaleTimeString();
|
||||
document.getElementById('result-count').textContent =
|
||||
`Showing ${(data.entries||[]).length} of ${data.total_raw || 0} lines`;
|
||||
})
|
||||
.catch(() => spinner.classList.remove('on'));
|
||||
}
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('spinner').classList.remove('on');
|
||||
expandedRows.clear();
|
||||
renderTable(data.entries || []);
|
||||
renderCounts(data.counts || {});
|
||||
populateModules(data.modules || []);
|
||||
document.getElementById('last-refresh').textContent =
|
||||
'Refreshed ' + new Date().toLocaleTimeString();
|
||||
const shown = (data.entries || []).length;
|
||||
const total = data.total_raw || 0;
|
||||
document.getElementById('result-count').textContent =
|
||||
shown + ' entr' + (shown === 1 ? 'y' : 'ies') +
|
||||
(total ? ' · ' + total + ' lines in file' : '');
|
||||
})
|
||||
.catch(() => document.getElementById('spinner').classList.remove('on'));
|
||||
}
|
||||
|
||||
function renderTable(entries) {
|
||||
// ── Table rendering ─────────────────────────────────────────────────────────
|
||||
function esc(t) {
|
||||
return (t || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function lvlBadge(lvl) {
|
||||
const cls = { INFO:'lb-INFO', WARNING:'lb-WARNING', ERROR:'lb-ERROR',
|
||||
CRITICAL:'lb-CRITICAL', DEBUG:'lb-DEBUG', RAW:'lb-RAW' }[lvl] || 'lb-RAW';
|
||||
return `<span class="lvl-badge ${cls}">${esc(lvl)}</span>`;
|
||||
}
|
||||
|
||||
function shortMod(name) {
|
||||
// Show last two segments: app.services.teller_service → services.teller_service
|
||||
const parts = (name || '').split('.');
|
||||
return parts.length > 2 ? parts.slice(-2).join('.') : name;
|
||||
}
|
||||
|
||||
function renderTable(entries) {
|
||||
const tbody = document.getElementById('log-body');
|
||||
if (!entries.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="4" id="empty-state">
|
||||
<i class="bi bi-inbox fs-3 d-block mb-2 opacity-25"></i>
|
||||
No log entries match your filters.
|
||||
</td></tr>`;
|
||||
return;
|
||||
tbody.innerHTML = `<tr><td colspan="4"><div class="log-empty">
|
||||
<i class="bi bi-inbox"></i>No entries match your filters.
|
||||
</div></td></tr>`;
|
||||
return;
|
||||
}
|
||||
const rows = entries.map(e => {
|
||||
const lvl = e.level || 'RAW';
|
||||
const safe = t => (t||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return `<tr class="row-${lvl}">
|
||||
<td class="ts-col">${safe(e.ts)}</td>
|
||||
<td><span class="level-badge level-${lvl}">${lvl}</span></td>
|
||||
<td class="name-col" title="${safe(e.name)}">${safe(e.name.split('.').pop())}</td>
|
||||
<td class="msg-col">${safe(e.message)}</td>
|
||||
</tr>`;
|
||||
const wrapCls = wrapMessages ? '' : ' nowrap';
|
||||
const rows = entries.map((e, i) => {
|
||||
const lvl = e.level || 'RAW';
|
||||
return `<tr class="row-${esc(lvl)}" data-idx="${i}" data-ts="${esc(e.ts)}" data-lvl="${esc(lvl)}" data-name="${esc(e.name)}" data-msg="${esc(e.message).replace(/"/g,'"')}">
|
||||
<td class="col-ts"><span class="ts-text">${esc(e.ts)}</span></td>
|
||||
<td class="col-lvl">${lvlBadge(lvl)}</td>
|
||||
<td class="col-mod d-mob-none"><span class="mod-text" title="${esc(e.name)}">${esc(shortMod(e.name))}</span></td>
|
||||
<td class="col-msg"><span class="msg-text${wrapCls}">${esc(e.message)}</span></td>
|
||||
</tr>`;
|
||||
});
|
||||
tbody.innerHTML = rows.join('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCounts(counts) {
|
||||
const total = Object.values(counts).reduce((s,v)=>s+v, 0);
|
||||
document.getElementById('cnt-ALL').textContent = total;
|
||||
document.getElementById('cnt-ERROR').textContent = counts['ERROR'] || 0;
|
||||
document.getElementById('cnt-WARNING').textContent = counts['WARNING'] || 0;
|
||||
document.getElementById('cnt-INFO').textContent = counts['INFO'] || 0;
|
||||
document.getElementById('cnt-DEBUG').textContent = counts['DEBUG'] || 0;
|
||||
}
|
||||
// ── Expand row on click ─────────────────────────────────────────────────────
|
||||
document.getElementById('log-body').addEventListener('click', function(e) {
|
||||
const row = e.target.closest('tr[data-idx]');
|
||||
if (!row) return;
|
||||
// If clicking copy button inside an expanded panel, let it work
|
||||
if (e.target.closest('.copy-btn')) return;
|
||||
|
||||
// ── level pills ───────────────────────────────────────────────────────────
|
||||
document.getElementById('stats-bar').addEventListener('click', function (e) {
|
||||
const pill = e.target.closest('.stat-pill');
|
||||
const idx = row.dataset.idx;
|
||||
const next = row.nextElementSibling;
|
||||
|
||||
// Collapse if already expanded
|
||||
if (next && next.classList.contains('expanded-row')) {
|
||||
next.remove();
|
||||
row.classList.remove('_expanded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse any other open row first
|
||||
document.querySelectorAll('.expanded-row').forEach(r => r.remove());
|
||||
document.querySelectorAll('._expanded').forEach(r => r.classList.remove('_expanded'));
|
||||
|
||||
// Build expanded panel
|
||||
const ts = row.dataset.ts;
|
||||
const lvl = row.dataset.lvl;
|
||||
const name = row.dataset.name;
|
||||
const msg = row.dataset.msg.replace(/"/g, '"');
|
||||
const raw = `${ts}|${lvl}|${name}|${msg}`;
|
||||
|
||||
const panel = document.createElement('tr');
|
||||
panel.className = 'expanded-row';
|
||||
panel.innerHTML = `<td colspan="4">
|
||||
<div class="expand-panel">
|
||||
<span class="ep-label">Timestamp</span><span class="ep-ts ep-val">${esc(ts)}</span>
|
||||
<span class="ep-label" style="margin-top:8px;">Module</span><span class="ep-mod ep-val">${esc(name) || '—'}</span>
|
||||
<span class="ep-label" style="margin-top:8px;">Message</span><span class="${lvl === 'ERROR' || lvl === 'CRITICAL' ? 'ep-err' : ''} ep-val">${esc(msg)}</span>
|
||||
<button class="copy-btn" onclick="copyRaw(this, ${JSON.stringify(raw)})">
|
||||
<i class="bi bi-clipboard me-1"></i>Copy raw line
|
||||
</button>
|
||||
</div>
|
||||
</td>`;
|
||||
|
||||
row.after(panel);
|
||||
row.classList.add('_expanded');
|
||||
});
|
||||
|
||||
window.copyRaw = function(btn, text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
btn.innerHTML = '<i class="bi bi-check2 me-1"></i>Copied!';
|
||||
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clipboard me-1"></i>Copy raw line'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Counts & module dropdown ────────────────────────────────────────────────
|
||||
function renderCounts(counts) {
|
||||
const total = Object.values(counts).reduce((s, v) => s + v, 0);
|
||||
document.getElementById('cnt-ALL').textContent = total || 0;
|
||||
document.getElementById('cnt-ERROR').textContent = counts['ERROR'] || 0;
|
||||
document.getElementById('cnt-CRITICAL').textContent = counts['CRITICAL'] || 0;
|
||||
document.getElementById('cnt-WARNING').textContent = counts['WARNING'] || 0;
|
||||
document.getElementById('cnt-INFO').textContent = counts['INFO'] || 0;
|
||||
document.getElementById('cnt-DEBUG').textContent = counts['DEBUG'] || 0;
|
||||
}
|
||||
|
||||
function populateModules(modules) {
|
||||
if (JSON.stringify(modules) === JSON.stringify(allModules)) return;
|
||||
allModules = modules;
|
||||
const sel = document.getElementById('module-select');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">All modules</option>' +
|
||||
modules.map(m => `<option value="${esc(m)}"${m === cur ? ' selected' : ''}>${esc(m)}</option>`).join('');
|
||||
}
|
||||
|
||||
// ── Level pill clicks ───────────────────────────────────────────────────────
|
||||
document.getElementById('level-pills').addEventListener('click', function(e) {
|
||||
const pill = e.target.closest('.lvl-pill');
|
||||
if (!pill) return;
|
||||
document.querySelectorAll('.stat-pill').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.lvl-pill').forEach(p => p.classList.remove('active'));
|
||||
pill.classList.add('active');
|
||||
activeLevel = pill.dataset.level;
|
||||
load();
|
||||
});
|
||||
});
|
||||
|
||||
// ── search / filter inputs ────────────────────────────────────────────────
|
||||
let debounce;
|
||||
['search-input','module-input','limit-select'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', function () {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(load, 300);
|
||||
// ── Search / filter / limit ─────────────────────────────────────────────────
|
||||
let debounce;
|
||||
['search-input', 'module-select', 'limit-select'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const ev = id === 'limit-select' || id === 'module-select' ? 'change' : 'input';
|
||||
el.addEventListener(ev, () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(load, 280);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── auto-refresh ──────────────────────────────────────────────────────────
|
||||
const arCheckbox = document.getElementById('auto-refresh');
|
||||
const arDot = document.getElementById('ar-dot');
|
||||
document.querySelector('label[for="auto-refresh"]') ||
|
||||
document.querySelector('label').addEventListener; // noop
|
||||
// ── Wrap toggle ─────────────────────────────────────────────────────────────
|
||||
document.getElementById('wrap-btn').addEventListener('click', function() {
|
||||
wrapMessages = !wrapMessages;
|
||||
this.classList.toggle('active', wrapMessages);
|
||||
document.querySelectorAll('.msg-text').forEach(el => {
|
||||
el.classList.toggle('nowrap', !wrapMessages);
|
||||
});
|
||||
});
|
||||
|
||||
// toggle by clicking the label area
|
||||
document.querySelector('.auto-refresh-dot').parentElement.addEventListener('click', function () {
|
||||
arCheckbox.checked = !arCheckbox.checked;
|
||||
arDot.classList.toggle('on', arCheckbox.checked);
|
||||
if (arCheckbox.checked) {
|
||||
arTimer = setInterval(load, 5000);
|
||||
} else {
|
||||
clearInterval(arTimer);
|
||||
}
|
||||
});
|
||||
// ── Auto-refresh ────────────────────────────────────────────────────────────
|
||||
const AR_INTERVAL = 5;
|
||||
|
||||
// ── clear ─────────────────────────────────────────────────────────────────
|
||||
document.getElementById('clear-btn').addEventListener('click', function () {
|
||||
if (!confirm('Clear all log entries from the file? This cannot be undone.')) return;
|
||||
function startAR() {
|
||||
arEnabled = true;
|
||||
document.getElementById('ar-dot').classList.add('on');
|
||||
arCountdown = AR_INTERVAL;
|
||||
document.getElementById('ar-countdown').textContent = arCountdown + 's';
|
||||
arTick = setInterval(() => {
|
||||
arCountdown--;
|
||||
if (arCountdown <= 0) {
|
||||
arCountdown = AR_INTERVAL;
|
||||
load();
|
||||
}
|
||||
document.getElementById('ar-countdown').textContent = arCountdown + 's';
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopAR() {
|
||||
arEnabled = false;
|
||||
clearInterval(arTick);
|
||||
document.getElementById('ar-dot').classList.remove('on');
|
||||
document.getElementById('ar-countdown').textContent = '';
|
||||
}
|
||||
|
||||
document.getElementById('ar-toggle').addEventListener('click', () => {
|
||||
arEnabled ? stopAR() : startAR();
|
||||
});
|
||||
|
||||
// ── Clear ───────────────────────────────────────────────────────────────────
|
||||
document.getElementById('clear-btn').addEventListener('click', function() {
|
||||
if (!confirm('Clear all log entries from the file?\nThis cannot be undone.')) return;
|
||||
fetch('{{ url_for("logs.clear") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF, 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF, 'Content-Type': 'application/json' },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.status === 'ok') load();
|
||||
else alert('Clear failed: ' + d.error);
|
||||
if (d.status === 'ok') load();
|
||||
else alert('Clear failed: ' + d.error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────────────
|
||||
load();
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||
if (e.key === 'r' || e.key === 'R') load();
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
arEnabled ? stopAR() : startAR();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.expanded-row').forEach(r => r.remove());
|
||||
document.querySelectorAll('._expanded').forEach(r => r.classList.remove('_expanded'));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────────
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Log nav card -->
|
||||
<!-- Audit Log + System Logs nav cards -->
|
||||
<div class="row g-3 mt-0">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<a href="{{ url_for('settings.audit_log') }}" class="text-decoration-none">
|
||||
@@ -72,6 +72,15 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<a href="{{ url_for('logs.index') }}" class="text-decoration-none">
|
||||
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#0f172a'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<i class="bi bi-terminal" style="font-size:2rem;color:#0f172a;"></i>
|
||||
<div style="font-size:14px;font-weight:600;margin-top:10px;">System Logs</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Application log viewer</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-Factor Authentication -->
|
||||
|
||||
Reference in New Issue
Block a user