feat: add dark mode, telemetry, and curl to Docker image
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 44s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m10s

- Install `curl` in Dockerfile for healthcheck/tooling support
- Add class-based dark mode via Tailwind `@custom-variant` and a
  pre-paint `<script>` in `app.html` to avoid theme flash on load
- Implement theme toggle in layout with system preference detection,
  `localStorage` persistence, and smooth transitions
- Update all UI components with `dark:` variants for full dark mode
  support across backgrounds, borders, and text colours
- Add `sendTelemetry` helper in `api.ts` for fire-and-forget
  client-side error reporting to `/api/v1/telemetry`
This commit is contained in:
2026-04-16 06:31:13 -04:00
parent 18710515d8
commit eb461f40ee
13 changed files with 304 additions and 109 deletions

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
# Nike — Football Data Platform
# Copy to .env and fill in your values. Never commit .env.
# ── TheSportsDB ──────────────────────────────────────────
# Free test key is '3' (limited). Get a premium key at https://www.patreon.com/thesportsdb
NIKE_SPORTSDB_KEY=3
# ── Database ──────────────────────────────────────────────
NIKE_DB_HOST=localhost
NIKE_DB_PORT=5432
NIKE_DB_USER=nike
NIKE_DB_PASSWORD=
NIKE_DB_NAME=nike
# ── Server ────────────────────────────────────────────────
NIKE_HOST=0.0.0.0
NIKE_PORT=8000
NIKE_LOG_LEVEL=WARNING
# IPs allowed to set X-Forwarded-* headers (your HAProxy host).
# '*' is safe when Nike's port is firewalled to HAProxy only.
NIKE_TRUSTED_PROXY=*
# ── Followed teams ────────────────────────────────────────
# Comma-separated list of "Team Name:League Name" pairs.
NIKE_TEAMS=Toronto FC:MLS, Arsenal:Premier League
# ── Legacy (not active) ───────────────────────────────────
# NIKE_RAPIDAPI_KEY=
# NIKE_API_FOOTBALL_KEY=

View File

@@ -2,6 +2,10 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .

View File

@@ -1,5 +1,8 @@
@import "tailwindcss"; @import "tailwindcss";
/* Class-based dark mode: toggled via .dark on <html>. */
@custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
--color-pitch: #16a34a; --color-pitch: #16a34a;
--color-pitch-dark: #15803d; --color-pitch-dark: #15803d;

View File

@@ -5,6 +5,16 @@
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nike — Football Data Platform</title> <title>Nike — Football Data Platform</title>
<!-- Apply theme before first paint to prevent flash of wrong theme. -->
<script>
try {
const stored = localStorage.getItem('nike-theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (stored !== 'light' && systemDark)) {
document.documentElement.classList.add('dark');
}
} catch (_) {}
</script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -1,5 +1,25 @@
import type { LogsResponse, RunResult, StatusResponse } from './types'; import type { LogsResponse, RunResult, StatusResponse } from './types';
interface TelemetryReport {
type: string;
message: string;
url?: string;
line?: number;
col?: number;
stack?: string;
}
/** Fire-and-forget client-side error report. Never throws. */
export function sendTelemetry(report: TelemetryReport): void {
fetch('/api/v1/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(report),
}).catch(() => {
// telemetry is best-effort — swallow errors silently
});
}
export async function fetchStatus(): Promise<StatusResponse> { export async function fetchStatus(): Promise<StatusResponse> {
const r = await fetch('/api/status'); const r = await fetch('/api/status');
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);

View File

@@ -1,21 +1,91 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte';
import { sendTelemetry } from '$lib/api';
let { children } = $props(); let { children } = $props();
type ThemeOverride = 'dark' | 'light' | null;
let override = $state<ThemeOverride>(null);
let systemDark = $state(true);
// Derived: dark when explicitly set, otherwise follow system.
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
// Keep <html> class in sync with isDark whenever it changes.
$effect(() => {
document.documentElement.classList.toggle('dark', isDark);
});
function toggleTheme() {
const next = !isDark;
// If toggling back to match system, remove the override.
if (next === systemDark) {
override = null;
localStorage.removeItem('nike-theme');
} else {
override = next ? 'dark' : 'light';
localStorage.setItem('nike-theme', override);
}
}
onMount(() => {
// ── Browser error telemetry ────────────────────────────
const onJsError = (e: ErrorEvent) => {
sendTelemetry({
type: 'js_error',
message: e.message,
url: e.filename || window.location.href,
line: e.lineno,
col: e.colno,
stack: e.error?.stack,
});
};
const onRejection = (e: PromiseRejectionEvent) => {
sendTelemetry({
type: 'unhandled_rejection',
message: String(e.reason),
url: window.location.href,
stack: e.reason?.stack,
});
};
window.addEventListener('error', onJsError);
window.addEventListener('unhandledrejection', onRejection);
// ── Theme initialisation ────────────────────────────────
const mq = window.matchMedia('(prefers-color-scheme: dark)');
systemDark = mq.matches;
const stored = localStorage.getItem('nike-theme');
if (stored === 'dark' || stored === 'light') {
override = stored as ThemeOverride;
}
const onSystemChange = (e: MediaQueryListEvent) => {
systemDark = e.matches;
};
mq.addEventListener('change', onSystemChange);
return () => {
window.removeEventListener('error', onJsError);
window.removeEventListener('unhandledrejection', onRejection);
mq.removeEventListener('change', onSystemChange);
};
});
const nav = [ const nav = [
{ href: '/', label: 'Status' }, { href: '/', label: 'Status' },
{ href: '/tools', label: 'Tool Runner' }, { href: '/tools', label: 'Tool Runner' },
]; ];
</script> </script>
<div class="min-h-screen bg-gray-950 text-gray-100"> <div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur sticky top-0 z-10"> <header class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10">
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6"> <div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-green-500 text-lg leading-none"></span> <span class="text-green-500 text-lg leading-none"></span>
<span class="font-semibold text-white tracking-tight">Nike</span> <span class="font-semibold text-gray-900 dark:text-white tracking-tight">Nike</span>
<span class="text-gray-500 text-sm hidden sm:inline">Football Data Platform</span> <span class="text-gray-500 text-sm hidden sm:inline">Football Data Platform</span>
</div> </div>
<nav class="flex gap-1 ml-2"> <nav class="flex gap-1 ml-2">
@@ -25,12 +95,19 @@
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname === class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
item.href item.href
? 'bg-green-700 text-white' ? 'bg-green-700 text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'}" : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
> >
{item.label} {item.label}
</a> </a>
{/each} {/each}
</nav> </nav>
<button
onclick={toggleTheme}
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? 'Light' : 'Dark'}
</button>
</div> </div>
</header> </header>

