Initial web

This commit is contained in:
Caesar2011
2026-05-17 19:55:53 +02:00
parent 6e3499812e
commit 20ed6ee9fb
58 changed files with 8541 additions and 0 deletions

4
node/.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Webhook configuration for the Factorio signal watcher
INGEST_BASE_URL=http://localhost:3000
API_TOKEN=password

4
node/.env.local Normal file
View File

@@ -0,0 +1,4 @@
# Webhook configuration for the Factorio signal watcher
INGEST_BASE_URL=http://localhost:3000
API_TOKEN=change-me

View File

@@ -1,4 +1,5 @@
{ {
"type": "module",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.17" "adm-zip": "^0.5.17"
} }

41
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
web/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
web/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
web/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
export const PUT = withAuth(async (req: NextRequest, { params }) => {
const { id } = await params;
const body = await req.json();
const { item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active } = body;
const result = await pool.query(
`UPDATE alerts SET
item_key = COALESCE($1, item_key),
item_key_is_regex = COALESCE($2, item_key_is_regex),
combinator = $3,
signal_type = COALESCE($4, signal_type),
condition = COALESCE($5, condition),
threshold = COALESCE($6, threshold),
active = COALESCE($7, active)
WHERE id = $8
RETURNING *`,
[item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active, id],
);
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(result.rows[0]);
});
export const DELETE = withAuth(async (_req: NextRequest, { params }) => {
const { id } = await params;
const result = await pool.query('DELETE FROM alerts WHERE id = $1', [id]);
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ ok: true });
});

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
import { getServerLocaleMap } from '@/lib/localeServer';
import { resolveName } from '@/lib/localization';
import type { AlertConfig, TriggeredAlert } from '@/lib/types';
export const GET = withAuth(async () => {
const alertsResult = await pool.query<AlertConfig>(
`SELECT id, item_key, item_key_is_regex, combinator, signal_type, condition, threshold
FROM alerts WHERE active = true`,
);
if (alertsResult.rows.length === 0) return NextResponse.json([]);
const latestResult = await pool.query<{
combinator: string; item_key: string; green: number; red: number;
}>(
`SELECT DISTINCT ON (combinator, item_key) combinator, item_key, green, red
FROM signals
ORDER BY combinator, item_key, real_time DESC`,
);
const localeMap = getServerLocaleMap();
const latestMap = new Map(
latestResult.rows.map(r => [`${r.combinator}::${r.item_key}`, r]),
);
const triggered: TriggeredAlert[] = [];
for (const alert of alertsResult.rows) {
for (const [key, vals] of latestMap) {
const [combinator, item_key] = key.split('::');
let itemMatch: boolean;
if (alert.item_key_is_regex) {
try {
const re = new RegExp(alert.item_key, 'i');
// Test against raw key and localized name
itemMatch = re.test(item_key) || re.test(resolveName(item_key, localeMap));
} catch {
itemMatch = false;
}
} else {
itemMatch = item_key === alert.item_key;
}
if (!itemMatch || (alert.combinator && combinator !== alert.combinator)) continue;
const value = alert.signal_type === 'green' ? vals.green : vals.red;
const fired = alert.condition === 'above' ? value > alert.threshold : value < alert.threshold;
if (fired) triggered.push({
...alert,
current_value: value,
combinator_match: combinator,
matched_item_key: item_key,
});
}
}
return NextResponse.json(triggered);
});

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
export const GET = withAuth(async () => {
const result = await pool.query('SELECT * FROM alerts ORDER BY created_at DESC');
return NextResponse.json(result.rows);
});
export const POST = withAuth(async (req: NextRequest) => {
const body = await req.json();
const {
item_key, item_key_is_regex = false,
combinator = null, signal_type = 'green', condition, threshold,
} = body;
if (!item_key || !condition || threshold === undefined) {
return NextResponse.json(
{ error: 'item_key, condition, threshold required' },
{ status: 400 },
);
}
const result = await pool.query(
`INSERT INTO alerts (item_key, item_key_is_regex, combinator, signal_type, condition, threshold)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[item_key, item_key_is_regex, combinator, signal_type, condition, threshold],
);
return NextResponse.json(result.rows[0], { status: 201 });
});

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
export const PUT = withAuth(async (req: NextRequest, { params }) => {
const { id } = await params;
const body = await req.json();
const {
title, pos_x, pos_y, width, height,
signal_type, chart_type, viz_type,
filter_items_regex, y_scale,
series_limit, order_by,
} = body;
const hasFilterCombinators = 'filter_combinators' in body;
const hasFilterItems = 'filter_items' in body;
const hasFilterItemsExclude = 'filter_items_exclude' in body;
const hasYMin = 'y_min' in body;
const hasYMax = 'y_max' in body;
const result = await pool.query(
`UPDATE charts SET
title = COALESCE($1, title),
pos_x = COALESCE($2, pos_x),
pos_y = COALESCE($3, pos_y),
width = COALESCE($4, width),
height = COALESCE($5, height),
signal_type = COALESCE($6, signal_type),
chart_type = COALESCE($7, chart_type),
viz_type = COALESCE($8, viz_type),
filter_combinators = CASE WHEN $9::boolean THEN $10::text[] ELSE filter_combinators END,
filter_items = CASE WHEN $11::boolean THEN $12::text[] ELSE filter_items END,
filter_items_exclude = CASE WHEN $13::boolean THEN $14::text[] ELSE filter_items_exclude END,
filter_items_regex = COALESCE($15, filter_items_regex),
y_min = CASE WHEN $16::boolean THEN $17::double precision ELSE y_min END,
y_max = CASE WHEN $18::boolean THEN $19::double precision ELSE y_max END,
y_scale = COALESCE($20, y_scale),
series_limit = COALESCE($21, series_limit),
order_by = COALESCE($22, order_by)
WHERE id = $23
RETURNING *`,
[
title, pos_x, pos_y, width, height, signal_type, chart_type, viz_type,
hasFilterCombinators, body.filter_combinators ?? null,
hasFilterItems, body.filter_items ?? null,
hasFilterItemsExclude, body.filter_items_exclude ?? null,
filter_items_regex,
hasYMin, body.y_min ?? null,
hasYMax, body.y_max ?? null,
y_scale,
series_limit, order_by,
id,
],
);
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(result.rows[0]);
});
export const DELETE = withAuth(async (_req: NextRequest, { params }) => {
const { id } = await params;
const result = await pool.query('DELETE FROM charts WHERE id = $1', [id]);
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ ok: true });
});

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
export const GET = withAuth(async () => {
const result = await pool.query('SELECT * FROM charts ORDER BY pos_y ASC, pos_x ASC');
return NextResponse.json(result.rows);
});
export const POST = withAuth(async (req: NextRequest) => {
const body = await req.json();
const {
title,
pos_x = 0, pos_y = 0, width = 2, height = 4,
signal_type = 'both', chart_type = 'signals', viz_type = 'line',
filter_combinators = null, filter_items = null,
filter_items_exclude = null, filter_items_regex = false,
y_min = null, y_max = null, y_scale = 'linear',
series_limit = 20, order_by = 'time',
} = body;
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
const result = await pool.query(
`INSERT INTO charts
(title,pos_x,pos_y,width,height,signal_type,chart_type,viz_type,
filter_combinators,filter_items,filter_items_exclude,filter_items_regex,
y_min,y_max,y_scale,series_limit,order_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *`,
[title,pos_x,pos_y,width,height,signal_type,chart_type,viz_type,
filter_combinators,filter_items,filter_items_exclude,filter_items_regex,
y_min,y_max,y_scale,series_limit,order_by],
);
return NextResponse.json(result.rows[0], { status: 201 });
});

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
interface CircuitNetwork {
green: Record<string, number>;
red: Record<string, number>;
}
interface IngestBody {
game_tick: number;
circuit_network: CircuitNetwork;
logistic_network: Record<string, number>;
}
export const POST = withAuth(async (req: NextRequest, { params }) => {
const { combinator } = await params;
const mtimeHeader = req.headers.get('x-file-mtime');
const realTime = mtimeHeader ? new Date(parseInt(mtimeHeader, 10)) : new Date();
let body: IngestBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { game_tick, circuit_network, logistic_network } = body;
if (typeof game_tick !== 'number') {
return NextResponse.json({ error: 'Missing game_tick' }, { status: 400 });
}
const green = circuit_network?.green ?? {};
const red = circuit_network?.red ?? {};
const logistic = logistic_network ?? {};
const allKeys = new Set([...Object.keys(green), ...Object.keys(red), ...Object.keys(logistic)]);
if (allKeys.size === 0) return NextResponse.json({ ok: true, rows: 0 });
const client = await pool.connect();
try {
await client.query('BEGIN');
const values: unknown[] = [];
const placeholders: string[] = [];
let idx = 1;
for (const key of allKeys) {
placeholders.push(`($${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++})`);
values.push(realTime, game_tick, combinator, key, green[key] ?? 0, red[key] ?? 0, logistic[key] ?? null);
}
await client.query(
`INSERT INTO signals (real_time, game_tick, combinator, item_key, green, red, logistic)
VALUES ${placeholders.join(', ')}`,
values,
);
await client.query(
`INSERT INTO tick_timing (real_time, game_tick, combinator) VALUES ($1,$2,$3)`,
[realTime, game_tick, combinator],
);
await client.query('COMMIT');
return NextResponse.json({ ok: true, rows: allKeys.size });
} catch (err) {
await client.query('ROLLBACK');
throw err; // re-throw — withAuth handler catches and returns 500
} finally {
client.release();
}
});

View File

@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSessionBoundaries } from '@/lib/sessions';
import { withAuth } from '@/lib/apiHelpers';
export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams;
const from = p.get('from') ? new Date(p.get('from')!) : new Date(Date.now() - 86_400_000);
const to = p.get('to') ? new Date(p.get('to')!) : new Date();
const boundaries = await getSessionBoundaries(from, to);
return NextResponse.json(boundaries);
});

View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
import { getServerLocaleMap } from '@/lib/localeServer';
import { matchKeys } from '@/lib/localization';
export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams;
const combinators = p.getAll('combinator');
const itemsWhitelist = p.getAll('item');
const itemsBlacklist = p.getAll('exclude');
const signalType = p.get('signal') ?? 'both';
const from = p.get('from');
const to = p.get('to');
const useRegex = p.get('regex') === 'true';
const orderBy = p.get('order_by') ?? 'time';
const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null;
const conditions: string[] = [];
const values: unknown[] = [];
let i = 1;
if (combinators.length > 0) {
conditions.push(`combinator = ANY($${i++})`);
values.push(combinators);
}
if (itemsWhitelist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
// Each pattern is expanded to matching keys (tested against key AND localized name).
// Union all patterns — if a pattern matches nothing, it contributes no keys.
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length === 0) return NextResponse.json([]);
conditions.push(`item_key = ANY($${i++})`);
values.push(keys);
} else {
conditions.push(`item_key = ANY($${i++})`);
values.push(itemsWhitelist);
}
}
if (itemsBlacklist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
// If blacklist pattern matches nothing, nothing to exclude — skip condition
if (keys.length > 0) {
conditions.push(`item_key != ALL($${i++})`);
values.push(keys);
}
} else {
conditions.push(`item_key != ALL($${i++})`);
values.push(itemsBlacklist);
}
}
if (from) { conditions.push(`real_time >= $${i++}`); values.push(new Date(from)); }
if (to) { conditions.push(`real_time <= $${i++}`); values.push(new Date(to)); }
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const valueCol = signalType === 'red' ? 'red' : 'green';
const selectCols =
signalType === 'green' ? 'real_time, game_tick, combinator, item_key, green'
: signalType === 'red' ? 'real_time, game_tick, combinator, item_key, red'
: 'real_time, game_tick, combinator, item_key, green, red';
if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) {
const baseConditions: string[] = [];
const baseValues: unknown[] = [];
let j = 1;
if (combinators.length > 0) {
baseConditions.push(`combinator = ANY($${j++})`);
baseValues.push(combinators);
}
if (itemsWhitelist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length === 0) return NextResponse.json([]);
baseConditions.push(`item_key = ANY($${j++})`);
baseValues.push(keys);
} else {
baseConditions.push(`item_key = ANY($${j++})`);
baseValues.push(itemsWhitelist);
}
}
if (itemsBlacklist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length > 0) {
baseConditions.push(`item_key != ALL($${j++})`);
baseValues.push(keys);
}
} else {
baseConditions.push(`item_key != ALL($${j++})`);
baseValues.push(itemsBlacklist);
}
}
const baseWhere = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : '';
const baseWhereAnd = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : '';
const deltaQuery = `
WITH snap_now AS (
SELECT DISTINCT ON (combinator, item_key)
combinator, item_key, ${valueCol} AS val, real_time AS ref_time
FROM signals
${baseWhere}
ORDER BY combinator, item_key, real_time DESC
),
snap_then AS (
SELECT DISTINCT ON (s.combinator, s.item_key)
s.combinator, s.item_key, s.${valueCol} AS val
FROM signals s
JOIN snap_now n USING (combinator, item_key)
WHERE s.real_time <= n.ref_time - INTERVAL '10 minutes'
${baseWhereAnd}
ORDER BY s.combinator, s.item_key, s.real_time DESC
),
deltas AS (
SELECT
snap_now.combinator,
snap_now.item_key,
(snap_now.val - COALESCE(snap_then.val, snap_now.val)) AS delta
FROM snap_now
LEFT JOIN snap_then USING (combinator, item_key)
)
SELECT combinator, item_key, delta
FROM deltas
ORDER BY delta ${orderBy === 'delta_asc' ? 'ASC' : 'DESC'}
LIMIT $${j}
`;
const deltaResult = await pool.query<{ combinator: string; item_key: string; delta: number }>(
deltaQuery,
[...baseValues, limit],
);
const top = deltaResult.rows;
if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map((_, idx) =>
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
);
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const result = await pool.query(
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY real_time ASC`,
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
);
return NextResponse.json(result.rows);
}
if ((orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') && limit !== null) {
const latestVals = await pool.query<{ combinator: string; item_key: string; val: number }>(
`SELECT DISTINCT ON (combinator, item_key)
combinator, item_key, ${valueCol} AS val
FROM signals ${where}
ORDER BY combinator, item_key, real_time DESC`,
values,
);
let sorted = latestVals.rows;
if (orderBy === 'value_asc') sorted = [...sorted].sort((a, b) => a.val - b.val);
if (orderBy === 'value_desc') sorted = [...sorted].sort((a, b) => b.val - a.val);
if (orderBy === 'abs_desc') sorted = [...sorted].sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
const top = sorted.slice(0, limit);
if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map((_, idx) =>
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
);
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const result = await pool.query(
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY real_time ASC`,
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
);
return NextResponse.json(result.rows);
}
const rowLimit = orderBy === 'time' && limit ? `LIMIT ${limit}` : '';
const result = await pool.query(
`SELECT ${selectCols} FROM signals ${where} ORDER BY real_time ASC ${rowLimit}`,
values,
);
return NextResponse.json(result.rows);
});

59
web/app/api/ups/route.ts Normal file
View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams;
const combinator = p.get('combinator');
const from = p.get('from') ? new Date(p.get('from')!) : new Date(Date.now() - 86_400_000);
const to = p.get('to') ? new Date(p.get('to')!) : new Date();
const conditions = ['real_time BETWEEN $1 AND $2'];
const values: unknown[] = [from, to];
if (combinator) {
conditions.push('combinator = $3');
values.push(combinator);
}
const result = await pool.query<{
real_time: Date; game_tick: string; combinator: string;
}>(
`SELECT real_time, game_tick, combinator
FROM tick_timing
WHERE ${conditions.join(' AND ')}
ORDER BY real_time ASC`,
values,
);
const rows = result.rows;
if (rows.length < 2) return NextResponse.json([]);
const byCombi = new Map<string, typeof rows>();
for (const row of rows) {
const arr = byCombi.get(row.combinator) ?? [];
arr.push(row);
byCombi.set(row.combinator, arr);
}
const points: { real_time: string; game_tick: number; combinator: string; ups: number }[] = [];
for (const [combi, combiRows] of byCombi) {
for (let i = 1; i < combiRows.length; i++) {
const prev = combiRows[i - 1];
const curr = combiRows[i];
const deltaRealMs = curr.real_time.getTime() - prev.real_time.getTime();
const deltaTicks = parseInt(curr.game_tick, 10) - parseInt(prev.game_tick, 10);
// Skip session gaps and bad data
if (deltaRealMs > 30 * 60 * 1000 || deltaRealMs <= 0 || deltaTicks <= 0) continue;
points.push({
real_time: curr.real_time.toISOString(),
game_tick: parseInt(curr.game_tick, 10),
combinator: combi,
ups: Math.round((deltaTicks / deltaRealMs) * 1000 * 10) / 10,
});
}
}
return NextResponse.json(points);
});

BIN
web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

17
web/app/globals.css Normal file
View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
:root {
--background: #111827;
--foreground: #f9fafb;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
body {
background: var(--background);
color: var(--foreground);
font-family: ui-sans-serif, system-ui, sans-serif;
}

17
web/app/layout.tsx Normal file
View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Factorio Dashboard",
description: "Factorio signal monitor",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

94
web/app/page.tsx Normal file
View File

@@ -0,0 +1,94 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { AppProvider, useApp } from '@/lib/context';
import { setToken, fetchAlerts } from '@/lib/api';
import { getLocaleMap } from '@/lib/localization';
import type { AlertConfig } from '@/lib/types';
import type { LocaleMap } from '@/lib/localization';
import TimeRangeSelector from '@/components/TimeRangeSelector';
import AlertPanel from '@/components/AlertPanel';
import Dashboard from '@/components/Dashboard';
function AppShell({ alerts }: { alerts: AlertConfig[] }) {
const { triggeredAlerts } = useApp();
const [alertPanelOpen, setAlertPanelOpen] = useState(false);
return (
<div className="min-h-screen bg-gray-950 text-gray-100">
<header className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900 sticky top-0 z-20">
<span className="font-bold text-indigo-400 tracking-wide">Factorio Dashboard</span>
<div className="flex items-center gap-4">
<TimeRangeSelector />
<button
onClick={() => setAlertPanelOpen(true)}
className="relative text-sm text-gray-300 hover:text-white px-3 py-1 rounded hover:bg-gray-800"
>
🔔 Alerts
{triggeredAlerts.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{triggeredAlerts.length}
</span>
)}
</button>
</div>
</header>
<main className="p-4">
<Dashboard alerts={alerts} />
</main>
<AlertPanel open={alertPanelOpen} onClose={() => setAlertPanelOpen(false)} />
</div>
);
}
function DashboardApp() {
const searchParams = useSearchParams();
const token = searchParams.get('token') ?? '';
const [ready, setReady] = useState(false);
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
const [localeMap, setLocaleMap] = useState<LocaleMap>(new Map());
useEffect(() => {
if (!token) return;
setToken(token);
Promise.all([getLocaleMap(), fetchAlerts()]).then(([lm, al]) => {
setLocaleMap(lm);
setAlerts(al);
setReady(true);
});
}, [token]);
if (!token) {
return (
<div className="flex min-h-screen items-center justify-center text-gray-400">
Missing <code className="mx-1 bg-gray-800 px-1 rounded">?token=</code> in URL
</div>
);
}
if (!ready) {
return (
<div className="flex min-h-screen items-center justify-center text-gray-400">
Loading
</div>
);
}
return (
<AppProvider token={token} localeMap={localeMap}>
<AppShell alerts={alerts} />
</AppProvider>
);
}
export default function Page() {
return (
<Suspense>
<DashboardApp />
</Suspense>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useApp } from '@/lib/context';
import { fetchAlerts, createAlert, updateAlert, deleteAlert } from '@/lib/api';
import { resolveName, resolveKey } from '@/lib/localization';
import type { AlertConfig } from '@/lib/types';
interface Props {
open: boolean;
onClose: () => void;
}
const inputCls = 'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
const selectCls = 'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
interface AlertFormState {
itemKey: string;
itemKeyIsRegex: boolean;
combinator: string;
signalType: 'green' | 'red';
condition: 'above' | 'below';
threshold: string;
}
function emptyForm(): AlertFormState {
return { itemKey: '', itemKeyIsRegex: false, combinator: '', signalType: 'green', condition: 'below', threshold: '0' };
}
function alertToForm(a: AlertConfig): AlertFormState {
return {
itemKey: a.item_key,
itemKeyIsRegex: a.item_key_is_regex,
combinator: a.combinator ?? '',
signalType: a.signal_type,
condition: a.condition,
threshold: String(a.threshold),
};
}
function Tooltip({ text }: { text: string }) {
return (
<span className="relative group inline-flex items-center cursor-default">
<span className="text-gray-500 hover:text-gray-300 text-xs select-none"></span>
<span className="pointer-events-none absolute left-5 top-0 z-50 w-48 rounded bg-gray-700 px-2 py-1 text-xs text-gray-200 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
{text}
</span>
</span>
);
}
function AlertForm({
value, onChange, onSubmit, onCancel, submitLabel,
}: {
value: AlertFormState;
onChange: (s: AlertFormState) => void;
onSubmit: () => void;
onCancel?: () => void;
submitLabel: string;
}) {
return (
<div className="space-y-2">
<input
value={value.itemKey}
onChange={e => onChange({ ...value, itemKey: e.target.value })}
placeholder={value.itemKeyIsRegex ? 'iron-.*|Iron Plate' : 'Iron Plate or item-key'}
className={inputCls}
/>
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
<input type="checkbox" checked={value.itemKeyIsRegex}
onChange={e => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
className="accent-indigo-500" />
Item key is regex
</label>
<input value={value.combinator} onChange={e => onChange({ ...value, combinator: e.target.value })}
placeholder="combinator (empty = all)" className={inputCls} />
<div className="flex gap-2">
<select value={value.signalType} onChange={e => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })} className={selectCls}>
<option value="green">Green</option>
<option value="red">Red</option>
</select>
<select value={value.condition} onChange={e => onChange({ ...value, condition: e.target.value as 'above' | 'below' })} className={selectCls}>
<option value="below">Below</option>
<option value="above">Above</option>
</select>
</div>
<input type="number" value={value.threshold} onChange={e => onChange({ ...value, threshold: e.target.value })}
placeholder="threshold" className={inputCls} />
<div className="flex gap-2">
<button onClick={onSubmit} className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5">
{submitLabel}
</button>
{onCancel && (
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700">
Cancel
</button>
)}
</div>
</div>
);
}
export default function AlertPanel({ open, onClose }: Props) {
const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp();
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
const [newForm, setNewForm] = useState<AlertFormState>(emptyForm());
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<AlertFormState>(emptyForm());
const prevTriggeredCount = useRef(0);
useEffect(() => {
if (open) fetchAlerts().then(setAlerts);
}, [open]);
useEffect(() => {
if (triggeredAlerts.length > prevTriggeredCount.current) {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
osc.start();
osc.stop(ctx.currentTime + 0.4);
} catch { /* AudioContext blocked */ }
}
prevTriggeredCount.current = triggeredAlerts.length;
}, [triggeredAlerts.length]);
/** Resolves item key input — skips resolution for regex mode. */
function normalizeItemKey(form: AlertFormState): string {
if (form.itemKeyIsRegex) return form.itemKey.trim();
return resolveKey(form.itemKey.trim(), reverseMap);
}
async function handleCreate() {
if (!newForm.itemKey.trim()) return;
const created = await createAlert({
item_key: normalizeItemKey(newForm),
item_key_is_regex: newForm.itemKeyIsRegex,
combinator: newForm.combinator.trim() || null,
signal_type: newForm.signalType,
condition: newForm.condition,
threshold: parseInt(newForm.threshold, 10),
});
setAlerts(a => [created, ...a]);
setNewForm(emptyForm());
await refreshAlerts();
}
async function handleEdit(id: string) {
if (!editForm.itemKey.trim()) return;
const updated = await updateAlert(id, {
item_key: normalizeItemKey(editForm),
item_key_is_regex: editForm.itemKeyIsRegex,
combinator: editForm.combinator.trim() || null,
signal_type: editForm.signalType,
condition: editForm.condition,
threshold: parseInt(editForm.threshold, 10),
});
setAlerts(a => a.map(x => x.id === id ? updated : x));
setEditingId(null);
await refreshAlerts();
}
async function handleDelete(id: string) {
await deleteAlert(id);
setAlerts(a => a.filter(x => x.id !== id));
await refreshAlerts();
}
function startEdit(alert: AlertConfig) {
setEditingId(alert.id);
setEditForm(alertToForm(alert));
}
return (
<div className={`fixed top-0 right-0 h-full w-80 bg-gray-900 border-l border-gray-700 shadow-xl z-40 transform transition-transform duration-200 ${open ? 'translate-x-0' : 'translate-x-full'}`}>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<span className="font-semibold text-white">Alerts</span>
<button onClick={onClose} className="text-gray-400 hover:text-white"></button>
</div>
{triggeredAlerts.length > 0 && (
<div className="px-4 py-2 bg-red-900/40 border-b border-red-800">
<p className="text-red-300 text-xs font-semibold mb-1">🔴 TRIGGERED ({triggeredAlerts.length})</p>
{triggeredAlerts.map((a, i) => (
<div key={i} className="text-xs text-red-200 flex items-center gap-1 flex-wrap">
<span>{resolveName(a.matched_item_key, localeMap)}</span>
<span className="text-red-400">({a.combinator_match})</span>
<span>[{a.signal_type}]</span>
<span>= {a.current_value} {a.condition} {a.threshold}</span>
{a.item_key_is_regex && a.matched_item_key !== a.item_key && (
<Tooltip text={`Matched by regex: /${a.item_key}/`} />
)}
</div>
))}
</div>
)}
<div className="p-4 border-b border-gray-700 space-y-2">
<p className="text-xs text-gray-400 font-semibold uppercase">New Alert</p>
<AlertForm value={newForm} onChange={setNewForm} onSubmit={handleCreate} submitLabel="Add Alert" />
</div>
<div className="overflow-y-auto flex-1 p-4 space-y-2">
{alerts.length === 0 && <p className="text-gray-500 text-sm">No alerts configured.</p>}
{alerts.map(a => (
<div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300">
{editingId === a.id ? (
<AlertForm
value={editForm}
onChange={setEditForm}
onSubmit={() => handleEdit(a.id)}
onCancel={() => setEditingId(null)}
submitLabel="Save"
/>
) : (
<div className="flex justify-between items-start">
<div>
<span className="font-medium text-white">{resolveName(a.item_key, localeMap)}</span>
{a.item_key_is_regex && (
<Tooltip text={`Regex pattern: /${a.item_key}/`} />
)}
{a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>}
<br />
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>[{a.signal_type}]</span>
{' '}{a.condition} {a.threshold}
</div>
<div className="flex gap-1 ml-2 shrink-0">
<button onClick={() => startEdit(a)} className="text-gray-500 hover:text-indigo-400"></button>
<button onClick={() => handleDelete(a.id)} className="text-gray-500 hover:text-red-400"></button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
interface HeaderProps {
title: string;
onEdit: () => void;
onDelete: () => void;
}
export function Header({ title, onEdit, onDelete }: HeaderProps) {
return (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 shrink-0">
<span className="text-sm font-medium text-gray-200 truncate">{title}</span>
<div className="flex gap-1 ml-2 shrink-0">
<button onClick={onEdit} className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"></button>
<button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
</div>
</div>
);
}
export function EmptyState() {
return (
<div className="flex-1 flex items-center justify-center text-gray-500 text-sm">No data</div>
);
}
interface CardShellProps extends HeaderProps {
empty: boolean;
children: React.ReactNode;
/** Ref to the div where the uPlot legend will be mounted */
legendContainerRef?: React.RefObject<HTMLDivElement>;
}
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) {
return (
<div className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden">
<Header title={title} onEdit={onEdit} onDelete={onDelete} />
{empty ? (
<EmptyState />
) : (
<>
{/* Plot fills remaining space */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Legend scrolls independently, capped at 25% card height */}
<div
ref={legendContainerRef}
className="shrink-0 max-h-[25%] overflow-y-auto border-t border-gray-800 px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
interface Props {
title: string;
onEdit: () => void;
onDelete: () => void;
}
export default function DividerCard({ title, onEdit, onDelete }: Props) {
return (
<div className="h-full flex items-center bg-gray-900/30 rounded border border-gray-600/40 px-4 gap-4 overflow-hidden">
<span className="text-sm font-bold text-gray-200 uppercase tracking-widest shrink-0">{title}</span>
<div className="flex-1 h-px bg-gray-600" />
<div className="flex gap-1 shrink-0">
<button onClick={onEdit} className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"></button>
<button onClick={onDelete} className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot';
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
import { CardShell } from './CardShell';
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
import { buildSeriesData } from './seriesData';
import { usePlot } from './usePlot';
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
interface Props {
config: ChartConfig;
rows: SignalRow[];
sessions: SessionBoundary[];
alerts: AlertConfig[];
timeMode: TimeMode;
onEdit: () => void;
onDelete: () => void;
}
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
const { localeMap } = useApp();
const { containerRef, legendRef } = usePlot(
(el, w, h, lRef) => {
const data = buildSeriesData(rows, config.signal_type, timeMode);
if (!data) return null;
const { keys, allXs, data: seriesData } = data;
const sessionXs = sessions.map(s => timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000);
const alertThresholds = alerts
.filter(a => config.signal_type === 'both' || config.signal_type === a.signal_type)
.map(a => a.threshold);
return new uPlot({
width: w,
height: h,
cursor: CURSOR_NO_DRAG,
legend: {
mount: (_u, legendEl) => {
if (lRef.current) lRef.current.appendChild(legendEl);
},
},
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap)),
axes: makeSignalsAxes(timeMode),
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
}, [allXs, ...seriesData], el);
},
[rows, sessions, alerts, config, timeMode, localeMap],
);
return (
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0} legendContainerRef={legendRef}>
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
</CardShell>
);
}

View File

@@ -0,0 +1,59 @@
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
import { CardShell } from './CardShell';
import type { ChartConfig, SignalRow } from '@/lib/types';
interface Props {
config: ChartConfig;
rows: SignalRow[];
onEdit: () => void;
onDelete: () => void;
}
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
const { localeMap } = useApp();
const latest = new Map<string, { green?: number; red?: number }>();
for (const row of rows) {
latest.set(`${row.combinator}::${row.item_key}`, { green: row.green, red: row.red });
}
const tableRows = [...latest.entries()].sort((a, b) => (a[1].green ?? 0) - (b[1].green ?? 0));
return (
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0}>
<div className="flex-1 overflow-y-auto">
<table className="w-full text-xs text-gray-300">
<thead className="sticky top-0 bg-gray-800">
<tr>
<th className="text-left px-2 py-1">Item</th>
<th className="text-left px-2 py-1">Combinator</th>
{config.signal_type !== 'red' && <th className="text-right px-2 py-1 text-green-400">Green</th>}
{config.signal_type !== 'green' && <th className="text-right px-2 py-1 text-red-400">Red (NP)</th>}
</tr>
</thead>
<tbody>
{tableRows.map(([key, vals]) => {
const [combinator, item_key] = key.split('::');
return (
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
<td className="px-2 py-0.5">{resolveName(item_key, localeMap)}</td>
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
{config.signal_type !== 'red' && (
<td className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}>
{vals.green?.toLocaleString() ?? '--'}
</td>
)}
{config.signal_type !== 'green' && (
<td className="px-2 py-0.5 text-right font-mono text-orange-400">
{vals.red?.toLocaleString() ?? '--'}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</CardShell>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot';
import { CardShell } from './CardShell';
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
import { usePlot } from './usePlot';
import type { ChartConfig, UpsRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
interface Props {
config: ChartConfig;
upsRows: UpsRow[];
timeMode: TimeMode;
onEdit: () => void;
onDelete: () => void;
}
export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }: Props) {
const { containerRef, legendRef } = usePlot(
(el, w, h, lRef) => {
if (upsRows.length < 2) return null;
const sorted = [...upsRows].sort((a, b) =>
timeMode === 'tick'
? a.game_tick - b.game_tick
: new Date(a.real_time).getTime() - new Date(b.real_time).getTime(),
);
const xs = sorted.map(r => timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000);
const ys = sorted.map(r => r.ups);
const xAxis: uPlot.Axis = {
...AXIS_BASE,
...(timeMode === 'real' && {
values: (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
}),
};
return new uPlot({
width: w,
height: h,
cursor: CURSOR_NO_DRAG,
legend: {
mount: (_u, legendEl) => {
if (lRef.current) lRef.current.appendChild(legendEl);
},
},
series: [
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
],
axes: [xAxis, { ...AXIS_BASE }],
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
}, [xs, ys], el);
},
[upsRows, config.y_min, config.y_max, config.y_scale, timeMode],
);
return (
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={upsRows.length === 0} legendContainerRef={legendRef}>
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
</CardShell>
);
}

View File

@@ -0,0 +1,25 @@
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow, UpsRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
import UpsChart from './UpsChart';
import SignalsChart from './SignalsChart';
import TableViz from './TableViz';
import DividerCard from './DividerCard';
export interface ChartCardProps {
config: ChartConfig;
rows: SignalRow[];
upsRows: UpsRow[];
sessions: SessionBoundary[];
alerts: AlertConfig[];
timeMode: TimeMode;
onEdit: () => void;
onDelete: () => void;
}
export default function ChartCard(props: ChartCardProps) {
const { config } = props;
if (config.chart_type === 'divider') return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
if (config.chart_type === 'ups') return <UpsChart {...props} />;
if (config.viz_type === 'table') return <TableViz config={props.config} rows={props.rows} onEdit={props.onEdit} onDelete={props.onDelete} />;
return <SignalsChart {...props} />;
}

View File

@@ -0,0 +1,178 @@
import uPlot from 'uplot';
import type { ChartConfig } from '@/lib/types';
// --- Color helpers ---
function djb2(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
return h >>> 0;
}
function hslColor(key: string): string {
const hue = djb2(key) % 360;
return `hsl(${hue},70%,60%)`;
}
const SEMANTIC_GREEN = '#4ade80';
const SEMANTIC_RED = '#f87171';
export interface SeriesStyle {
color: string;
dash: number[] | undefined;
}
export function getSeriesStyle(
key: string,
uCombs: number,
uItems: number,
uSigs: number,
): SeriesStyle {
const [combinator, item_key, sig] = key.split('::');
if (uCombs === 1 && uItems === 1) {
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
}
if (uItems > 1) {
return { color: hslColor(item_key), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
}
return { color: hslColor(combinator), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
}
/**
* Builds a human-readable series label.
* @param displayName Pre-resolved localized name for the item.
*/
export function getSeriesLabel(
key: string,
uCombs: number,
uItems: number,
uSigs: number,
displayName: string,
): string {
const [combinator, , sig] = key.split('::');
if (uCombs === 1 && uItems === 1) return sig;
const parts: string[] = [];
if (uItems > 1) parts.push(displayName);
if (uCombs > 1) parts.push(`(${combinator})`);
if (uSigs > 1) parts.push(`[${sig}]`);
return parts.join(' ');
}
// --- Axis / scale helpers ---
export const AXIS_BASE: uPlot.Axis = {
stroke: '#9ca3af',
ticks: { stroke: '#374151' },
grid: { stroke: '#1f2937' },
};
export const CURSOR_NO_DRAG: uPlot.Cursor = {
drag: { x: false, y: false },
};
/**
* Builds a uPlot y-scale.
* 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives.
*/
export function makeYScale(
yMin: number | null,
yMax: number | null,
yScale: ChartConfig['y_scale'] = 'linear',
): uPlot.Scale {
if (yScale === 'log') {
return {
distr: 4,
asinh: 1,
...(yMin !== null || yMax !== null ? {
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
} : {}),
};
}
if (yMin === null && yMax === null) return { dir: 1 };
return {
dir: 1,
range: (_u, dataMin, dataMax) => {
const lo = yMin ?? (dataMin ?? 0);
const hi = yMax ?? (dataMax ?? 1);
if (lo === hi) return [lo - 1, hi + 1];
const pad = (yMin == null || yMax == null) ? Math.abs(hi - lo) * 0.05 : 0;
return [lo - pad, hi + pad];
},
};
}
export function makeAnnotationHooks(
sessionXs: number[],
alertThresholds: number[],
): uPlot.Options['hooks'] {
return {
draw: [(u) => {
const { ctx, bbox } = u;
ctx.save();
ctx.strokeStyle = 'rgba(251,191,36,0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
for (const sx of sessionXs) {
const cx = Math.round(u.valToPos(sx, 'x', true));
ctx.beginPath(); ctx.moveTo(cx, bbox.top); ctx.lineTo(cx, bbox.top + bbox.height); ctx.stroke();
}
ctx.strokeStyle = 'rgba(248,113,113,0.7)';
ctx.setLineDash([6, 3]);
for (const t of alertThresholds) {
const cy = Math.round(u.valToPos(t, 'y', true));
ctx.beginPath(); ctx.moveTo(bbox.left, cy); ctx.lineTo(bbox.left + bbox.width, cy); ctx.stroke();
}
ctx.restore();
}],
};
}
export function makeSignalsSeries(
keys: string[],
timeMode: 'real' | 'tick',
resolveName: (key: string) => string,
): uPlot.Series[] {
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
const uSigs = new Set(keys.map(k => k.split('::')[2])).size;
const xSeries: uPlot.Series = {
label: timeMode === 'tick' ? 'Tick' : 'Time',
...(timeMode === 'real' && {
value: (_u: uPlot, v: number | null) =>
v == null ? '--' : new Date(v * 1000).toLocaleTimeString(),
}),
};
return [
xSeries,
...keys.map(k => {
const [, item_key] = k.split('::');
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs);
return {
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
stroke: color,
width: 1.5,
dash,
};
}),
];
}
export function makeSignalsAxes(timeMode: 'real' | 'tick'): uPlot.Axis[] {
return [
{
...AXIS_BASE,
...(timeMode === 'real' && {
values: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
}),
},
{ ...AXIS_BASE },
];
}

View File

@@ -0,0 +1,43 @@
import type { SignalRow, ChartConfig } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
const MAX_SERIES = 80;
export interface SeriesData {
keys: string[];
allXs: number[];
data: (number | undefined)[][];
}
export function buildSeriesData(
rows: SignalRow[],
signalType: ChartConfig['signal_type'],
timeMode: TimeMode,
): SeriesData | null {
const seriesMap = new Map<string, Map<number, number>>();
for (const row of rows) {
for (const [sig, val] of [['green', row.green], ['red', row.red]] as ['green' | 'red', number | undefined][]) {
if (signalType !== 'both' && signalType !== sig) continue;
if (val === undefined) continue;
const key = `${row.combinator}::${row.item_key}::${sig}`;
const x = timeMode === 'tick'
? parseInt(row.game_tick, 10)
: new Date(row.real_time).getTime() / 1000;
if (!seriesMap.has(key)) seriesMap.set(key, new Map());
seriesMap.get(key)!.set(x, val);
}
}
if (seriesMap.size === 0) return null;
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
const allXs = [...new Set(keys.flatMap(k => [...seriesMap.get(k)!.keys()]))].sort((a, b) => a - b);
const data = keys.map(k => {
const m = seriesMap.get(k)!;
return allXs.map(x => m.get(x)); // undefined = gap
});
return { keys, allXs, data };
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef } from 'react';
import uPlot from 'uplot';
export type BuildFn = (
el: HTMLDivElement,
w: number,
h: number,
legendRef: React.RefObject<HTMLDivElement>,
) => uPlot | null;
/** Converts a data index to the pixel x position uPlot expects for setCursor */
function idxToPixel(plot: uPlot, idx: number): number {
const x = plot.data[0]?.[idx];
if (x == null) return -10;
return plot.valToPos(x, 'x');
}
/**
* Manages full uPlot lifecycle:
* - builds/rebuilds on dep change or resize
* - mounts legend into legendRef container
* - pins cursor to last data point on init (legend shows latest values)
* - restores last-point legend on mouseleave
*/
export function usePlot(
build: BuildFn,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deps: any[],
): { containerRef: React.RefObject<HTMLDivElement | null>; legendRef: React.RefObject<HTMLDivElement> } {
const containerRef = useRef<HTMLDivElement | null>(null);
const legendRef = useRef<HTMLDivElement>(null!);
const plotRef = useRef<uPlot | null>(null);
const lastIdxRef = useRef<number>(0);
const rebuild = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const w = el.clientWidth, h = el.clientHeight;
if (w < 10 || h < 10) return;
plotRef.current?.destroy();
if (legendRef.current) legendRef.current.innerHTML = '';
const plot = build(el, w, h, legendRef);
plotRef.current = plot;
if (plot) {
const lastIdx = Math.max(0, (plot.data[0]?.length ?? 1) - 1);
lastIdxRef.current = lastIdx;
// Pin legend to latest data point
plot.setCursor({ left: idxToPixel(plot, lastIdx), top: -10 });
// Defer mouseleave — prevents React hydration events firing before lastIdxRef set
requestAnimationFrame(() => {
plot.over.addEventListener('mouseleave', () => {
const p = plotRef.current;
if (!p) return;
p.setCursor({ left: idxToPixel(p, lastIdxRef.current), top: -10 });
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
rebuild();
return () => { plotRef.current?.destroy(); plotRef.current = null; };
}, [rebuild]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(rebuild);
ro.observe(el);
return () => ro.disconnect();
}, [rebuild]);
return { containerRef, legendRef };
}

View File

@@ -0,0 +1,201 @@
'use client';
import { useState } from 'react';
import { useApp } from '@/lib/context';
import { resolveKey } from '@/lib/localization';
import type { ChartConfig } from '@/lib/types';
type DraftChart = Omit<ChartConfig, 'id'>;
interface Props {
initial?: ChartConfig;
onSave: (draft: DraftChart) => void;
onClose: () => void;
}
const inputCls =
'w-full bg-gray-800 border border-gray-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500';
/**
* Normalizes a comma-separated list of user tokens (localized names or raw keys)
* to raw item_keys. Unknown tokens are kept as-is.
*/
function normalizeList(raw: string, reverseMap: Map<string, string>): string[] | null {
const arr = raw.split(',').map(x => x.trim()).filter(Boolean);
if (arr.length === 0) return null;
return arr.map(t => resolveKey(t, reverseMap));
}
export default function ChartEditor({ initial, onSave, onClose }: Props) {
const { reverseMap } = useApp();
const [title, setTitle] = useState(initial?.title ?? '');
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(initial?.chart_type ?? 'signals');
const [vizType, setVizType] = useState<ChartConfig['viz_type']>(initial?.viz_type ?? 'line');
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(initial?.signal_type ?? 'both');
const [combinators, setCombinators] = useState((initial?.filter_combinators ?? []).join(', '));
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'time');
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
const [yScale, setYScale] = useState<ChartConfig['y_scale']>(initial?.y_scale ?? 'linear');
const [width, setWidth] = useState(initial?.width ?? 2);
const [height, setHeight] = useState(initial?.height ?? 4);
function splitCombinators(): string[] | null {
const arr = combinators.split(',').map(x => x.trim()).filter(Boolean);
return arr.length > 0 ? arr : null;
}
function handleSave() {
if (!title.trim()) return;
// Regex mode: store pattern exactly as typed — server expands via matchKeys at query time.
// Non-regex mode: resolve each comma-token (localized name or raw key) to raw key.
const filter_items = useRegex
? (whitelist.trim() ? [whitelist.trim()] : null)
: normalizeList(whitelist, reverseMap);
const filter_items_exclude = useRegex
? (blacklist.trim() ? [blacklist.trim()] : null)
: normalizeList(blacklist, reverseMap);
onSave({
title: title.trim(),
chart_type: chartType,
viz_type: chartType === 'divider' ? 'line' : vizType,
signal_type: signalType,
pos_x: initial?.pos_x ?? 0,
pos_y: initial?.pos_y ?? 0,
width, height,
filter_combinators: chartType === 'divider' ? null : splitCombinators(),
filter_items: chartType === 'divider' ? null : filter_items,
filter_items_exclude: chartType === 'divider' ? null : filter_items_exclude,
filter_items_regex: useRegex,
order_by: orderBy,
series_limit: seriesLimit,
y_min: yMin !== '' ? parseFloat(yMin) : null,
y_max: yMax !== '' ? parseFloat(yMax) : null,
y_scale: yScale,
});
}
const isSignals = chartType === 'signals';
const isDivider = chartType === 'divider';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md shadow-xl overflow-y-auto max-h-[90vh]"
onClick={e => e.stopPropagation()}
>
<h2 className="text-lg font-semibold text-white mb-4">
{initial ? 'Edit Chart' : 'New Chart'}
</h2>
<label className="block text-sm text-gray-400 mb-1">Title</label>
<input value={title} onChange={e => setTitle(e.target.value)} className={`${inputCls} mb-3`} />
<label className="block text-sm text-gray-400 mb-1">Chart Type</label>
<select value={chartType} onChange={e => setChartType(e.target.value as ChartConfig['chart_type'])} className={`${inputCls} mb-3`}>
<option value="signals">Signals</option>
<option value="ups">UPS / Game Tick Rate</option>
<option value="divider">Divider / Section Label</option>
</select>
{!isDivider && <>
<label className="block text-sm text-gray-400 mb-1">Visualization</label>
<select value={vizType} onChange={e => setVizType(e.target.value as ChartConfig['viz_type'])} className={`${inputCls} mb-3`}>
<option value="line">Line Chart</option>
<option value="table">Table</option>
</select>
</>}
{isSignals && <>
<label className="block text-sm text-gray-400 mb-1">Signal</label>
<select value={signalType} onChange={e => setSignalType(e.target.value as ChartConfig['signal_type'])} className={`${inputCls} mb-3`}>
<option value="both">Both (green + red)</option>
<option value="green">Green only</option>
<option value="red">Red only</option>
</select>
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
<select value={orderBy} onChange={e => setOrderBy(e.target.value as ChartConfig['order_by'])} className={`${inputCls} mb-3`}>
<option value="time">Time (no sort)</option>
<option value="value_asc">Latest lowest values</option>
<option value="value_desc">Latest highest values</option>
<option value="delta_asc">Biggest decrease (last 10 min)</option>
<option value="delta_desc">Biggest increase (last 10 min)</option>
</select>
<label className="block text-sm text-gray-400 mb-1">Max series (lines)</label>
<input type="number" min={1} max={200} value={seriesLimit}
onChange={e => setSeriesLimit(Number(e.target.value))}
className={`${inputCls} mb-3`} />
<label className="block text-sm text-gray-400 mb-1">Combinators (comma-separated, empty = all)</label>
<input value={combinators} onChange={e => setCombinators(e.target.value)}
placeholder="nauvis, nauvis-orbit" className={`${inputCls} mb-3`} />
<div className="flex items-center gap-2 mb-2">
<label className="text-sm text-gray-400 flex-1">Item filters</label>
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
<input type="checkbox" checked={useRegex} onChange={e => setUseRegex(e.target.checked)}
className="accent-indigo-500" />
Use regex
</label>
</div>
<input value={whitelist} onChange={e => setWhitelist(e.target.value)}
placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'}
className={`${inputCls} mb-1`} />
<p className="text-xs text-gray-500 mb-2">Whitelist localized names or item keys accepted (empty = all)</p>
<input value={blacklist} onChange={e => setBlacklist(e.target.value)}
placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'}
className={`${inputCls} mb-1`} />
<p className="text-xs text-gray-500 mb-3">Blacklist localized names or item keys accepted</p>
</>}
{!isDivider && <>
<div className="flex gap-3 mb-3">
<div className="flex-1">
<label className="block text-sm text-gray-400 mb-1">Y Min (empty = auto)</label>
<input type="number" value={yMin} onChange={e => setYMin(e.target.value)}
placeholder="auto" className={inputCls} />
</div>
<div className="flex-1">
<label className="block text-sm text-gray-400 mb-1">Y Max (empty = auto)</label>
<input type="number" value={yMax} onChange={e => setYMax(e.target.value)}
placeholder="auto" className={inputCls} />
</div>
</div>
<label className="block text-sm text-gray-400 mb-1">Y Scale</label>
<select value={yScale} onChange={e => setYScale(e.target.value as ChartConfig['y_scale'])} className={`${inputCls} mb-3`}>
<option value="linear">Linear</option>
<option value="log">Symmetric Log (arcsinh)</option>
</select>
</>}
<div className="flex gap-3 mb-4">
<div className="flex-1">
<label className="block text-sm text-gray-400 mb-1">Width (16 cols)</label>
<input type="number" min={1} max={6} value={width}
onChange={e => setWidth(Number(e.target.value))} className={inputCls} />
</div>
<div className="flex-1">
<label className="block text-sm text-gray-400 mb-1">Height (rows)</label>
<input type="number" min={2} max={20} value={height}
onChange={e => setHeight(Number(e.target.value))} className={inputCls} />
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700">Cancel</button>
<button onClick={handleSave} className="px-4 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded">Save</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import GridLayout from 'react-grid-layout';
import type { Layout, LayoutItem } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useApp } from '@/lib/context';
import { fetchCharts, createChart, updateChart, deleteChart, fetchSignals, fetchSessions, fetchUps } from '@/lib/api';
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
import ChartCard from './ChartCard';
import ChartEditor from './ChartEditor';
const COLS = 6;
const ROW_HEIGHT = 80;
interface Props {
alerts: AlertConfig[];
}
export default function Dashboard({ alerts }: Props) {
const { timeRange, timeMode, getFromTo } = useApp();
const [charts, setCharts] = useState<ChartConfig[]>([]);
const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map());
const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map());
const [sessions, setSessions] = useState<SessionBoundary[]>([]);
const [editingChart, setEditingChart] = useState<ChartConfig | null>(null);
const [creatingChart, setCreatingChart] = useState(false);
const [containerWidth, setContainerWidth] = useState(1200);
const containerRef = useRef<HTMLDivElement>(null);
const chartsRef = useRef<ChartConfig[]>([]);
const refreshingRef = useRef(false);
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { chartsRef.current = charts; }, [charts]);
useEffect(() => { fetchCharts().then(setCharts); }, []);
const refreshData = useCallback(async () => {
if (refreshingRef.current) return;
const current = chartsRef.current;
if (current.length === 0) return;
refreshingRef.current = true;
try {
const { from, to } = getFromTo();
const signalCharts = current.filter(c => c.chart_type === 'signals');
const upsCharts = current.filter(c => c.chart_type === 'ups');
if (signalCharts.length === 0 && upsCharts.length === 0) return;
const [newSessions, ...results] = await Promise.all([
fetchSessions(from, to),
...signalCharts.map(c => fetchSignals({
combinator: c.filter_combinators ?? undefined,
item: c.filter_items ?? undefined,
exclude: c.filter_items_exclude ?? undefined,
signal: c.signal_type,
time_mode: timeMode,
from, to,
regex: c.filter_items_regex || undefined,
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}),
})),
...upsCharts.map(c => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
]);
setSessions(newSessions as SessionBoundary[]);
const sigMap = new Map<string, SignalRow[]>();
signalCharts.forEach((c, i) => sigMap.set(c.id, results[i] as SignalRow[]));
setSignalData(sigMap);
const upsMap = new Map<string, UpsRow[]>();
upsCharts.forEach((c, i) => upsMap.set(c.id, results[signalCharts.length + i] as UpsRow[]));
setUpsData(upsMap);
} finally {
refreshingRef.current = false;
}
}, [getFromTo, timeMode]);
useEffect(() => {
if (charts.length > 0) refreshData();
}, [charts, timeRange, timeMode, refreshData]);
useEffect(() => {
const id = setInterval(refreshData, 30_000);
return () => clearInterval(id);
}, [refreshData]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth));
ro.observe(el);
return () => ro.disconnect();
}, []);
async function handleCreate(draft: Omit<ChartConfig, 'id'>) {
const created = await createChart(draft);
setCharts(cs => [...cs, created]);
setCreatingChart(false);
}
async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) {
const updated = await updateChart(id, draft);
setCharts(cs => cs.map(c => c.id === id ? updated : c));
setEditingChart(null);
}
async function handleDelete(id: string) {
await deleteChart(id);
setCharts(cs => cs.filter(c => c.id !== id));
}
function handleLayoutChange(layout: Layout) {
const items = layout as readonly LayoutItem[];
const changed = items.filter(item => {
const chart = chartsRef.current.find(c => c.id === item.i);
return chart && (
chart.pos_x !== item.x || chart.pos_y !== item.y ||
chart.width !== item.w || chart.height !== item.h
);
});
if (changed.length === 0) return;
setCharts(cs => cs.map(c => {
const l = changed.find(item => item.i === c.id);
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c;
}));
if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current);
layoutSaveTimer.current = setTimeout(() => {
changed.forEach(item =>
updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }),
);
}, 500);
}
const layout: Layout = charts.map(c => ({
i: c.id, x: c.pos_x, y: c.pos_y, w: c.width, h: c.height,
minW: 1,
minH: c.chart_type === 'divider' ? 1 : 2,
}));
return (
<div ref={containerRef} className="w-full">
<GridLayout
layout={layout}
width={containerWidth}
onLayoutChange={handleLayoutChange}
gridConfig={{ cols: COLS, rowHeight: ROW_HEIGHT, margin: [8, 8] }}
dragConfig={{ handle: '.drag-handle' }}
>
{charts.map(c => (
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
<ChartCard
config={c}
rows={signalData.get(c.id) ?? []}
upsRows={upsData.get(c.id) ?? []}
sessions={sessions}
alerts={alerts.filter(a =>
!c.filter_combinators || !a.combinator ||
c.filter_combinators.includes(a.combinator),
)}
timeMode={timeMode}
onEdit={() => setEditingChart(c)}
onDelete={() => handleDelete(c.id)}
/>
</div>
))}
</GridLayout>
<button
onClick={() => setCreatingChart(true)}
className="fixed bottom-6 right-6 w-12 h-12 bg-indigo-600 hover:bg-indigo-500 text-white rounded-full text-2xl shadow-lg flex items-center justify-center z-30"
>
+
</button>
{creatingChart && (
<ChartEditor onSave={handleCreate} onClose={() => setCreatingChart(false)} />
)}
{editingChart && (
<ChartEditor
initial={editingChart}
onSave={draft => handleUpdate(editingChart.id, draft)}
onClose={() => setEditingChart(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { useApp } from '@/lib/context';
import type { TimeRange, TimeMode } from '@/lib/types';
const RANGES: TimeRange[] = ['30m', '1h', '6h', '24h', '7d', '30d'];
export default function TimeRangeSelector() {
const { timeRange, setTimeRange, timeMode, setTimeMode } = useApp();
return (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex rounded overflow-hidden border border-gray-700">
{RANGES.map(r => (
<button
key={r}
onClick={() => setTimeRange(r)}
className={`px-3 py-1 text-sm font-mono transition-colors ${
timeRange === r
? 'bg-indigo-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{r}
</button>
))}
</div>
<div className="flex rounded overflow-hidden border border-gray-700">
{(['real', 'tick'] as TimeMode[]).map(m => (
<button
key={m}
onClick={() => setTimeMode(m)}
className={`px-3 py-1 text-sm transition-colors ${
timeMode === m
? 'bg-indigo-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{m === 'real' ? 'Real Time' : 'Game Tick'}
</button>
))}
</div>
</div>
);
}

16
web/docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
db:
image: timescale/timescaledb:latest-pg16
restart: unless-stopped
environment:
POSTGRES_USER: factorio
POSTGRES_PASSWORD: factorio
POSTGRES_DB: factorio
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
volumes:
db_data:

65
web/lib/api.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { ChartConfig, AlertConfig, TriggeredAlert, SignalRow, SessionBoundary, UpsRow, TimeMode } from './types';
let _token = '';
export function setToken(token: string) { _token = token; }
function url(path: string, params: Record<string, string | string[] | boolean | number | undefined> = {}) {
const u = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000');
u.searchParams.set('token', _token);
for (const [k, v] of Object.entries(params)) {
if (v === undefined) continue;
if (Array.isArray(v)) v.forEach(val => u.searchParams.append(k, String(val)));
else u.searchParams.set(k, String(v));
}
return u.toString();
}
async function get<T>(path: string, params?: Record<string, string | string[] | boolean | number | undefined>): Promise<T> {
const res = await fetch(url(path, params));
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function mutate<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(url(path), {
method,
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
export function fetchSignals(params: {
combinator?: string[];
item?: string[];
exclude?: string[];
signal?: string;
time_mode?: TimeMode;
from?: string;
to?: string;
regex?: boolean;
order_by?: string;
limit?: number;
}): Promise<SignalRow[]> {
return get('/api/signals', params as Record<string, string | string[]>);
}
export function fetchUps(params: { combinator?: string; from?: string; to?: string }): Promise<UpsRow[]> {
return get('/api/ups', params);
}
export function fetchSessions(from: string, to: string): Promise<SessionBoundary[]> {
return get('/api/sessions', { from, to });
}
export function fetchCharts(): Promise<ChartConfig[]> { return get('/api/charts'); }
export function createChart(body: Omit<ChartConfig, 'id'>): Promise<ChartConfig> { return mutate('POST', '/api/charts', body); }
export function updateChart(id: string, body: Partial<ChartConfig>): Promise<ChartConfig> { return mutate('PUT', `/api/charts/${id}`, body); }
export function deleteChart(id: string): Promise<{ ok: boolean }> { return mutate('DELETE', `/api/charts/${id}`); }
export function fetchAlerts(): Promise<AlertConfig[]> { return get('/api/alerts'); }
export function createAlert(body: Omit<AlertConfig, 'id' | 'active'>): Promise<AlertConfig> { return mutate('POST', '/api/alerts', body); }
export function updateAlert(id: string, body: Partial<AlertConfig>): Promise<AlertConfig> { return mutate('PUT', `/api/alerts/${id}`, body); }
export function deleteAlert(id: string): Promise<{ ok: boolean }> { return mutate('DELETE', `/api/alerts/${id}`); }
export function checkAlerts(): Promise<TriggeredAlert[]> { return get('/api/alerts/check'); }

26
web/lib/apiHelpers.ts Normal file
View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAuthorized, unauthorized } from './auth';
type RouteContext = { params: Promise<Record<string, string>> };
type Handler = (req: NextRequest, ctx: RouteContext) => Promise<NextResponse>;
/**
* Wraps a route handler with auth checking and unified error handling.
*
* @example
* export const GET = withAuth(async (req) => {
* const rows = await pool.query('...');
* return NextResponse.json(rows);
* });
*/
export function withAuth(handler: Handler): Handler {
return async (req, ctx) => {
if (!isAuthorized(req)) return unauthorized();
try {
return await handler(req, ctx);
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
}

22
web/lib/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Returns true if the request carries a valid API token.
* Accepts token via `?token=` query param or `Authorization: Bearer <token>` header.
*/
export function isAuthorized(req: NextRequest): boolean {
const expected = process.env.API_TOKEN;
if (!expected) return false;
const queryToken = req.nextUrl.searchParams.get('token');
if (queryToken === expected) return true;
const authHeader = req.headers.get('authorization');
if (authHeader === `Bearer ${expected}`) return true;
return false;
}
export function unauthorized(): NextResponse {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

83
web/lib/context.tsx Normal file
View File

@@ -0,0 +1,83 @@
'use client';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import type { TimeRange, TimeMode, TriggeredAlert } from './types';
import { TIME_RANGE_MS } from './types';
import { checkAlerts } from './api';
import { buildReverseMap } from './localization';
import type { LocaleMap, ReverseMap } from './localization';
interface AppContextValue {
timeRange: TimeRange;
setTimeRange: (r: TimeRange) => void;
timeMode: TimeMode;
setTimeMode: (m: TimeMode) => void;
triggeredAlerts: TriggeredAlert[];
refreshAlerts: () => Promise<void>;
getFromTo: () => { from: string; to: string };
localeMap: LocaleMap;
reverseMap: ReverseMap;
}
const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({
token: _token,
localeMap,
children,
}: {
token: string;
localeMap: LocaleMap;
children: React.ReactNode;
}) {
const [timeRange, setTimeRange] = useState<TimeRange>('6h');
const [timeMode, setTimeMode] = useState<TimeMode>('real');
const [triggeredAlerts, setTriggeredAlerts] = useState<TriggeredAlert[]>([]);
const reverseMap = buildReverseMap(localeMap);
const getFromTo = useCallback(() => {
const to = new Date();
const from = new Date(to.getTime() - TIME_RANGE_MS[timeRange]);
return { from: from.toISOString(), to: to.toISOString() };
}, [timeRange]);
const refreshAlerts = useCallback(async () => {
setTriggeredAlerts(await checkAlerts());
}, []);
useEffect(() => {
let cancelled = false;
const poll = () => checkAlerts().then(a => { if (!cancelled) setTriggeredAlerts(a); });
poll();
const id = setInterval(poll, 30_000);
return () => { cancelled = true; clearInterval(id); };
}, []);
return (
<AppContext.Provider
value={{
timeRange, setTimeRange,
timeMode, setTimeMode,
triggeredAlerts, refreshAlerts,
getFromTo,
localeMap, reverseMap,
}}
>
{children}
</AppContext.Provider>
);
}
/** Must be used within {@link AppProvider}. */
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppProvider');
return ctx;
}

14
web/lib/db.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Pool } from 'pg';
declare global {
// eslint-disable-next-line no-var
var __pgPool: Pool | undefined;
}
const pool = globalThis.__pgPool ?? new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== 'production') {
globalThis.__pgPool = pool;
}
export default pool;

29
web/lib/localeServer.ts Normal file
View File

@@ -0,0 +1,29 @@
import fs from 'fs';
import path from 'path';
import { parseCsv } from './localization';
import type { LocaleMap } from './localization';
declare global { var __serverLocaleCache: LocaleMap | undefined; }
/**
* Loads and merges EN + DE locale CSVs from the public directory.
* Cached for the lifetime of the server process.
* Server-only — never import from client components.
*/
export function getServerLocaleMap(): LocaleMap {
if (globalThis.__serverLocaleCache) return globalThis.__serverLocaleCache;
const pub = path.join(process.cwd(), 'public');
function load(filename: string): LocaleMap {
try {
return parseCsv(fs.readFileSync(path.join(pub, filename), 'utf8'));
} catch {
return new Map();
}
}
const merged: LocaleMap = new Map([...load('factorio_english_items.csv'), ...load('factorio_german_items.csv')]);
globalThis.__serverLocaleCache = merged;
return merged;
}

103
web/lib/localization.ts Normal file
View File

@@ -0,0 +1,103 @@
export type LocaleMap = Map<string, string>;
export type ReverseMap = Map<string, string>;
/** Parses a 3-column CSV (section, item_key, localized_name). */
export function parseCsv(text: string): LocaleMap {
const map: LocaleMap = new Map();
const lines = text.split(/\r?\n/).slice(1);
for (const line of lines) {
if (!line.trim()) continue;
const cols = splitCsvLine(line);
if (cols.length >= 3) map.set(cols[1], cols[2]);
}
return map;
}
function splitCsvLine(line: string): string[] {
const cols: string[] = [];
let cur = '';
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
else inQuote = !inQuote;
} else if (ch === ',' && !inQuote) {
cols.push(cur);
cur = '';
} else {
cur += ch;
}
}
cols.push(cur);
return cols;
}
declare global { var __localeCache: LocaleMap | undefined; }
async function loadCsv(path: string): Promise<LocaleMap> {
try {
const res = await fetch(path);
return res.ok ? parseCsv(await res.text()) : new Map();
} catch {
return new Map();
}
}
/**
* Fetches and merges DE + EN locale CSVs.
* Result is cached for the lifetime of the page.
*/
export async function getLocaleMap(): Promise<LocaleMap> {
if (globalThis.__localeCache) return globalThis.__localeCache;
const [en, de] = await Promise.all([
loadCsv('/factorio_english_items.csv'),
loadCsv('/factorio_german_items.csv'),
]);
const merged: LocaleMap = new Map([...en, ...de]);
globalThis.__localeCache = merged;
return merged;
}
/** Resolves an `item_key` to its localized display name, falling back to the key itself. */
export function resolveName(key: string, map: LocaleMap): string {
return map.get(key) ?? key;
}
/**
* Builds a reverse lookup: lowercased localized name → item_key.
* Used for normalizing user input back to raw keys.
*/
export function buildReverseMap(map: LocaleMap): ReverseMap {
const rev: ReverseMap = new Map();
for (const [key, name] of map) {
rev.set(name.toLowerCase(), key);
}
return rev;
}
/**
* Resolves a single user-typed token (localized name or raw key) to a raw item_key.
* Falls back to the input itself if no match is found.
*/
export function resolveKey(input: string, rev: ReverseMap): string {
return rev.get(input.toLowerCase()) ?? input;
}
/**
* Applies a regex pattern against both raw item_keys and localized names,
* returning the union of all matching raw item_keys.
*/
export function matchKeys(pattern: string, map: LocaleMap): string[] {
let re: RegExp;
try {
re = new RegExp(pattern, 'i');
} catch {
return [];
}
const result = new Set<string>();
for (const [key, name] of map) {
if (re.test(key) || re.test(name)) result.add(key);
}
return [...result];
}

38
web/lib/sessions.ts Normal file
View File

@@ -0,0 +1,38 @@
import pool from '@/lib/db';
import type { SessionBoundary } from '@/lib/types';
/**
* Returns session-start timestamps where any gap > 30 min exists
* in the global tick_timing timeline.
*/
export async function getSessionBoundaries(
from: Date,
to: Date,
): Promise<SessionBoundary[]> {
const result = await pool.query<{ real_time: Date; game_tick: string }>(
`SELECT real_time, game_tick
FROM tick_timing
WHERE real_time BETWEEN $1 AND $2
ORDER BY real_time ASC`,
[from, to],
);
const rows = result.rows;
if (rows.length === 0) return [];
const boundaries: SessionBoundary[] = [{
real_time: rows[0].real_time.toISOString(),
game_tick: parseInt(rows[0].game_tick, 10),
}];
for (let i = 1; i < rows.length; i++) {
if (rows[i].real_time.getTime() - rows[i - 1].real_time.getTime() > 30 * 60 * 1000) {
boundaries.push({
real_time: rows[i].real_time.toISOString(),
game_tick: parseInt(rows[i].game_tick, 10),
});
}
}
return boundaries;
}

71
web/lib/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface ChartConfig {
id: string;
title: string;
pos_x: number;
pos_y: number;
width: number;
height: number;
signal_type: 'green' | 'red' | 'both';
chart_type: 'signals' | 'ups' | 'divider';
viz_type: 'line' | 'table';
filter_combinators: string[] | null;
filter_items: string[] | null;
filter_items_exclude: string[] | null;
filter_items_regex: boolean;
y_min: number | null;
y_max: number | null;
y_scale: 'linear' | 'log';
series_limit: number;
order_by: 'time' | 'value_asc' | 'value_desc' | 'abs_desc' | 'delta_asc' | 'delta_desc';
}
export interface AlertConfig {
id: string;
item_key: string;
item_key_is_regex: boolean;
combinator: string | null;
signal_type: 'green' | 'red';
condition: 'above' | 'below';
threshold: number;
active: boolean;
}
export interface TriggeredAlert extends AlertConfig {
current_value: number;
combinator_match: string;
/** Actual matched item_key — differs from item_key when item_key_is_regex=true */
matched_item_key: string;
}
export interface SignalRow {
real_time: string;
game_tick: string;
combinator: string;
item_key: string;
green?: number;
red?: number;
}
export interface UpsRow {
real_time: string;
game_tick: number;
combinator: string;
ups: number;
}
export interface SessionBoundary {
real_time: string;
game_tick: number;
}
export type TimeMode = 'real' | 'tick';
export type TimeRange = '30m' | '1h' | '6h' | '24h' | '7d' | '30d';
export const TIME_RANGE_MS: Record<TimeRange, number> = {
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
};

View File

@@ -0,0 +1,94 @@
/** @type {import('node-pg-migrate').MigrationBuilder} */
exports.up = (pgm) => {
pgm.sql(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`);
pgm.createTable('signals', {
real_time: { type: 'timestamptz', notNull: true },
game_tick: { type: 'bigint', notNull: true },
combinator: { type: 'text', notNull: true },
item_key: { type: 'text', notNull: true },
green: { type: 'integer', notNull: true, default: 0 },
red: { type: 'integer', notNull: true, default: 0 },
logistic: { type: 'integer' },
}, { ifNotExists: true });
pgm.sql(`SELECT create_hypertable('signals', 'real_time', if_not_exists => true)`);
pgm.sql(`CREATE INDEX IF NOT EXISTS signals_combinator_item_key_real_time_idx ON signals (combinator, item_key, real_time DESC)`);
pgm.sql(`CREATE INDEX IF NOT EXISTS signals_game_tick_idx ON signals (game_tick DESC)`);
pgm.sql(`SELECT add_retention_policy('signals', INTERVAL '30 days', if_not_exists => true)`);
pgm.createTable('tick_timing', {
real_time: { type: 'timestamptz', notNull: true },
game_tick: { type: 'bigint', notNull: true },
combinator: { type: 'text', notNull: true },
}, { ifNotExists: true });
pgm.sql(`SELECT create_hypertable('tick_timing', 'real_time', if_not_exists => true)`);
pgm.sql(`CREATE INDEX IF NOT EXISTS tick_timing_combinator_real_time_idx ON tick_timing (combinator, real_time DESC)`);
pgm.sql(`SELECT add_retention_policy('tick_timing', INTERVAL '30 days', if_not_exists => true)`);
pgm.createTable('charts', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
title: { type: 'text', notNull: true },
pos_x: { type: 'integer', notNull: true, default: 0 },
pos_y: { type: 'integer', notNull: true, default: 0 },
width: { type: 'integer', notNull: true, default: 2 },
height: { type: 'integer', notNull: true, default: 4 },
signal_type: { type: 'text', notNull: true, default: 'both' },
chart_type: { type: 'text', notNull: true, default: 'signals' },
viz_type: { type: 'text', notNull: true, default: 'line' },
filter_combinators: { type: 'text[]' },
filter_items: { type: 'text[]' },
filter_items_exclude: { type: 'text[]' },
filter_items_regex: { type: 'boolean', notNull: true, default: false },
y_min: { type: 'real' },
y_max: { type: 'real' },
series_limit: { type: 'integer', notNull: true, default: 20 },
order_by: { type: 'text', notNull: true, default: 'time' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
}, { ifNotExists: true });
// Use DO blocks so constraints are idempotent on existing DBs
pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_signal_type_check CHECK (signal_type IN ('green','red','both'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.createTable('alerts', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
item_key: { type: 'text', notNull: true },
item_key_is_regex: { type: 'boolean', notNull: true, default: false },
combinator: { type: 'text' },
signal_type: { type: 'text', notNull: true, default: 'green' },
condition: { type: 'text', notNull: true },
threshold: { type: 'integer', notNull: true },
active: { type: 'boolean', notNull: true, default: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
}, { ifNotExists: true });
pgm.sql(`DO $$ BEGIN
ALTER TABLE alerts ADD CONSTRAINT alerts_signal_type_check CHECK (signal_type IN ('green','red'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN
ALTER TABLE alerts ADD CONSTRAINT alerts_condition_check CHECK (condition IN ('above','below'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.createTable('settings', {
key: { type: 'text', primaryKey: true },
value: { type: 'text', notNull: true },
}, { ifNotExists: true });
};
exports.down = () => Promise.resolve();

View File

@@ -0,0 +1,18 @@
/** @type {import('node-pg-migrate').MigrationBuilder} */
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE charts ADD COLUMN IF NOT EXISTS y_scale TEXT NOT NULL DEFAULT 'linear'`);
pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_y_scale_check CHECK (y_scale IN ('linear','log'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'))`);
};
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc'))`);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_y_scale_check`);
pgm.sql(`ALTER TABLE charts DROP COLUMN IF EXISTS y_scale`);
};

View File

@@ -0,0 +1,19 @@
/** @type {import('node-pg-migrate').MigrationBuilder} */
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'))`);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups','divider'))`);
// Migrate any existing stacked charts to line
pgm.sql(`UPDATE charts SET viz_type = 'line' WHERE viz_type = 'stacked'`);
};
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'))`);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`);
};

7
web/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

2373
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
web/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"migrate": "node-pg-migrate up -m migrations",
"migrate:down": "node-pg-migrate down -m migrations",
"migrate:create": "node-pg-migrate create -m migrations"
},
"dependencies": {
"@tailwindcss/postcss": "^4.3.0",
"next": "16.2.6",
"pg": "^8.20.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-grid-layout": "^2.2.3",
"react-resizable": "^4.0.1",
"uplot": "^1.6.32"
},
"devDependencies": {
"@types/node": "^24",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"node-pg-migrate": "^8.0.4",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
}
}

7
web/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1
web/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
web/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
web/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
web/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
web/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
web/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}