feat: initialize Nike Dashboard with SvelteKit and TailwindCSS

- Add package.json for project dependencies and scripts
- Create app.css with TailwindCSS imports and theme variables
- Set up basic HTML structure in app.html
- Implement API functions in api.ts for fetching status, logs, and running tools
- Define TypeScript interfaces in types.ts for API responses and logs
- Create layout component with navigation and main content area
- Disable SSR and prerendering in layout.ts
- Build main status page with real-time updates and logs
- Develop tool runner page for executing MCP tools with parameters
- Add favicon.svg for branding
- Configure SvelteKit adapter for static site generation
- Set up TypeScript configuration for the project
- Configure Vite with TailwindCSS and API proxy settings
- Create Docker Compose file for containerized deployment
This commit is contained in:
2026-03-28 17:26:40 +00:00
parent ee8436d5b8
commit 482657492d
23 changed files with 3209 additions and 4 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
venv/
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.env
*.log
.git/

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
venv/ venv/
__pycache__/ __pycache__/
dashboard/build/
dashboard/.svelte-kit/
dashboard/node_modules/
*.pyc *.pyc
*.pyo *.pyo
*.pyd *.pyd

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir -e .
COPY . .
EXPOSE 8000
CMD ["python", "run.py"]

View File

@@ -14,7 +14,7 @@ Queries live data from [free-api-live-football-data](https://rapidapi.com/Creati
│ │ │ │
│ GET / → Bootstrap dashboard (dashboard.html)│ │ GET / → Bootstrap dashboard (dashboard.html)│
│ GET /api/* → Dashboard JSON API │ │ GET /api/* → Dashboard JSON API │
│ /mcp → FastMCP HTTP (streamable) │ │ /mcp/ → FastMCP HTTP (streamable) │
└──────────┬──────────────────────────────────────────────┘ └──────────┬──────────────────────────────────────────────┘
nike/rapidapi.py nike/rapidapi.py
@@ -60,6 +60,41 @@ Queries live data from [free-api-live-football-data](https://rapidapi.com/Creati
--- ---
## Dashboard
The web dashboard is a SvelteKit 2 / Svelte 5 / Tailwind CSS 4 app in `dashboard/`.
| Route | Description |
|-------|-------------|
| `/` | System status: DB, API, MCP health cards; followed teams; tools list; request log |
| `/tools` | Interactive tool runner — pick a tool, fill in parameters, inspect raw output |
### Build (required before serving via FastAPI)
```bash
cd dashboard
npm install
npm run build # outputs to dashboard/build/
```
Once built, `python run.py` serves the SvelteKit app at `http://<host>:{PORT}/`.
### Development (live reload)
Run the FastAPI backend and the SvelteKit dev server in separate terminals:
```bash
# Terminal 1 — backend
python run.py
# Terminal 2 — frontend (proxies /api and /mcp to localhost:8000)
cd dashboard && npm run dev
```
The dev dashboard is at `http://localhost:5173`.
---
## Setup ## Setup
### Prerequisites ### Prerequisites
@@ -118,14 +153,14 @@ sudo systemctl enable --now nike
## MCP Client Configuration ## MCP Client Configuration
Nike exposes a Streamable HTTP MCP endpoint at `/mcp`. To connect an MCP client (e.g. Claude Desktop, Cline, or any MCP-compatible tool), add the following to your client's MCP server configuration: Nike exposes a Streamable HTTP MCP endpoint at `/mcp/`. To connect an MCP client (e.g. Claude Desktop, Cline, or any MCP-compatible tool), add the following to your client's MCP server configuration:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"nike": { "nike": {
"type": "streamable-http", "type": "streamable-http",
"url": "http://<host>:{PORT}/mcp" "url": "http://<host>:{PORT}/mcp/"
} }
} }
} }

3
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.svelte-kit
build
node_modules

2174
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
dashboard/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "nike-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3",
"svelte": "^5.25.3",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",
"typescript": "^5.8.3",
"vite": "^6.2.5"
},
"dependencies": {
"@melt-ui/svelte": "^0.83.0"
}
}

6
dashboard/src/app.css Normal file
View File

@@ -0,0 +1,6 @@
@import "tailwindcss";
@theme {
--color-pitch: #16a34a;
--color-pitch-dark: #15803d;
}

13
dashboard/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nike — Football Data Platform</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
</html>

32
dashboard/src/lib/api.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { LogsResponse, RunResult, StatusResponse } from './types';
export async function fetchStatus(): Promise<StatusResponse> {
const r = await fetch('/api/status');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
export async function fetchLogs(limit = 50): Promise<LogsResponse> {
const r = await fetch(`/api/logs?limit=${limit}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
export async function invalidateCache(): Promise<{ cache_meta_cleared: number }> {
const r = await fetch('/api/cache/invalidate', { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
export async function runTool(tool: string, args: Record<string, unknown>): Promise<RunResult> {
const r = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool, args }),
});
if (!r.ok) {
const text = await r.text();
return { ok: false, error: text };
}
return r.json();
}

View File

@@ -0,0 +1,63 @@
export interface DatabaseStatus {
connected: boolean;
host?: string;
latency_ms?: number;
version?: string;
error?: string;
}
export interface ApiStatus {
connected: boolean;
latency_ms?: number;
backend?: string;
error?: string;
}
export interface McpStatus {
running: boolean;
transport: string;
endpoint: string;
port: number;
uptime: string;
tool_count: number;
premium: boolean;
}
export interface DataStatus {
table_counts: Record<string, number>;
last_cache: string | null;
followed: Array<{ team: string; league: string }>;
}
export interface Tool {
name: string;
description: string;
readonly: boolean;
premium?: boolean;
}
export interface StatusResponse {
database: DatabaseStatus;
api: ApiStatus;
mcp: McpStatus;
data: DataStatus;
tools: Tool[];
}
export interface LogEntry {
tool: string;
args: Record<string, unknown>;
duration_ms: number;
caller: string;
timestamp: string;
}
export interface LogsResponse {
logs: LogEntry[];
}
export interface RunResult {
ok: boolean;
result?: string;
error?: string;
}

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
let { children } = $props();
const nav = [
{ href: '/', label: 'Status' },
{ href: '/tools', label: 'Tool Runner' },
];
</script>
<div class="min-h-screen bg-gray-950 text-gray-100">
<header class="border-b border-gray-800 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="flex items-center gap-2">
<span class="text-green-500 text-lg leading-none"></span>
<span class="font-semibold text-white tracking-tight">Nike</span>
<span class="text-gray-500 text-sm hidden sm:inline">Football Data Platform</span>
</div>
<nav class="flex gap-1 ml-2">
{#each nav as item}
<a
href={item.href}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
item.href
? 'bg-green-700 text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'}"
>
{item.label}
</a>
{/each}
</nav>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6">
{@render children()}
</main>
</div>

View File

@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;

View File

@@ -0,0 +1,316 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { fetchLogs, fetchStatus, invalidateCache } from '$lib/api';
import type { LogEntry, StatusResponse } from '$lib/types';
let status = $state<StatusResponse | null>(null);
let logs = $state<LogEntry[]>([]);
let loadError = $state<string | null>(null);
let invalidating = $state(false);
let invalidateMsg = $state<string | null>(null);
async function loadStatus() {
try {
status = await fetchStatus();
loadError = null;
} catch (e) {
loadError = String(e);
}
}
async function loadLogs() {
try {
const r = await fetchLogs(50);
logs = r.logs;
} catch {
// silently retry next tick
}
}
async function doInvalidate() {
invalidating = true;
try {
const r = await invalidateCache();
invalidateMsg = `Cache cleared (${r.cache_meta_cleared} DB entries)`;
setTimeout(() => (invalidateMsg = null), 4000);
await loadStatus();
} catch (e) {
invalidateMsg = `Error: ${e}`;
setTimeout(() => (invalidateMsg = null), 4000);
} finally {
invalidating = false;
}
}
let statusTimer: ReturnType<typeof setInterval>;
let logTimer: ReturnType<typeof setInterval>;
onMount(() => {
loadStatus();
loadLogs();
statusTimer = setInterval(loadStatus, 30_000);
logTimer = setInterval(loadLogs, 5_000);
});
onDestroy(() => {
clearInterval(statusTimer);
clearInterval(logTimer);
});
function dot(connected: boolean | undefined) {
return connected ? 'bg-green-500 shadow-green-500/50 shadow-sm' : 'bg-red-500';
}
function fmtMs(ms: number | undefined | null) {
if (ms == null) return '—';
return `${ms.toFixed(0)} ms`;
}
function relTime(iso: string) {
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
function fmtArgs(args: Record<string, unknown>) {
const pairs = Object.entries(args).map(([k, v]) => `${k}=${JSON.stringify(v)}`);
return pairs.join(', ') || '—';
}
</script>
<div class="space-y-5">
<!-- Title row -->
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-white">System Status</h1>
<div class="flex items-center gap-3">
{#if invalidateMsg}
<span class="text-sm text-green-400 transition-opacity">{invalidateMsg}</span>
{/if}
<button
onclick={doInvalidate}
disabled={invalidating}
class="px-3 py-1.5 rounded bg-gray-800 hover:bg-gray-700 text-sm text-gray-300
border border-gray-700 disabled:opacity-50 transition-colors"
>
{invalidating ? 'Clearing…' : 'Clear Cache'}
</button>
</div>
</div>
{#if loadError}
<div class="p-4 rounded-lg bg-red-950 border border-red-800 text-red-300 text-sm">
{loadError}
</div>
{/if}
{#if status}
<!-- Status cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Database -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-3">
<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="font-medium text-sm text-white">Database</span>
</div>
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Host</dt>
<dd class="text-gray-200">{status.database.host ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Latency</dt>
<dd class="text-gray-200">{fmtMs(status.database.latency_ms)}</dd>
</div>
{#if status.database.version}
<div class="flex justify-between">
<dt class="text-gray-500">Version</dt>
<dd class="text-gray-400 text-xs font-mono truncate max-w-40">
{status.database.version}
</dd>
</div>
{/if}
{#if status.database.error}
<div class="text-red-400 text-xs pt-1">{status.database.error}</div>
{/if}
</dl>
</div>
<!-- TheSportsDB -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-3">
<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="font-medium text-sm text-white">TheSportsDB</span>
</div>
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Latency</dt>
<dd class="text-gray-200">{fmtMs(status.api.latency_ms)}</dd>
</div>
{#if status.api.backend}
<div class="flex justify-between">
<dt class="text-gray-500">Backend</dt>
<dd class="text-gray-200">{status.api.backend}</dd>
</div>
{/if}
{#if status.api.error}
<div class="text-red-400 text-xs pt-1">{status.api.error}</div>
{/if}
</dl>
</div>
<!-- MCP Server -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-3">
<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="font-medium text-sm text-white">MCP Server</span>
{#if status.mcp.premium}
<span
class="ml-auto text-xs px-1.5 py-0.5 rounded bg-amber-900/60 text-amber-300 border border-amber-800"
>
Premium
</span>
{/if}
</div>
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Transport</dt>
<dd class="text-gray-200">{status.mcp.transport}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Uptime</dt>
<dd class="text-gray-200">{status.mcp.uptime}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Tools</dt>
<dd class="text-gray-200">{status.mcp.tool_count}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-gray-500 shrink-0">Endpoint</dt>
<dd class="text-gray-400 text-xs font-mono truncate" title={status.mcp.endpoint}>
{status.mcp.endpoint}
</dd>
</div>
</dl>
</div>
</div>
<!-- Followed teams + MCP tools -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Followed teams -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Followed Teams
</h2>
{#if status.data.followed.length === 0}
<p class="text-gray-500 text-sm">No teams configured (set NIKE_TEAMS in .env)</p>
{:else}
<ul class="space-y-2">
{#each status.data.followed as team}
<li class="flex items-center gap-2 text-sm">
<span class="text-green-500 text-xs"></span>
<span class="text-white">{team.team}</span>
<span class="text-gray-700">·</span>
<span class="text-gray-400">{team.league}</span>
</li>
{/each}
</ul>
{/if}
{#if status.data.last_cache}
<p class="mt-3 text-xs text-gray-600">
Last cache update: {relTime(status.data.last_cache)}
</p>
{/if}
</div>
<!-- MCP Tools -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">MCP Tools</h2>
<table class="w-full text-sm">
<tbody class="divide-y divide-gray-800/60">
{#each status.tools as tool}
<tr>
<td class="py-1.5 pr-3">
<code class="text-green-300 text-xs">{tool.name}</code>
{#if tool.premium}
<span
class="ml-1.5 text-xs px-1 rounded bg-amber-900/50 text-amber-400 border border-amber-800/50"
title="Requires premium TheSportsDB key"
>
</span>
{/if}
</td>
<td class="py-1.5 text-gray-400">{tool.description}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- DB table counts -->
{#if Object.keys(status.data.table_counts).length > 0}
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Database Contents
</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4">
{#each Object.entries(status.data.table_counts) as [table, count]}
<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>
{/each}
</div>
</div>
{/if}
{:else if !loadError}
<div class="text-gray-500 text-sm animate-pulse">Loading status…</div>
{/if}
<!-- Request Log -->
<div class="rounded-lg bg-gray-900 border border-gray-800">
<div
class="px-4 py-3 border-b border-gray-800 flex items-center justify-between"
>
<h2 class="text-sm font-medium text-white">Request Log</h2>
<span class="text-xs text-gray-600">{logs.length} entries · auto-refreshes every 5 s</span>
</div>
{#if logs.length === 0}
<p class="px-4 py-5 text-gray-500 text-sm">No MCP requests yet.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-gray-600 border-b border-gray-800">
<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">Args</th>
<th class="px-4 py-2 font-medium text-right">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/40">
{#each logs as entry}
<tr class="hover:bg-gray-800/40 transition-colors">
<td class="px-4 py-2 text-gray-500 whitespace-nowrap text-xs">
{relTime(entry.timestamp)}
</td>
<td class="px-4 py-2">
<code class="text-green-300 text-xs">{entry.tool}</code>
</td>
<td class="px-4 py-2 text-gray-400 text-xs font-mono max-w-xs truncate">
{fmtArgs(entry.args)}
</td>
<td class="px-4 py-2 text-right text-gray-500 whitespace-nowrap text-xs">
{entry.duration_ms} ms
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,357 @@
<script lang="ts">
import { createTooltip, melt } from '@melt-ui/svelte';
import { runTool } from '$lib/api';
// ── Tool definitions ────────────────────────────────────
type ParamType = 'text' | 'number' | 'date' | 'select';
interface Param {
key: string;
label: string;
type: ParamType;
default: string;
options?: string[];
placeholder?: string;
}
interface ToolDef {
name: string;
description: string;
premium: boolean;
params: Param[];
}
const TOOLS: ToolDef[] = [
{
name: 'get_team_info',
description: 'Team profile: stadium, capacity, location, founded year, colors.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC', placeholder: 'e.g. Arsenal, Toronto FC' },
],
},
{
name: 'get_roster',
description: 'Current squad grouped by position. Requires premium key for live data.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
],
},
{
name: 'get_player_info',
description: 'Player profile: position, nationality, DOB, team, status.',
premium: false,
params: [
{ key: 'player_name', label: 'Player Name', type: 'text', default: '', placeholder: 'e.g. Federico Bernardeschi' },
],
},
{
name: 'get_fixtures',
description: 'Recent results and upcoming matches for a team.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
{ key: 'status', label: 'Status Filter', type: 'select', default: 'all', options: ['all', 'upcoming', 'past'] },
],
},
{
name: 'get_standings',
description: 'Full league table with points, goal difference, and form.',
premium: false,
params: [
{ key: 'league', label: 'League', type: 'text', default: 'American Major League Soccer', placeholder: 'e.g. English Premier League' },
{ key: 'season', label: 'Season', type: 'text', default: '2026', placeholder: 'e.g. 2026 or 2025-2026' },
],
},
{
name: 'get_match_result',
description: 'Match result for a team on a specific date.',
premium: false,
params: [
{ key: 'team_name', label: 'Team Name', type: 'text', default: 'Toronto FC' },
{ key: 'match_date', label: 'Date', type: 'date', default: '' },
],
},
{
name: 'get_match_detail',
description: 'Deep match stats, lineup, and timeline. Requires a premium TheSportsDB key.',
premium: true,
params: [
{ key: 'event_id', label: 'Event ID', type: 'number', default: '', placeholder: 'Get from get_fixtures first' },
],
},
{
name: 'get_livescores',
description: 'Current live soccer scores worldwide. Requires a premium TheSportsDB key.',
premium: true,
params: [],
},
];
// ── State ────────────────────────────────────────────────
let selectedTool = $state<ToolDef>(TOOLS[0]);
let paramValues = $state<Record<string, string>>({});
let running = $state(false);
let result = $state<string | null>(null);
let resultError = $state<string | null>(null);
interface HistoryEntry {
tool: string;
args: Record<string, string>;
output: string;
ok: boolean;
ts: Date;
}
let history = $state<HistoryEntry[]>([]);
function selectTool(tool: ToolDef) {
selectedTool = tool;
result = null;
resultError = null;
paramValues = Object.fromEntries(tool.params.map((p) => [p.key, p.default]));
}
// Init
selectTool(TOOLS[0]);
async function submit() {
running = true;
result = null;
resultError = null;
const args: Record<string, unknown> = {};
for (const p of selectedTool.params) {
const val = paramValues[p.key];
if (val === '') continue;
args[p.key] = p.type === 'number' ? Number(val) : val;
}
const snapshot = { ...paramValues };
try {
const r = await runTool(selectedTool.name, args);
if (r.ok) {
result = r.result ?? '';
} else {
resultError = r.error ?? 'Unknown error';
}
history = [
{ tool: selectedTool.name, args: snapshot, output: r.ok ? (r.result ?? '') : (r.error ?? ''), ok: r.ok, ts: new Date() },
...history,
].slice(0, 25);
} catch (e) {
resultError = String(e);
} finally {
running = false;
}
}
function loadHistory(entry: HistoryEntry) {
const tool = TOOLS.find((t) => t.name === entry.tool);
if (!tool) return;
selectTool(tool);
// selectTool resets paramValues, restore after microtask
setTimeout(() => {
paramValues = { ...entry.args };
if (entry.ok) {
result = entry.output;
resultError = null;
} else {
result = null;
resultError = entry.output;
}
}, 0);
}
function fmtTime(d: Date) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// Melt UI tooltip for premium badge
const {
elements: { trigger: premTrigger, content: premContent },
states: { open: premOpen },
} = createTooltip({ positioning: { placement: 'top' }, openDelay: 200 });
</script>
<div class="space-y-5">
<div>
<h1 class="text-lg font-semibold text-white">Tool Runner</h1>
<p class="text-sm text-gray-400 mt-1">
Run MCP tools interactively and inspect raw API responses. Useful for spotting strange API
results.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
<!-- Left: selector + form + result -->
<div class="lg:col-span-2 space-y-4">
<!-- Tool selector -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Select Tool</h2>
<div class="flex flex-wrap gap-2">
{#each TOOLS as tool}
<button
onclick={() => selectTool(tool)}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors
{selectedTool.name === tool.name
? 'bg-green-700 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-700'}"
>
{tool.name}
{#if tool.premium}
<span use:melt={$premTrigger} class="ml-1 text-amber-400 cursor-help"></span>
{/if}
</button>
{/each}
</div>
</div>
<!-- Parameter form -->
<div class="rounded-lg bg-gray-900 border border-gray-800 p-4 space-y-4">
<div>
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-white font-mono">{selectedTool.name}</h2>
{#if selectedTool.premium}
<span
class="text-xs px-1.5 py-0.5 rounded bg-amber-900/60 text-amber-300 border border-amber-800/50"
>
Premium
</span>
{/if}
</div>
<p class="text-xs text-gray-400 mt-1">{selectedTool.description}</p>
</div>
{#if selectedTool.params.length > 0}
<div class="space-y-3">
{#each selectedTool.params as param}
<div>
<label class="block text-xs font-medium text-gray-400 mb-1.5" for={param.key}>
{param.label}
</label>
{#if param.type === 'select'}
<select
id={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
text-gray-100 focus:outline-none focus:border-green-600 focus:ring-1
focus:ring-green-600"
>
{#each param.options ?? [] as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input
id={param.key}
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'date' : 'text'}
placeholder={param.placeholder ?? ''}
bind:value={paramValues[param.key]}
class="w-full rounded-md bg-gray-800 border border-gray-700 px-3 py-2 text-sm
text-gray-100 placeholder-gray-600 focus:outline-none focus:border-green-600
focus:ring-1 focus:ring-green-600"
/>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-xs text-gray-500 italic">No parameters — just click Run.</p>
{/if}
<button
onclick={submit}
disabled={running}
class="px-4 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white text-sm
font-medium disabled:opacity-50 transition-colors flex items-center gap-2"
>
{#if running}
<span class="inline-block w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
Running…
{:else}
Run Tool
{/if}
</button>
</div>
<!-- Result -->
{#if result !== null || resultError !== null}
<div
class="rounded-lg bg-gray-900 border {resultError
? 'border-red-800'
: 'border-gray-800'}"
>
<div
class="px-4 py-2.5 border-b {resultError
? 'border-red-800'
: 'border-gray-800'} flex items-center gap-2"
>
<span class="text-xs font-medium {resultError ? 'text-red-400' : 'text-green-400'}">
{resultError ? 'Error' : 'Result'}
</span>
{#if result}
<span class="text-xs text-gray-600">
{result.split('\n').length} lines
</span>
{/if}
</div>
<pre
class="px-4 py-4 text-sm font-mono whitespace-pre-wrap text-gray-200 overflow-x-auto
max-h-[520px] overflow-y-auto leading-relaxed"
>{resultError ?? result}</pre>
</div>
{/if}
</div>
<!-- Right: session history -->
<div class="rounded-lg bg-gray-900 border border-gray-800 sticky top-20">
<div class="px-4 py-3 border-b border-gray-800">
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider">Session History</h2>
</div>
{#if history.length === 0}
<p class="px-4 py-5 text-sm text-gray-500">No queries yet.</p>
{:else}
<ul class="divide-y divide-gray-800 max-h-[600px] overflow-y-auto">
{#each history as entry}
<li>
<button
onclick={() => loadHistory(entry)}
class="w-full text-left px-4 py-3 hover:bg-gray-800/50 transition-colors"
>
<div class="flex items-center justify-between gap-2">
<code class="text-xs {entry.ok ? 'text-green-300' : 'text-red-400'} truncate">
{entry.tool}
</code>
<span class="text-xs text-gray-600 shrink-0">{fmtTime(entry.ts)}</span>
</div>
{#each Object.entries(entry.args) as [k, v]}
{#if v}
<div class="text-xs text-gray-500 truncate mt-0.5">
{k}: <span class="text-gray-400">{v}</span>
</div>
{/if}
{/each}
{#if !entry.ok}
<div class="text-xs text-red-500 mt-0.5 truncate">{entry.output}</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
<!-- Melt UI tooltip for premium star -->
{#if $premOpen}
<div
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"
>
Requires a premium TheSportsDB key
</div>
{/if}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="15" fill="#16a34a"/>
<text x="16" y="22" text-anchor="middle" font-size="18"></text>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@@ -0,0 +1,14 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
}),
},
};
export default config;

13
dashboard/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
}

13
dashboard/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8000',
'/mcp': 'http://localhost:8000',
},
},
});

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
nike:
build: .
ports:
- "${NIKE_PORT:-8000}:8000"
env_file:
- .env
environment:
NIKE_HOST: "0.0.0.0"
NIKE_PORT: "8000"
restart: unless-stopped

View File

@@ -36,3 +36,6 @@ API_FOOTBALL_KEY = os.getenv('API_FOOTBALL_KEY', '')
SERVER_HOST = os.getenv('NIKE_HOST', '0.0.0.0') SERVER_HOST = os.getenv('NIKE_HOST', '0.0.0.0')
SERVER_PORT = int(os.getenv('NIKE_PORT', 8000)) SERVER_PORT = int(os.getenv('NIKE_PORT', 8000))
SERVER_NAME = "Nike — Football Data Platform" SERVER_NAME = "Nike — Football Data Platform"
# IPs allowed to set X-Forwarded-Proto (your HAProxy host).
# '*' trusts all — safe when Nike's port is firewalled to HAProxy only.
TRUSTED_PROXY_IPS = os.getenv('NIKE_TRUSTED_PROXY', '*')

View File

@@ -23,8 +23,10 @@ from typing import Any
import uvicorn import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from fastmcp import FastMCP from fastmcp import FastMCP
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
@@ -704,8 +706,14 @@ dashboard = FastAPI(title="Nike Dashboard", lifespan=_lifespan)
# ── Dashboard routes ────────────────────────────────────── # ── Dashboard routes ──────────────────────────────────────
_SVELTE_BUILD = Path(__file__).parent.parent / "dashboard" / "build"
@dashboard.get("/", response_class=HTMLResponse) @dashboard.get("/", response_class=HTMLResponse)
async def index(request: Request): async def index(request: Request):
svelte_index = _SVELTE_BUILD / "index.html"
if svelte_index.exists():
return FileResponse(str(svelte_index))
return _TEMPLATES.TemplateResponse("dashboard.html", {"request": request}) return _TEMPLATES.TemplateResponse("dashboard.html", {"request": request})
@@ -770,9 +778,52 @@ async def api_cache_invalidate():
}) })
# ── Tool runner API ───────────────────────────────────────
_TOOLS: dict[str, Any] = {} # populated after tool definitions exist
def _register_tools() -> None:
_TOOLS.update({
"get_team_info": get_team_info,
"get_roster": get_roster,
"get_player_info": get_player_info,
"get_fixtures": get_fixtures,
"get_standings": get_standings,
"get_match_result": get_match_result,
"get_match_detail": get_match_detail,
"get_livescores": get_livescores,
})
_register_tools()
class _RunRequest(BaseModel):
tool: str
args: dict[str, Any] = {}
@dashboard.post("/api/run")
async def api_run(body: _RunRequest):
fn = _TOOLS.get(body.tool)
if fn is None:
return JSONResponse({"ok": False, "error": f"Unknown tool: {body.tool!r}"}, status_code=400)
try:
result = fn(**body.args)
return JSONResponse({"ok": True, "result": result})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
# ── Mount MCP onto dashboard ────────────────────────────── # ── Mount MCP onto dashboard ──────────────────────────────
dashboard.mount("/mcp", _mcp_app) dashboard.mount("/mcp", _mcp_app)
# ── Serve SvelteKit build (after mcp so /mcp takes priority) ──
if _SVELTE_BUILD.exists():
# html=True makes unknown paths fall back to index.html (SPA routing)
dashboard.mount("/", StaticFiles(directory=str(_SVELTE_BUILD), html=True))
# ── Entry point ─────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────
def main(): def main():
@@ -783,6 +834,9 @@ def main():
reload=False, reload=False,
log_level="info", log_level="info",
ws="wsproto", ws="wsproto",
# Trust X-Forwarded-Proto / X-Forwarded-For from HAProxy
proxy_headers=True,
forwarded_allow_ips=config.TRUSTED_PROXY_IPS,
) )

View File

@@ -16,6 +16,7 @@ dependencies = [
"python-dotenv>=1.0", "python-dotenv>=1.0",
"requests>=2.32", "requests>=2.32",
"jinja2>=3.1", "jinja2>=3.1",
"wsproto>=1.2",
] ]
[project.optional-dependencies] [project.optional-dependencies]