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:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.git/
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
12
Dockerfile
Normal 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"]
|
||||||
41
README.md
41
README.md
@@ -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
3
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
2174
dashboard/package-lock.json
generated
Normal file
2174
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
dashboard/package.json
Normal file
26
dashboard/package.json
Normal 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
6
dashboard/src/app.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-pitch: #16a34a;
|
||||||
|
--color-pitch-dark: #15803d;
|
||||||
|
}
|
||||||
13
dashboard/src/app.html
Normal file
13
dashboard/src/app.html
Normal 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
32
dashboard/src/lib/api.ts
Normal 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();
|
||||||
|
}
|
||||||
63
dashboard/src/lib/types.ts
Normal file
63
dashboard/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
40
dashboard/src/routes/+layout.svelte
Normal file
40
dashboard/src/routes/+layout.svelte
Normal 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>
|
||||||
2
dashboard/src/routes/+layout.ts
Normal file
2
dashboard/src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
316
dashboard/src/routes/+page.svelte
Normal file
316
dashboard/src/routes/+page.svelte
Normal 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>
|
||||||
357
dashboard/src/routes/tools/+page.svelte
Normal file
357
dashboard/src/routes/tools/+page.svelte
Normal 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}
|
||||||
4
dashboard/static/favicon.svg
Normal file
4
dashboard/static/favicon.svg
Normal 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 |
14
dashboard/svelte.config.js
Normal file
14
dashboard/svelte.config.js
Normal 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
13
dashboard/tsconfig.json
Normal 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
13
dashboard/vite.config.ts
Normal 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
11
docker-compose.yml
Normal 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
|
||||||
@@ -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', '*')
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user