hub v0.43.0: remote app-log diagnostics — copyable issues + context + on-demand log tails + range/dismissal fixes
- store: app_log_issues gains context/context_customer (first capture wins) + dismissed_at (resurface only on last_seen > dismissed_at); log_tail_requests (pending operator intents, consume-once) + app_log_tails (transient, keep last 2 per app) - api: /report ingests log_tails (stores + clears the request); ACK advertises log_tail_requests (same additive omit-when-empty pattern as escrow) - web: Known Issues rows click-to-expand (full copyable message + context with provenance + explicit affected-customers list); Dismiss replaces Delete; period selector now filters issues (F); ?customer= filtered view + customer-page drill-down links (H); per-app Request-log-tail button + pending badge + App Log Tails section + ordered tail view with line numbers + .log download; customer-visible log_tail_requested event - tests: store (context first-capture/late-adopt, range filter, dismissal old-window vs new-occurrence, tail request/fulfill/prune/scoping), api ACK round-trip, web render (expanded row, customer page sections, tail view + download + cross-customer 404) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -24,21 +24,21 @@
|
||||
|
||||
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">← Apps</a>
|
||||
|
||||
<!-- Period selector -->
|
||||
<!-- Period selector (carries the customer filter + show-dismissed state) -->
|
||||
<div class="period-selector" style="margin-top: 1rem;">
|
||||
<a href="?period=24h" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
|
||||
<a href="?period=7d" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
|
||||
<a href="?period=30d" class="period-btn{{if eq .Period "30d"}} active{{end}}">30d</a>
|
||||
<a href="?period=24h{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
|
||||
<a href="?period=7d{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
|
||||
<a href="?period=30d{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" class="period-btn{{if eq .Period "30d"}} active{{end}}">30d</a>
|
||||
</div>
|
||||
|
||||
{{if eq .Flash "telemetry_reset"}}
|
||||
<div class="flash flash-success" style="margin-top: 1rem;">Telemetry data deleted successfully.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "issues_deleted"}}
|
||||
<div class="flash flash-success" style="margin-top: 1rem;">Selected issues deleted successfully.</div>
|
||||
{{if eq .Flash "issues_dismissed"}}
|
||||
<div class="flash flash-success" style="margin-top: 1rem;">Issues dismissed — they stay hidden until a NEW occurrence resurfaces them.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "no_issues_selected"}}
|
||||
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected for deletion.</div>
|
||||
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected.</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Overview card -->
|
||||
@@ -189,10 +189,22 @@
|
||||
{{end}}
|
||||
|
||||
<!-- Known issues -->
|
||||
{{if .Issues}}
|
||||
<section class="card">
|
||||
<h2>Known Issues</h2>
|
||||
<form method="POST" action="/apps/{{.AppName}}/delete-issues{{if .Period}}?period={{.Period}}{{end}}" id="issueForm">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h2 style="margin: 0;">Known Issues{{if .CustomerFilter}} <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">— filtered: {{.CustomerFilter}}</span>{{end}}</h2>
|
||||
<div style="display: flex; gap: 0.75rem; align-items: center;">
|
||||
{{if .CustomerFilter}}
|
||||
<a href="?period={{.Period}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" style="font-size: 0.85rem;">Clear filter (fleet view)</a>
|
||||
{{end}}
|
||||
{{if .ShowDismissed}}
|
||||
<a href="?period={{.Period}}{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}" style="font-size: 0.85rem;">Hide dismissed</a>
|
||||
{{else}}
|
||||
<a href="?period={{.Period}}{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}&show_dismissed=1" style="font-size: 0.85rem;">Show dismissed</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Issues}}
|
||||
<form method="POST" action="/apps/{{.AppName}}/dismiss-issues{{if .Period}}?period={{.Period}}{{end}}" id="issueForm">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="selected" id="issueAction">
|
||||
<table class="data-table">
|
||||
@@ -201,32 +213,64 @@
|
||||
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
|
||||
<th>Severity</th>
|
||||
<th>Message</th>
|
||||
<th>Occurrences</th>
|
||||
<th>Occurrences (all customers)</th>
|
||||
<th>Affected Customers</th>
|
||||
<th>First Seen</th>
|
||||
<th>Last Seen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Issues}}
|
||||
<tr>
|
||||
<tr{{if .DismissedAt}} style="opacity: 0.55;"{{end}}>
|
||||
<td><input type="checkbox" name="issue_ids" value="{{.ID}}" class="issue-cb"></td>
|
||||
<td>
|
||||
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
|
||||
{{else}}<span class="badge badge-warn">warn</span>{{end}}
|
||||
{{if .DismissedAt}}<span class="badge badge-neutral">dismissed</span>{{end}}
|
||||
</td>
|
||||
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{.Message}}">{{.Message}}</td>
|
||||
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;" onclick="toggleIssue({{.ID}})">{{.Message}}</td>
|
||||
<td>{{.OccurrenceCount}}</td>
|
||||
<td>{{len .AffectedCustomers}}</td>
|
||||
<td>{{timeAgo .FirstSeen}}</td>
|
||||
<td>{{timeAgo .LastSeen}}</td>
|
||||
<td><button type="button" class="btn btn-sm" onclick="toggleIssue({{.ID}})">Details</button></td>
|
||||
</tr>
|
||||
<tr id="issue-detail-{{.ID}}" style="display: none;">
|
||||
<td colspan="8" style="background: var(--bg-1); border: 1px solid var(--line-soft); padding: 0.75rem;">
|
||||
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
|
||||
fingerprint: <span style="font-family: var(--font-mono);">{{.Fingerprint}}</span>
|
||||
· severity: {{.Severity}}
|
||||
· first seen: {{.FirstSeen.Format "2006-01-02 15:04:05"}}
|
||||
· last seen: {{.LastSeen.Format "2006-01-02 15:04:05"}}
|
||||
{{if .DismissedAt}}· dismissed: {{.DismissedAt.Format "2006-01-02 15:04:05"}}{{end}}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
|
||||
affected customers:
|
||||
{{range $i, $c := .AffectedCustomers}}{{if $i}}, {{end}}<a href="/customers/{{$c}}">{{$c}}</a>{{else}}—{{end}}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
|
||||
<span class="text-muted" style="font-size: 0.8rem;">Full message:</span>
|
||||
<button type="button" class="btn btn-sm" onclick="copyText('issue-msg-{{.ID}}', this)">Copy</button>
|
||||
</div>
|
||||
<pre id="issue-msg-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0.75rem;">{{.Message}}</pre>
|
||||
{{if .Context}}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
|
||||
<span class="text-muted" style="font-size: 0.8rem;">Context around first occurrence{{if .ContextCustomer}} — from <a href="/customers/{{.ContextCustomer}}">{{.ContextCustomer}}</a>{{end}}:</span>
|
||||
<button type="button" class="btn btn-sm" onclick="copyText('issue-ctx-{{.ID}}', this)">Copy</button>
|
||||
</div>
|
||||
<pre id="issue-ctx-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0;">{{joinStrings .Context "\n"}}</pre>
|
||||
{{else}}
|
||||
<span class="text-muted" style="font-size: 0.8rem;">No context captured (pre-v0.111 report or warn-severity issue).</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem;">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="document.getElementById('issueAction').value='selected';">Delete Selected</button>
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Delete ALL issues for {{.AppName}}? This cannot be undone.')) return false; document.getElementById('issueAction').value='all';">Delete All Issues</button>
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="document.getElementById('issueAction').value='selected';">Dismiss Selected</button>
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Dismiss ALL issues for {{.AppName}}? They resurface on a new occurrence.')) return false; document.getElementById('issueAction').value='all';">Dismiss All Issues</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
@@ -234,9 +278,40 @@
|
||||
var cbs = document.querySelectorAll('.issue-cb');
|
||||
for (var i = 0; i < cbs.length; i++) cbs[i].checked = this.checked;
|
||||
});
|
||||
function toggleIssue(id) {
|
||||
var el = document.getElementById('issue-detail-' + id);
|
||||
if (el) el.style.display = (el.style.display === 'none') ? '' : 'none';
|
||||
}
|
||||
function copyText(id, btn) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
var text = el.textContent;
|
||||
var done = function() {
|
||||
var old = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(function() { btn.textContent = old; }, 1200);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function() { fallbackCopy(text, done); });
|
||||
} else {
|
||||
fallbackCopy(text, done);
|
||||
}
|
||||
}
|
||||
function fallbackCopy(text, done) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); done(); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
</script>
|
||||
{{else}}
|
||||
<p class="text-muted">No issues in this period{{if .CustomerFilter}} for this customer{{end}}.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
||||
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
{{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored.
|
||||
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
|
||||
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
|
||||
{{else if eq .Flash "log_tail_requested"}}Log tail requested — the controller delivers it on its next report cycle (a few minutes). A customer-visible event line was recorded.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -616,18 +617,62 @@
|
||||
<th>Catalog Limit</th>
|
||||
<th>Errors</th>
|
||||
<th>Warnings</th>
|
||||
<th>Logs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AppTelemetry}}
|
||||
<tr>
|
||||
<td><a href="/apps/{{.AppName}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
|
||||
<td><a href="/apps/{{.AppName}}?customer={{$.CustomerID}}" title="Known issues filtered to this customer">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
|
||||
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
|
||||
<td>{{formatFloat .MemoryAvgMB}} MB</td>
|
||||
<td>{{formatFloat .MemoryPeakMB}} MB</td>
|
||||
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
|
||||
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
|
||||
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
|
||||
<td>
|
||||
{{if index $.PendingTails .AppName}}
|
||||
<span class="badge badge-neutral" title="The controller delivers the tail on its next report cycle">tail pending</span>
|
||||
{{else}}
|
||||
<form method="POST" action="/customers/{{$.CustomerID}}/request-log-tail" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="app" value="{{.AppName}}">
|
||||
<button type="submit" class="btn btn-sm" title="Pull-based: the controller ships the last 200 log lines on its next report; a customer-visible event line is recorded">Request log tail</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Received log tails (on-demand, transient — last 2 per app) -->
|
||||
{{if .HasLogTails}}
|
||||
<section class="card">
|
||||
<h2>App Log Tails <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(on-demand, last 2 per app kept)</span></h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App</th>
|
||||
<th>Collected</th>
|
||||
<th>Received</th>
|
||||
<th>Lines</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .LogTails}}
|
||||
<tr>
|
||||
<td style="font-family: var(--font-mono);">{{.AppName}}</td>
|
||||
<td>{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}})</td>
|
||||
<td>{{timeAgo .ReceivedAt}}</td>
|
||||
<td>{{len .Lines}}</td>
|
||||
<td style="white-space: nowrap;">
|
||||
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}" class="btn btn-sm">View</a>
|
||||
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}?download=1" class="btn btn-sm">Download .log</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Tail.AppName}} log tail — Felhom Hub</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "icon_sprite"}}
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom <span>Hub</span></h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<a href="/customers/{{.CustomerID}}" class="back-link">← {{.CustomerID}}</a>
|
||||
|
||||
<section class="card" style="margin-top: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h2 style="margin: 0;">Log tail: <span style="font-family: var(--font-mono);">{{.Tail.AppName}}</span></h2>
|
||||
<a href="/customers/{{.CustomerID}}/log-tail/{{.Tail.ID}}?download=1" class="btn btn-sm">Download .log</a>
|
||||
</div>
|
||||
<p class="text-muted" style="margin-top: 0.25rem; font-size: 0.85rem;">
|
||||
Collected on the box at {{.Tail.CollectedAt.Format "2006-01-02 15:04:05"}} UTC ({{timeAgo .Tail.CollectedAt}})
|
||||
· {{len .Tail.Lines}} lines, ordered as emitted · redacted controller-side before shipping.
|
||||
</p>
|
||||
{{if .Tail.Lines}}
|
||||
<ol class="log-lines" style="font-family: var(--font-mono); font-size: 0.8rem; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem 0.5rem 0.5rem 4rem; margin: 0.5rem 0 0; overflow-x: auto;">
|
||||
{{range .Tail.Lines}}
|
||||
<li style="white-space: pre-wrap; word-break: break-word; color: var(--text-1);">{{.}}</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
{{else}}
|
||||
<p class="text-muted">The tail arrived empty (the container produced no log lines).</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
||||
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -759,6 +759,10 @@ code {
|
||||
background: rgba(250, 204, 21, 0.2);
|
||||
color: var(--warn);
|
||||
}
|
||||
.badge-neutral {
|
||||
background: var(--bg-2);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* Summary cards row */
|
||||
.summary-cards {
|
||||
|
||||
Reference in New Issue
Block a user