feat: add dark mode, telemetry, and curl to Docker image
- 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:
30
.env.example
Normal file
30
.env.example
Normal 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=
|
||||||
@@ -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 .
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
56
nike/db.py
56
nike/db.py
@@ -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
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user