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

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);
});