Files
felhom.eu/hub/internal/web/templates/app_detail.html
T
admin c084046af0 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
2026-07-10 16:03:32 +02:00

322 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.AppName}} — Felhom Hub</title>
<link rel="stylesheet" href="/style.css">
<script src="/static/chart.min.js"></script>
</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">Customers</a>
<a href="/apps" class="nav-link active">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="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">&larr; Apps</a>
<!-- Period selector (carries the customer filter + show-dismissed state) -->
<div class="period-selector" style="margin-top: 1rem;">
<a href="?period=24h{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
<a href="?period=7d{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
<a href="?period=30d{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;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_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.</div>
{{end}}
<!-- Overview card -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">{{if .Summary}}{{if .Summary.DisplayName}}{{.Summary.DisplayName}}{{else}}{{.AppName}}{{end}}{{else}}{{.AppName}}{{end}}</h2>
<form method="POST" action="/apps/{{.AppName}}/reset-telemetry{{if .Period}}?period={{.Period}}{{end}}"
onsubmit="return confirm('Are you sure you want to delete all telemetry data for {{.AppName}}? This cannot be undone.');">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-sm btn-danger">Reset Telemetry</button>
</form>
</div>
<div class="info-grid">
<div class="info-item">
<span class="label">App Name</span>
<span class="value" style="font-family: var(--font-mono)">{{.AppName}}</span>
</div>
{{if .Summary}}
<div class="info-item">
<span class="label">Deployments</span>
<span class="value">{{.Summary.DeploymentCount}}</span>
</div>
<div class="info-item">
<span class="label">Catalog Estimate</span>
<span class="value">{{if .Summary.CatalogEstimate}}{{.Summary.CatalogEstimate}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Catalog Limit</span>
<span class="value">{{if .Summary.CatalogLimit}}{{.Summary.CatalogLimit}}{{else}}—{{end}}</span>
</div>
{{if .SuggestedLimit}}
<div class="info-item">
<span class="label">Suggested Limit (P95×1.2)</span>
<span class="value" style="color: var(--yellow)">{{.SuggestedLimit}} MB</span>
</div>
{{end}}
<div class="info-item">
<span class="label">Avg Memory</span>
<span class="value">{{formatFloat .Summary.AvgMemoryMB}} MB</span>
</div>
<div class="info-item">
<span class="label">P95 Memory</span>
<span class="value {{accuracyClass .Summary.P95MemoryMB .Summary.CatalogLimit}}">{{formatFloat .Summary.P95MemoryMB}} MB</span>
</div>
<div class="info-item">
<span class="label">Avg CPU</span>
<span class="value">{{formatFloat .Summary.AvgCPU}}%</span>
</div>
{{end}}
</div>
</section>
<!-- Memory trend chart -->
<section class="card">
<h2>Memory Trend</h2>
<div class="chart-container">
<canvas id="memoryChart"></canvas>
</div>
<script>
(function() {
var chartData = {{json .ChartData}};
if (!chartData || !chartData.labels || chartData.labels.length === 0) {
document.getElementById('memoryChart').parentElement.innerHTML = '<p class="text-muted">Not enough data for the chart.</p>';
return;
}
var ctx = document.getElementById('memoryChart').getContext('2d');
var datasets = [
{
label: 'Avg Memory (MB)',
data: chartData.avg_memory,
borderColor: '#2EA8F5',
backgroundColor: 'rgba(46,168,245,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2
},
{
label: 'Peak Memory (MB)',
data: chartData.peak_memory,
borderColor: '#8E7CE8',
backgroundColor: 'transparent',
fill: false,
tension: 0.3,
pointRadius: 2,
borderDash: [4, 2]
}
];
if (chartData.catalog_limit > 0) {
datasets.push({
label: 'Catalog Limit',
data: chartData.labels.map(function() { return chartData.catalog_limit; }),
borderColor: '#E0A93E',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 1,
borderDash: [6, 4]
});
}
new Chart(ctx, {
type: 'line',
data: { labels: chartData.labels, datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94A6BF', font: { size: 11 } } }
},
scales: {
x: { ticks: { color: '#5E7392', font: { size: 10 }, maxTicksLimit: 10 }, grid: { color: 'rgba(34,52,79,.5)' } },
y: { ticks: { color: '#5E7392', font: { size: 10 } }, grid: { color: 'rgba(34,52,79,.5)' }, title: { display: true, text: 'MB', color: '#5E7392' } }
}
}
});
})();
</script>
</section>
<!-- Customer breakdown -->
{{if .Customers}}
<section class="card">
<h2>Customer Breakdown</h2>
<table class="data-table">
<thead>
<tr>
<th>Customer</th>
<th>Avg Memory</th>
<th>Peak Memory</th>
<th>Avg CPU</th>
<th>Total Errors</th>
<th>Last Report</th>
</tr>
</thead>
<tbody>
{{range .Customers}}
<tr>
<td><a href="/customers/{{.CustomerID}}">{{.CustomerID}}</a></td>
<td>{{formatFloat .AvgMemoryMB}} MB</td>
<td>{{formatFloat .PeakMemoryMB}} MB</td>
<td>{{formatFloat .AvgCPU}}%</td>
<td>{{if gt .TotalErrors 0}}<span class="badge badge-error">{{.TotalErrors}}</span>{{else}}0{{end}}</td>
<td>{{timeAgo .LastReport}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<!-- Known issues -->
<section class="card">
<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}}&amp;show_dismissed=1{{end}}" style="font-size: 0.85rem;">Clear filter (fleet view)</a>
{{end}}
{{if .ShowDismissed}}
<a href="?period={{.Period}}{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}" style="font-size: 0.85rem;">Hide dismissed</a>
{{else}}
<a href="?period={{.Period}}{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}&amp;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">
<thead>
<tr>
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
<th>Severity</th>
<th>Message</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{{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; 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>
&middot; severity: {{.Severity}}
&middot; first seen: {{.FirstSeen.Format "2006-01-02 15:04:05"}}
&middot; last seen: {{.LastSeen.Format "2006-01-02 15:04:05"}}
{{if .DismissedAt}}&middot; 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';">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>
document.getElementById('selectAll').addEventListener('change', function() {
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>
<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>