View File

@@ -83,16 +83,16 @@
<div class="space-y-5"> <div class="space-y-5">
<!-- Title row --> <!-- Title row -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-white">System Status</h1> <h1 class="text-lg font-semibold text-gray-900 dark:text-white">System Status</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if invalidateMsg} {#if invalidateMsg}
<span class="text-sm text-green-400 transition-opacity">{invalidateMsg}</span> <span class="text-sm text-green-600 dark:text-green-400 transition-opacity">{invalidateMsg}</span>
{/if} {/if}
<button <button
onclick={doInvalidate} onclick={doInvalidate}
disabled={invalidating} disabled={invalidating}
class="px-3 py-1.5 rounded bg-gray-800 hover:bg-gray-700 text-sm text-gray-300 class="px-3 py-1.5 rounded bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300
border border-gray-700 disabled:opacity-50 transition-colors" border border-gray-300 dark:border-gray-700 disabled:opacity-50 transition-colors"
> >
{invalidating ? 'Clearing…' : 'Clear Cache'} {invalidating ? 'Clearing…' : 'Clear Cache'}
</button> </button>
@@ -100,7 +100,7 @@
</div> </div>
{#if loadError} {#if loadError}
<div class="p-4 rounded-lg bg-red-950 border border-red-800 text-red-300 text-sm"> <div class="p-4 rounded-lg bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-300 text-sm">
{loadError} {loadError}
</div> </div>
{/if} {/if}
@@ -109,65 +109,65 @@
<!-- Status cards --> <!-- Status cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Database --> <!-- Database -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-3"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.database.connected)}"></span> <span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.database.connected)}"></span>
<span class="font-medium text-sm text-white">Database</span> <span class="font-medium text-sm text-gray-900 dark:text-white">Database</span>
</div> </div>
<dl class="text-sm space-y-1.5"> <dl class="text-sm space-y-1.5">
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Host</dt> <dt class="text-gray-500">Host</dt>
<dd class="text-gray-200">{status.database.host ?? '—'}</dd> <dd class="text-gray-700 dark:text-gray-200">{status.database.host ?? '—'}</dd>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Latency</dt> <dt class="text-gray-500">Latency</dt>
<dd class="text-gray-200">{fmtMs(status.database.latency_ms)}</dd> <dd class="text-gray-700 dark:text-gray-200">{fmtMs(status.database.latency_ms)}</dd>
</div> </div>
{#if status.database.version} {#if status.database.version}
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Version</dt> <dt class="text-gray-500">Version</dt>
<dd class="text-gray-400 text-xs font-mono truncate max-w-40"> <dd class="text-gray-500 dark:text-gray-400 text-xs font-mono truncate max-w-40">
{status.database.version} {status.database.version}
</dd> </dd>
</div> </div>
{/if} {/if}
{#if status.database.error} {#if status.database.error}
<div class="text-red-400 text-xs pt-1">{status.database.error}</div> <div class="text-red-500 dark:text-red-400 text-xs pt-1">{status.database.error}</div>
{/if} {/if}
</dl> </dl>
</div> </div>
<!-- TheSportsDB --> <!-- TheSportsDB -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-3"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.api.connected)}"></span> <span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.api.connected)}"></span>
<span class="font-medium text-sm text-white">TheSportsDB</span> <span class="font-medium text-sm text-gray-900 dark:text-white">TheSportsDB</span>
</div> </div>
<dl class="text-sm space-y-1.5"> <dl class="text-sm space-y-1.5">
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Latency</dt> <dt class="text-gray-500">Latency</dt>
<dd class="text-gray-200">{fmtMs(status.api.latency_ms)}</dd> <dd class="text-gray-700 dark:text-gray-200">{fmtMs(status.api.latency_ms)}</dd>
</div> </div>
{#if status.api.backend} {#if status.api.backend}
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Backend</dt> <dt class="text-gray-500">Backend</dt>
<dd class="text-gray-200">{status.api.backend}</dd> <dd class="text-gray-700 dark:text-gray-200">{status.api.backend}</dd>
</div> </div>
{/if} {/if}
{#if status.api.error} {#if status.api.error}
<div class="text-red-400 text-xs pt-1">{status.api.error}</div> <div class="text-red-500 dark:text-red-400 text-xs pt-1">{status.api.error}</div>
{/if} {/if}
</dl> </dl>
</div> </div>
<!-- MCP Server --> <!-- MCP Server -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-3"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.mcp.running)}"></span> <span class="inline-block w-2.5 h-2.5 rounded-full {dot(status.mcp.running)}"></span>
<span class="font-medium text-sm text-white">MCP Server</span> <span class="font-medium text-sm text-gray-900 dark:text-white">MCP Server</span>
{#if status.mcp.premium} {#if status.mcp.premium}
<span <span
class="ml-auto text-xs px-1.5 py-0.5 rounded bg-amber-900/60 text-amber-300 border border-amber-800" class="ml-auto text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800"
> >
Premium Premium
</span> </span>
@@ -176,19 +176,19 @@
<dl class="text-sm space-y-1.5"> <dl class="text-sm space-y-1.5">
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Transport</dt> <dt class="text-gray-500">Transport</dt>
<dd class="text-gray-200">{status.mcp.transport}</dd> <dd class="text-gray-700 dark:text-gray-200">{status.mcp.transport}</dd>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Uptime</dt> <dt class="text-gray-500">Uptime</dt>
<dd class="text-gray-200">{status.mcp.uptime}</dd> <dd class="text-gray-700 dark:text-gray-200">{status.mcp.uptime}</dd>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-500">Tools</dt> <dt class="text-gray-500">Tools</dt>
<dd class="text-gray-200">{status.mcp.tool_count}</dd> <dd class="text-gray-700 dark:text-gray-200">{status.mcp.tool_count}</dd>
</div> </div>
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<dt class="text-gray-500 shrink-0">Endpoint</dt> <dt class="text-gray-500 shrink-0">Endpoint</dt>
<dd class="text-gray-400 text-xs font-mono truncate" title={status.mcp.endpoint}> <dd class="text-gray-500 dark:text-gray-400 text-xs font-mono truncate" title={status.mcp.endpoint}>
{status.mcp.endpoint} {status.mcp.endpoint}
</dd> </dd>
</div> </div>
@@ -199,7 +199,7 @@
<!-- Followed teams + MCP tools --> <!-- Followed teams + MCP tools -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Followed teams --> <!-- Followed teams -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3"> <h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Followed Teams Followed Teams
</h2> </h2>
@@ -210,39 +210,39 @@
{#each status.data.followed as team} {#each status.data.followed as team}
<li class="flex items-center gap-2 text-sm"> <li class="flex items-center gap-2 text-sm">
<span class="text-green-500 text-xs"></span> <span class="text-green-500 text-xs"></span>
<span class="text-white">{team.team}</span> <span class="text-gray-900 dark:text-white">{team.team}</span>
<span class="text-gray-700">·</span> <span class="text-gray-300 dark:text-gray-700">·</span>
<span class="text-gray-400">{team.league}</span> <span class="text-gray-500 dark:text-gray-400">{team.league}</span>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if status.data.last_cache} {#if status.data.last_cache}
<p class="mt-3 text-xs text-gray-600"> <p class="mt-3 text-xs text-gray-500">
Last cache update: {relTime(status.data.last_cache)} Last cache update: {relTime(status.data.last_cache)}
</p> </p>
{/if} {/if}
</div> </div>
<!-- MCP Tools --> <!-- MCP Tools -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">MCP Tools</h2> <h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">MCP Tools</h2>
<table class="w-full text-sm"> <table class="w-full text-sm">
<tbody class="divide-y divide-gray-800/60"> <tbody class="divide-y divide-gray-100 dark:divide-gray-800/60">
{#each status.tools as tool} {#each status.tools as tool}
<tr> <tr>
<td class="py-1.5 pr-3"> <td class="py-1.5 pr-3">
<code class="text-green-300 text-xs">{tool.name}</code> <code class="text-green-600 dark:text-green-300 text-xs">{tool.name}</code>
{#if tool.premium} {#if tool.premium}
<span <span
class="ml-1.5 text-xs px-1 rounded bg-amber-900/50 text-amber-400 border border-amber-800/50" class="ml-1.5 text-xs px-1 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800/50"
title="Requires premium TheSportsDB key" title="Requires premium TheSportsDB key"
> >
</span> </span>
{/if} {/if}
</td> </td>
<td class="py-1.5 text-gray-400">{tool.description}</td> <td class="py-1.5 text-gray-500 dark:text-gray-400">{tool.description}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -252,7 +252,7 @@
<!-- DB table counts --> <!-- DB table counts -->
{#if Object.keys(status.data.table_counts).length > 0} {#if Object.keys(status.data.table_counts).length > 0}
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3"> <h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Database Contents Database Contents
</h2> </h2>
@@ -260,7 +260,7 @@
{#each Object.entries(status.data.table_counts) as [table, count]} {#each Object.entries(status.data.table_counts) as [table, count]}
<div> <div>
<div class="text-xs text-gray-500">{table}</div> <div class="text-xs text-gray-500">{table}</div>
<div class="text-white font-mono text-sm mt-0.5">{count.toLocaleString()}</div> <div class="text-gray-900 dark:text-white font-mono text-sm mt-0.5">{count.toLocaleString()}</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -271,12 +271,12 @@
{/if} {/if}
<!-- Request Log --> <!-- Request Log -->
<div class="rounded-lg bg-gray-900 border border-gray-800"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
<div <div
class="px-4 py-3 border-b border-gray-800 flex items-center justify-between" class="px-4 py-3 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between"
> >
<h2 class="text-sm font-medium text-white">Request Log</h2> <h2 class="text-sm font-medium text-gray-900 dark:text-white">Request Log</h2>
<span class="text-xs text-gray-600">{logs.length} entries · auto-refreshes every 5 s</span> <span class="text-xs text-gray-500">{logs.length} entries · auto-refreshes every 5 s</span>
</div> </div>
{#if logs.length === 0} {#if logs.length === 0}
<p class="px-4 py-5 text-gray-500 text-sm">No MCP requests yet.</p> <p class="px-4 py-5 text-gray-500 text-sm">No MCP requests yet.</p>
@@ -284,23 +284,23 @@
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-left text-xs text-gray-600 border-b border-gray-800"> <tr class="text-left text-xs text-gray-500 dark:text-gray-600 border-b border-gray-200 dark:border-gray-800">
<th class="px-4 py-2 font-medium">Time</th> <th class="px-4 py-2 font-medium">Time</th>
<th class="px-4 py-2 font-medium">Tool</th> <th class="px-4 py-2 font-medium">Tool</th>
<th class="px-4 py-2 font-medium">Args</th> <th class="px-4 py-2 font-medium">Args</th>
<th class="px-4 py-2 font-medium text-right">Duration</th> <th class="px-4 py-2 font-medium text-right">Duration</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-800/40"> <tbody class="divide-y divide-gray-100/40 dark:divide-gray-800/40">
{#each logs as entry} {#each logs as entry}
<tr class="hover:bg-gray-800/40 transition-colors"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td class="px-4 py-2 text-gray-500 whitespace-nowrap text-xs"> <td class="px-4 py-2 text-gray-500 whitespace-nowrap text-xs">
{relTime(entry.timestamp)} {relTime(entry.timestamp)}
</td> </td>
<td class="px-4 py-2"> <td class="px-4 py-2">
<code class="text-green-300 text-xs">{entry.tool}</code> <code class="text-green-600 dark:text-green-300 text-xs">{entry.tool}</code>
</td> </td>
<td class="px-4 py-2 text-gray-400 text-xs font-mono max-w-xs truncate"> <td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs font-mono max-w-xs truncate">
{fmtArgs(entry.args)} {fmtArgs(entry.args)}
</td> </td>
<td class="px-4 py-2 text-right text-gray-500 whitespace-nowrap text-xs"> <td class="px-4 py-2 text-right text-gray-500 whitespace-nowrap text-xs">

View File

@@ -178,8 +178,8 @@
<div class="space-y-5"> <div class="space-y-5">
<div> <div>
<h1 class="text-lg font-semibold text-white">Tool Runner</h1> <h1 class="text-lg font-semibold text-gray-900 dark:text-white">Tool Runner</h1>
<p class="text-sm text-gray-400 mt-1"> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API
results. results.
</p> </p>
@@ -189,7 +189,7 @@
<!-- Left: selector + form + result --> <!-- Left: selector + form + result -->
<div class="lg:col-span-2 space-y-4"> <div class="lg:col-span-2 space-y-4">
<!-- Tool selector --> <!-- Tool selector -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Select Tool</h2> <h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Select Tool</h2>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each TOOLS as tool} {#each TOOLS as tool}
@@ -198,11 +198,11 @@
class="px-3 py-1.5 rounded text-sm font-medium transition-colors class="px-3 py-1.5 rounded text-sm font-medium transition-colors
{selectedTool.name === tool.name {selectedTool.name === tool.name
? 'bg-green-700 text-white' ? 'bg-green-700 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-700'}" : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700'}"
> >
{tool.name} {tool.name}
{#if tool.premium} {#if tool.premium}
<span use:melt={$premTrigger} class="ml-1 text-amber-400 cursor-help"></span> <span use:melt={$premTrigger} class="ml-1 text-amber-500 dark:text-amber-400 cursor-help"></span>
{/if} {/if}
</button> </button>
{/each} {/each}
@@ -210,34 +210,34 @@
</div> </div>
<!-- Parameter form --> <!-- Parameter form -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-4"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-4">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-white font-mono">{selectedTool.name}</h2> <h2 class="text-sm font-semibold text-gray-900 dark:text-white font-mono">{selectedTool.name}</h2>
{#if selectedTool.premium} {#if selectedTool.premium}
<span <span
class="text-xs px-1.5 py-0.5 rounded bg-amber-900/60 text-amber-300 border border-amber-800/50" class="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50"
> >
Premium Premium
</span> </span>
{/if} {/if}
</div> </div>
<p class="text-xs text-gray-400 mt-1">{selectedTool.description}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{selectedTool.description}</p>
</div> </div>
{#if selectedTool.params.length > 0} {#if selectedTool.params.length > 0}
<div class="space-y-3"> <div class="space-y-3">
{#each selectedTool.params as param} {#each selectedTool.params as param}
<div> <div>
<label class="block text-xs font-medium text-gray-400 mb-1.5" for={param.key}> <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5" for={param.key}>
{param.label} {param.label}
</label> </label>
{#if param.type === 'select'} {#if param.type === 'select'}
<select <select
id={param.key} id={param.key}
bind:value={paramValues[param.key]} bind:value={paramValues[param.key]}
class="w-full rounded-md bg-gray-800 border border-gray-700 px-3 py-2 text-sm class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
text-gray-100 focus:outline-none focus:border-green-600 focus:ring-1 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-green-600 focus:ring-1
focus:ring-green-600" focus:ring-green-600"
> >
{#each param.options ?? [] as opt} {#each param.options ?? [] as opt}
@@ -250,8 +250,8 @@
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'date' : 'text'} type={param.type === 'number' ? 'number' : param.type === 'date' ? 'date' : 'text'}
placeholder={param.placeholder ?? ''} placeholder={param.placeholder ?? ''}
bind:value={paramValues[param.key]} bind:value={paramValues[param.key]}
class="w-full rounded-md bg-gray-800 border border-gray-700 px-3 py-2 text-sm class="w-full rounded-md bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm
text-gray-100 placeholder-gray-600 focus:outline-none focus:border-green-600 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-green-600
focus:ring-1 focus:ring-green-600" focus:ring-1 focus:ring-green-600"
/> />
{/if} {/if}
@@ -280,26 +280,26 @@
<!-- Result --> <!-- Result -->
{#if result !== null || resultError !== null} {#if result !== null || resultError !== null}
<div <div
class="rounded-lg bg-gray-900 border {resultError class="rounded-lg bg-white dark:bg-gray-900 border {resultError
? 'border-red-800' ? 'border-red-200 dark:border-red-800'
: 'border-gray-800'}" : 'border-gray-200 dark:border-gray-800'}"
> >
<div <div
class="px-4 py-2.5 border-b {resultError class="px-4 py-2.5 border-b {resultError
? 'border-red-800' ? 'border-red-200 dark:border-red-800'
: 'border-gray-800'} flex items-center gap-2" : 'border-gray-200 dark:border-gray-800'} flex items-center gap-2"
> >
<span class="text-xs font-medium {resultError ? 'text-red-400' : 'text-green-400'}"> <span class="text-xs font-medium {resultError ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
{resultError ? 'Error' : 'Result'} {resultError ? 'Error' : 'Result'}
</span> </span>
{#if result} {#if result}
<span class="text-xs text-gray-600"> <span class="text-xs text-gray-500">
{result.split('\n').length} lines {result.split('\n').length} lines
</span> </span>
{/if} {/if}
</div> </div>
<pre <pre
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap text-gray-200 overflow-x-auto class="px-4 py-4 text-sm font-mono whitespace-pre-wrap text-gray-700 dark:text-gray-200 overflow-x-auto
max-h-[520px] overflow-y-auto leading-relaxed" max-h-[520px] overflow-y-auto leading-relaxed"
>{resultError ?? result}</pre> >{resultError ?? result}</pre>
</div> </div>
@@ -307,30 +307,30 @@
</div> </div>
<!-- Right: session history --> <!-- Right: session history -->
<div class="rounded-lg bg-gray-900 border border-gray-800 sticky top-20"> <div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 sticky top-20">
<div class="px-4 py-3 border-b border-gray-800"> <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider">Session History</h2> <h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider">Session History</h2>
</div> </div>
{#if history.length === 0} {#if history.length === 0}
<p class="px-4 py-5 text-sm text-gray-500">No queries yet.</p> <p class="px-4 py-5 text-sm text-gray-500">No queries yet.</p>
{:else} {:else}
<ul class="divide-y divide-gray-800 max-h-[600px] overflow-y-auto"> <ul class="divide-y divide-gray-100 dark:divide-gray-800 max-h-[600px] overflow-y-auto">
{#each history as entry} {#each history as entry}
<li> <li>
<button <button
onclick={() => loadHistory(entry)} onclick={() => loadHistory(entry)}
class="w-full text-left px-4 py-3 hover:bg-gray-800/50 transition-colors" class="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<code class="text-xs {entry.ok ? 'text-green-300' : 'text-red-400'} truncate"> <code class="text-xs {entry.ok ? 'text-green-600 dark:text-green-300' : 'text-red-500 dark:text-red-400'} truncate">
{entry.tool} {entry.tool}
</code> </code>
<span class="text-xs text-gray-600 shrink-0">{fmtTime(entry.ts)}</span> <span class="text-xs text-gray-500 shrink-0">{fmtTime(entry.ts)}</span>
</div> </div>
{#each Object.entries(entry.args) as [k, v]} {#each Object.entries(entry.args) as [k, v]}
{#if v} {#if v}
<div class="text-xs text-gray-500 truncate mt-0.5"> <div class="text-xs text-gray-500 truncate mt-0.5">
{k}: <span class="text-gray-400">{v}</span> {k}: <span class="text-gray-500 dark:text-gray-400">{v}</span>
</div> </div>
{/if} {/if}
{/each} {/each}
@@ -350,7 +350,7 @@
{#if $premOpen} {#if $premOpen}
<div <div
use:melt={$premContent} use:melt={$premContent}
class="z-50 rounded bg-gray-800 border border-gray-700 px-2.5 py-1.5 text-xs text-amber-300 shadow-lg" class="z-50 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2.5 py-1.5 text-xs text-amber-700 dark:text-amber-300 shadow-lg"
> >
Requires a premium TheSportsDB key Requires a premium TheSportsDB key
</div> </div>

View File

@@ -6,3 +6,9 @@ services:
env_file: env_file:
- .env - .env
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${NIKE_PORT:-8000}/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View File

@@ -235,8 +235,6 @@ Place documentation in the `/docs/` directory of the repository.
HTML documents must follow [docs/documentation_style_guide.html](documentation_style_guide.html). HTML documents must follow [docs/documentation_style_guide.html](documentation_style_guide.html).
- Use Bootstrap CDN with Bootswatch theme **Flatly** - Include a dark mode that follows the system automatically and include a toggle button in the navbar
- Include a dark mode toggle button in the navbar - avoid custom CSS
- Use Bootstrap Icons for icons
- Use Bootstrap CSS for styles — avoid custom CSS
- Use **Mermaid** for diagrams - Use **Mermaid** for diagrams

View File

@@ -9,14 +9,14 @@ _ENV_PATH = Path(__file__).resolve().parent.parent / '.env'
load_dotenv(_ENV_PATH) load_dotenv(_ENV_PATH)
# ── Database ────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────
DB_HOST = os.getenv('DB_HOST', 'portia.incus') DB_HOST = os.getenv('NIKE_DB_HOST', 'portia.incus')
DB_PORT = int(os.getenv('DB_PORT', 5432)) DB_PORT = int(os.getenv('NIKE_DB_PORT', 5432))
DB_USER = os.getenv('DB_USER', 'nike') DB_USER = os.getenv('NIKE_DB_USER', 'nike')
DB_PASSWORD = os.getenv('DB_PASSWORD', '') DB_PASSWORD = os.getenv('NIKE_DB_PASSWORD', '')
DB_NAME = os.getenv('DB_NAME', 'nike') DB_NAME = os.getenv('NIKE_DB_NAME', 'nike')
# ── TheSportsDB ─────────────────────────────────────────── # ── TheSportsDB ───────────────────────────────────────────
SPORTSDB_KEY = os.getenv('SPORTSDB_KEY', '3') # '3' = free test key SPORTSDB_KEY = os.getenv('NIKE_SPORTSDB_KEY', '3') # '3' = free test key
SPORTSDB_V2 = "https://www.thesportsdb.com/api/v2/json" SPORTSDB_V2 = "https://www.thesportsdb.com/api/v2/json"
SPORTSDB_V1 = f"https://www.thesportsdb.com/api/v1/json/{SPORTSDB_KEY}" SPORTSDB_V1 = f"https://www.thesportsdb.com/api/v1/json/{SPORTSDB_KEY}"
@@ -29,8 +29,8 @@ FOLLOWED_TEAMS = [
] ]
# ── Legacy API keys (preserved, not active) ─────────────── # ── Legacy API keys (preserved, not active) ───────────────
RAPIDAPI_KEY = os.getenv('RAPIDAPI_KEY', '') RAPIDAPI_KEY = os.getenv('NIKE_RAPIDAPI_KEY', '')
API_FOOTBALL_KEY = os.getenv('API_FOOTBALL_KEY', '') API_FOOTBALL_KEY = os.getenv('NIKE_API_FOOTBALL_KEY', '')
# ── Server ──────────────────────────────────────────────── # ── Server ────────────────────────────────────────────────
SERVER_HOST = os.getenv('NIKE_HOST', '0.0.0.0') SERVER_HOST = os.getenv('NIKE_HOST', '0.0.0.0')

View File

@@ -10,6 +10,7 @@ Caching strategy:
- cache_meta: TTL-aware freshness checks for volatile data - cache_meta: TTL-aware freshness checks for volatile data
""" """
import json import json
import logging
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -21,6 +22,8 @@ import psycopg2.extras
from nike import config from nike import config
logger = logging.getLogger("nike.db")
_pool: psycopg2.pool.ThreadedConnectionPool | None = None _pool: psycopg2.pool.ThreadedConnectionPool | None = None
@@ -85,9 +88,11 @@ def check_connection() -> dict:
version = cur.fetchone()[0].split(',')[0] version = cur.fetchone()[0].split(',')[0]
cur.close() cur.close()
conn.close() conn.close()
return {"connected": True, "latency_ms": latency_ms, "version": version} return {"connected": True, "latency_ms": latency_ms, "version": version,
"host": config.DB_HOST}
except Exception as e: except Exception as e:
return {"connected": False, "latency_ms": None, "version": None, "error": str(e)} return {"connected": False, "latency_ms": None, "version": None,
"host": config.DB_HOST, "error": str(e)}
def get_table_counts() -> dict[str, int]: def get_table_counts() -> dict[str, int]:
@@ -109,6 +114,7 @@ def get_table_counts() -> dict[str, int]:
counts[t] = -1 counts[t] = -1
cur.close() cur.close()
except Exception: except Exception:
logger.debug("get_table_counts failed", exc_info=True)
counts = {t: -1 for t in tables} counts = {t: -1 for t in tables}
return counts return counts
@@ -131,6 +137,7 @@ def get_last_cache_time() -> str | None:
cur.close() cur.close()
return row[0].isoformat() if row and row[0] else None return row[0].isoformat() if row and row[0] else None
except Exception: except Exception:
logger.debug("get_last_cache_time failed", exc_info=True)
return None return None
@@ -176,6 +183,7 @@ def is_cache_fresh(cache_key: str) -> bool:
age_s = (datetime.now(timezone.utc) - fetched_at).total_seconds() age_s = (datetime.now(timezone.utc) - fetched_at).total_seconds()
return age_s < ttl return age_s < ttl
except Exception: except Exception:
logger.debug("is_cache_fresh failed for key=%s", cache_key, exc_info=True)
return False return False
@@ -200,6 +208,7 @@ def get_cached_json(cache_key: str) -> Any | None:
return None return None
return data return data
except Exception: except Exception:
logger.debug("get_cached_json failed for key=%s", cache_key, exc_info=True)
return None return None
@@ -218,7 +227,7 @@ def set_cache_meta(cache_key: str, ttl_seconds: int = 3600,
""", (cache_key, ttl_seconds, data_json, ttl_seconds, data_json)) """, (cache_key, ttl_seconds, data_json, ttl_seconds, data_json))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("set_cache_meta failed for key=%s", cache_key, exc_info=True)
def invalidate_cache(pattern: str = "%") -> int: def invalidate_cache(pattern: str = "%") -> int:
@@ -231,6 +240,7 @@ def invalidate_cache(pattern: str = "%") -> int:
cur.close() cur.close()
return count return count
except Exception: except Exception:
logger.debug("invalidate_cache failed for pattern=%s", pattern, exc_info=True)
return 0 return 0
@@ -269,7 +279,7 @@ def cache_league(league: dict) -> None:
)) ))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("cache_league failed for id=%s", lid, exc_info=True)
def cache_team(team: dict) -> None: def cache_team(team: dict) -> None:
@@ -336,7 +346,7 @@ def cache_team(team: dict) -> None:
)) ))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("cache_team failed for id=%s", tid, exc_info=True)
def cache_player(player: dict) -> None: def cache_player(player: dict) -> None:
@@ -388,7 +398,7 @@ def cache_player(player: dict) -> None:
)) ))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("cache_player failed for id=%s", pid, exc_info=True)
def cache_event(event: dict) -> None: def cache_event(event: dict) -> None:
@@ -466,7 +476,7 @@ def cache_event(event: dict) -> None:
)) ))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("cache_event failed for id=%s", eid, exc_info=True)
def cache_event_stats(event_id: int, stats: list[dict]) -> None: def cache_event_stats(event_id: int, stats: list[dict]) -> None:
@@ -494,7 +504,7 @@ def cache_event_stats(event_id: int, stats: list[dict]) -> None:
)) ))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("cache_event_stats failed for event_id=%s", event_id, exc_info=True)
def cache_event_timeline(event_id: int, timeline: list[dict]) -> None: def cache_event_timeline(event_id: int, timeline: list[dict]) -> None:
@@ -536,7 +546,7 @@ def cache_event_timeline(event_id: int, timeline: list[dict]) -> None:
)) ))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("cache_event_timeline failed for event_id=%s", event_id, exc_info=True)
def cache_event_lineup(event_id: int, lineup: list[dict]) -> None: def cache_event_lineup(event_id: int, lineup: list[dict]) -> None:
@@ -574,7 +584,7 @@ def cache_event_lineup(event_id: int, lineup: list[dict]) -> None:
)) ))
cur.close() cur.close()
except Exception: except Exception:
pass logger.debug("cache_event_lineup failed for event_id=%s", event_id, exc_info=True)
# ══════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════
@@ -599,6 +609,7 @@ def query_team(team_name: str) -> dict | None:
cur.close() cur.close()
return rows[0] if rows else None return rows[0] if rows else None
except Exception: except Exception:
logger.debug("query_team failed for name=%r", team_name, exc_info=True)
return None return None
@@ -618,6 +629,7 @@ def query_roster(team_id: int) -> list[dict]:
cur.close() cur.close()
return rows return rows
except Exception: except Exception:
logger.debug("query_roster failed for team_id=%s", team_id, exc_info=True)
return [] return []
@@ -638,18 +650,22 @@ def query_player_by_id(player_id: int) -> dict | None:
cur.close() cur.close()
return rows[0] if rows else None return rows[0] if rows else None
except Exception: except Exception:
logger.debug("query_player_by_id failed for id=%s", player_id, exc_info=True)
return None return None
# Whitelisted status clauses — values are (sql_fragment, bound_params).
# Using a dict prevents SQL injection if the caller passes an unexpected status.
_STATUS_CLAUSES: dict[str, tuple[str, tuple]] = {
'finished': ("AND status = %s", ('Match Finished',)),
'upcoming': ("AND status = %s", ('Not Started',)),
'today': ("AND event_date = CURRENT_DATE", ()),
}
def query_events_for_team(team_id: int, status: str = 'all') -> list[dict]: def query_events_for_team(team_id: int, status: str = 'all') -> list[dict]:
"""Return cached events for a team.""" """Return cached events for a team."""
status_filter = "" clause, extra_params = _STATUS_CLAUSES.get(status, ("", ()))
if status == 'finished':
status_filter = "AND status = 'Match Finished'"
elif status == 'upcoming':
status_filter = "AND status = 'Not Started'"
elif status == 'today':
status_filter = "AND event_date = CURRENT_DATE"
try: try:
with get_conn() as conn: with get_conn() as conn:
@@ -661,13 +677,14 @@ def query_events_for_team(team_id: int, status: str = 'all') -> list[dict]:
spectators, status spectators, status
FROM events FROM events
WHERE (home_team_id = %s OR away_team_id = %s) WHERE (home_team_id = %s OR away_team_id = %s)
{status_filter} {clause}
ORDER BY event_date DESC, event_time DESC ORDER BY event_date DESC, event_time DESC
""", (team_id, team_id)) """, (team_id, team_id) + extra_params)
rows = _rows_as_dicts(cur) rows = _rows_as_dicts(cur)
cur.close() cur.close()
return rows return rows
except Exception: except Exception:
logger.debug("query_events_for_team failed for team_id=%s", team_id, exc_info=True)
return [] return []
@@ -683,4 +700,5 @@ def query_event(event_id: int) -> dict | None:
cur.close() cur.close()
return rows[0] if rows else None return rows[0] if rows else None
except Exception: except Exception:
logger.debug("query_event failed for event_id=%s", event_id, exc_info=True)
return None return None

