134 lines
5.9 KiB
Plaintext
134 lines
5.9 KiB
Plaintext
@page
|
|
@model LogsModel
|
|
@{
|
|
ViewData["Title"] = "Log Stream";
|
|
ViewData["Page"] = "logs";
|
|
}
|
|
|
|
@section Styles {
|
|
<style>
|
|
.chart-wrap canvas { height: 180px !important; }
|
|
</style>
|
|
}
|
|
|
|
<div class="section-header">
|
|
<span class="section-title">
|
|
Log Stream
|
|
<span class="live-count" id="live-count"></span>
|
|
</span>
|
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.TotalCount entries</span>
|
|
</div>
|
|
|
|
<!-- Log volume chart (last 24 h) -->
|
|
<div class="chart-wrap">
|
|
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:10px">LOG VOLUME - LAST 24H</div>
|
|
<canvas id="log-stats-chart"></canvas>
|
|
</div>
|
|
|
|
<form method="get" class="filter-bar mt-2">
|
|
<select name="Level" class="form-control" onchange="this.form.submit()">
|
|
<option value="">All Levels</option>
|
|
@foreach (var lvl in new[] { "debug", "info", "warn", "error", "critical" })
|
|
{
|
|
<option value="@lvl" selected="@(Model.Level?.ToLower() == lvl)">@lvl.ToUpper()</option>
|
|
}
|
|
</select>
|
|
<select name="Source" class="form-control" onchange="this.form.submit()">
|
|
<option value="">All Sources</option>
|
|
@foreach (var s in Model.Sources)
|
|
{
|
|
<option value="@s" selected="@(Model.Source == s)">@s</option>
|
|
}
|
|
</select>
|
|
<input id="log-search" type="text" name="Search" value="@Model.Search"
|
|
placeholder="Search messages… (/ to focus)" class="form-control" style="max-width:280px" />
|
|
<input type="date" name="FromDate" value="@Model.FromDate?.ToString("yyyy-MM-dd")" class="form-control" style="max-width:145px" title="From date" />
|
|
<input type="date" name="ToDate" value="@Model.ToDate?.ToString("yyyy-MM-dd")" class="form-control" style="max-width:145px" title="To date" />
|
|
<button type="submit" class="btn btn-outline btn-sm">Apply</button>
|
|
<a href="/logs" class="btn btn-outline btn-sm">Clear</a>
|
|
<button type="button" class="btn btn-outline btn-sm" onclick="exportLogs()" title="Export visible entries as JSON">⬇ Export</button>
|
|
</form>
|
|
|
|
<!-- Log toolbar -->
|
|
<div class="log-toolbar">
|
|
<span class="mono" style="font-size:10px;color:var(--text-muted)">ENTRIES</span>
|
|
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
|
<span class="kbd" title="Keyboard shortcut">/</span> <span style="font-size:11px;color:var(--text-muted)">search</span>
|
|
<button class="btn btn-outline btn-sm" onclick="scrollLogsToBottom()" title="Jump to bottom">↓ Bottom</button>
|
|
<button class="btn btn-outline btn-sm" id="btn-scroll-top" onclick="document.getElementById('log-stream').scrollTop=0" title="Jump to top">↑ Top</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="log-stream" id="log-stream" data-last-id="@(Model.Entries.FirstOrDefault()?.Id ?? 0)">
|
|
@if (!Model.Entries.Any())
|
|
{
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">▦</div>
|
|
<div class="empty-state-text">No log entries match your filters</div>
|
|
</div>
|
|
}
|
|
@foreach (var e in Model.Entries.AsEnumerable().Reverse())
|
|
{
|
|
<div class="log-entry">
|
|
<span class="log-ts">@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</span>
|
|
<span class="log-level @e.Level.ToLower()">@e.Level.ToUpper()</span>
|
|
<span class="log-source" title="@e.Source">@e.Source</span>
|
|
<span class="log-message">
|
|
@e.Message
|
|
@if (!string.IsNullOrEmpty(e.TraceId))
|
|
{
|
|
<span class="mono" style="font-size:9px;color:var(--text-muted);margin-left:6px">trace=@e.TraceId</span>
|
|
}
|
|
@if (!string.IsNullOrEmpty(e.Exception))
|
|
{
|
|
<details style="margin-top:3px">
|
|
<summary style="color:var(--down);cursor:pointer;font-size:10px">Exception ▾</summary>
|
|
<pre style="font-size:10px;color:var(--text-muted);margin-top:4px;white-space:pre-wrap">@e.Exception</pre>
|
|
</details>
|
|
}
|
|
</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (Model.TotalPages > 1)
|
|
{
|
|
<div class="flex gap-2 mt-2 align-center">
|
|
@if (Model.PageIndex > 1)
|
|
{
|
|
<a href="@Url.Page("/Logs", null, new { PageIndex = Model.PageIndex - 1, Level = Model.Level, Source = Model.Source, Search = Model.Search }, null)" class="btn btn-outline btn-sm">← Prev</a>
|
|
}
|
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">Page @Model.PageIndex of @Model.TotalPages</span>
|
|
@if (Model.PageIndex < Model.TotalPages)
|
|
{
|
|
<a href="@Url.Page("/Logs", null, new { PageIndex = Model.PageIndex + 1, Level = Model.Level, Source = Model.Source, Search = Model.Search }, null)" class="btn btn-outline btn-sm">Next →</a>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@section Scripts {
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" crossorigin="anonymous"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadLogStatsChart('log-stats-chart');
|
|
});
|
|
|
|
function exportLogs() {
|
|
const entries = [];
|
|
document.querySelectorAll('.log-entry').forEach(row => {
|
|
entries.push({
|
|
timestamp: row.querySelector('.log-ts')?.textContent?.trim(),
|
|
level: row.querySelector('.log-level')?.textContent?.trim(),
|
|
source: row.querySelector('.log-source')?.textContent?.trim(),
|
|
message: row.querySelector('.log-message')?.childNodes[0]?.textContent?.trim()
|
|
});
|
|
});
|
|
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' });
|
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
|
a.download = `logs_${new Date().toISOString().slice(0,10)}.json`;
|
|
a.click();
|
|
showToast('Exported ' + entries.length + ' entries', 'success');
|
|
}
|
|
</script>
|
|
}
|