This is a large squashed commit covering two batches of prior uncommitted work plus a full security-audit remediation pass, kept together because go.mod/go.sum and several shared files (main.go, handler.go) were touched by both and splitting risked non-building intermediate commits. Features (built earlier, previously uncommitted): - Local username/password login for single-tenant deployments with no SSO configured (api/localauth, alerting/internal/sessioncheck, sentryctl users, web/src/routes/login, metadata migrations 0040/0041). - Remotely-editable additional log file paths for agents, on top of their existing primary source (api/agents, agent/sentry-agent extra-file-path diffing, web agent config UI). - IPv4/IPv6 addresses reported alongside other host system metrics. Security audit remediation (this pass, all live-verified in production): - Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...) in the raw-SQL query escape hatch. - High: deny sensitive paths and require Admin to add agent extra_file_paths (Editor could previously point an agent at /etc/shadow or an SSH key); alerting webhook targets now validate against internal/metadata/loopback addresses, both at creation and send time; alerting's session middleware now enforces an Editor+ floor on mutating requests instead of "any authenticated session"; bumped goxmldsig to close a SAML signature-verification bypass (GO-2026-4753). - Medium: per-IP login rate limiting; security response headers (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy) on web/nginx.conf; a DevCredentialWarnings check in every Go service's config loader, logging loudly at startup if a deployment is still on docker-compose.yml's literal dev-only credentials; dependency bumps (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected Go module and both Rust crates, including a previously-uncovered x/net vulnerability in deploy/operator; a new security-scan.yml CI workflow running cargo-deny/govulncheck/npm-audit, mirroring the existing license-compliance.yml matrix shape. - Low: removed sentryctl's plaintext --password flag (shell history/`ps` exposure) in favor of stdin and a --password-stdin flag for reset-password's optional specific-password path; a dummy bcrypt comparison closes a login response-time username-enumeration side-channel.
187 lines
4.9 KiB
Svelte
187 lines
4.9 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { getHostMetrics, type HostMetrics } from '$lib/api';
|
|
import { Card, Skeleton } from '$lib/components/ui';
|
|
|
|
const host = page.params.host!;
|
|
|
|
let metrics = $state<HostMetrics | null>(null);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
|
|
async function load() {
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
metrics = await getHostMetrics(host);
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
load();
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
|
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
|
|
}
|
|
|
|
function percent(used: number, total: number): number {
|
|
if (total <= 0) return 0;
|
|
return Math.min(100, Math.max(0, (used / total) * 100));
|
|
}
|
|
|
|
function relativeTime(iso: string): string {
|
|
const ms = Date.now() - new Date(iso).getTime();
|
|
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
|
|
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
|
|
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
|
|
return `${Math.round(ms / 86_400_000)}d ago`;
|
|
}
|
|
|
|
function formatUptime(seconds: number): string {
|
|
if (seconds <= 0) return '—';
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
if (days > 0) return `${days}d ${hours}h`;
|
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
return `${minutes}m`;
|
|
}
|
|
</script>
|
|
|
|
<main>
|
|
<a class="back" href="/hosts">← Hosts</a>
|
|
<h1>{host}</h1>
|
|
|
|
{#if loading}
|
|
<Skeleton height="12rem" />
|
|
{:else if error}
|
|
<p class="error">Error: {error}</p>
|
|
{:else if !metrics}
|
|
<p class="hint">No metrics samples for this host yet.</p>
|
|
{:else}
|
|
<p class="hint">Last sample {relativeTime(metrics.timestamp)}.</p>
|
|
|
|
<section class="system">
|
|
<dl>
|
|
<dt>OS</dt>
|
|
<dd>{metrics.osName}</dd>
|
|
<dt>Kernel</dt>
|
|
<dd>{metrics.kernelVersion}</dd>
|
|
<dt>Architecture</dt>
|
|
<dd>{metrics.arch}</dd>
|
|
<dt>Uptime</dt>
|
|
<dd>{formatUptime(metrics.uptimeSeconds)}</dd>
|
|
<dt>IPv4</dt>
|
|
<dd>{metrics.ipv4Addresses.length > 0 ? metrics.ipv4Addresses.join(', ') : '—'}</dd>
|
|
<dt>IPv6</dt>
|
|
<dd>{metrics.ipv6Addresses.length > 0 ? metrics.ipv6Addresses.join(', ') : '—'}</dd>
|
|
</dl>
|
|
</section>
|
|
|
|
<div class="stats">
|
|
<Card title="CPU">
|
|
<div class="big-number">{metrics.cpuPercent.toFixed(1)}%</div>
|
|
<div class="bar">
|
|
<div class="bar-fill" style="width: {metrics.cpuPercent.toFixed(1)}%"></div>
|
|
</div>
|
|
<div class="detail">{metrics.cpuCores} core{metrics.cpuCores === 1 ? '' : 's'}</div>
|
|
</Card>
|
|
|
|
<Card title="Memory">
|
|
<div class="big-number">{percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%</div>
|
|
<div class="bar">
|
|
<div
|
|
class="bar-fill"
|
|
style="width: {percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%"
|
|
></div>
|
|
</div>
|
|
<div class="detail">{formatBytes(metrics.memUsedBytes)} / {formatBytes(metrics.memTotalBytes)}</div>
|
|
</Card>
|
|
|
|
<Card title="Disk (/)">
|
|
<div class="big-number">{percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%</div>
|
|
<div class="bar">
|
|
<div
|
|
class="bar-fill"
|
|
style="width: {percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%"
|
|
></div>
|
|
</div>
|
|
<div class="detail">{formatBytes(metrics.diskUsedBytes)} / {formatBytes(metrics.diskTotalBytes)}</div>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
max-width: 48rem;
|
|
}
|
|
.back {
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
text-decoration: none;
|
|
}
|
|
.back:hover {
|
|
color: var(--color-accent);
|
|
}
|
|
h1 {
|
|
font-size: var(--text-xl);
|
|
margin: var(--space-2) 0 var(--space-2);
|
|
font-family: var(--font-mono);
|
|
}
|
|
.hint {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--text-sm);
|
|
margin-bottom: var(--space-5);
|
|
}
|
|
.error {
|
|
color: var(--color-danger);
|
|
}
|
|
.system {
|
|
margin-bottom: var(--space-5);
|
|
}
|
|
.system dl {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: var(--space-1) var(--space-4);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.system dt {
|
|
color: var(--color-text-muted);
|
|
}
|
|
.system dd {
|
|
margin: 0;
|
|
}
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
|
gap: var(--space-4);
|
|
}
|
|
.big-number {
|
|
font-size: var(--text-xl);
|
|
font-weight: var(--font-weight-bold);
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
.bar {
|
|
height: 0.5rem;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
border: 1px solid var(--color-border);
|
|
overflow: hidden;
|
|
}
|
|
.bar-fill {
|
|
height: 100%;
|
|
background: var(--color-accent);
|
|
}
|
|
.detail {
|
|
margin-top: var(--space-2);
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
}
|
|
</style>
|