View File

@@ -810,6 +810,7 @@ async def api_status():
{"name": "get_livescores", "description": "Live scores", "readonly": True, {"name": "get_livescores", "description": "Live scores", "readonly": True,
"premium": True}, "premium": True},
] ]
db_status.setdefault("host", config.DB_HOST)
return JSONResponse({ return JSONResponse({
"database": db_status, "database": db_status,
"api": api_conn, "api": api_conn,
@@ -890,6 +891,21 @@ class _RunRequest(BaseModel):
args: dict[str, Any] = {} args: dict[str, Any] = {}
class _TelemetryReport(BaseModel):
type: str
message: str
url: str = ""
line: int | None = None
col: int | None = None
stack: str | None = None
# ── Note on authentication ─────────────────────────────────
# The /api/* routes below are NOT individually authenticated. Nike is expected
# to sit behind HAProxy, which enforces access control at the edge. If Nike is
# exposed directly (without HAProxy), these write endpoints should be protected.
@dashboard.post("/api/run") @dashboard.post("/api/run")
async def api_run(body: _RunRequest): async def api_run(body: _RunRequest):
fn = _TOOLS.get(body.tool) fn = _TOOLS.get(body.tool)
@@ -903,6 +919,19 @@ async def api_run(body: _RunRequest):
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500) return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@dashboard.post("/api/v1/telemetry")
async def api_telemetry(body: _TelemetryReport, request: Request):
"""Accept client-side browser error reports. Unprotected per Red Panda Standards."""
ua = request.headers.get("user-agent", "unknown")
logger.warning(
"browser error type=%s msg=%r url=%s ua=%s",
body.type, body.message, body.url, ua,
)
if body.stack:
logger.warning("browser error stack: %s", body.stack[:1000])
return JSONResponse({"ok": True})
# ── Health endpoints ────────────────────────────────────── # ── Health endpoints ──────────────────────────────────────