Initial web
This commit is contained in:
32
web/app/api/alerts/[id]/route.ts
Normal file
32
web/app/api/alerts/[id]/route.ts
Normal 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 });
|
||||
});
|
||||
62
web/app/api/alerts/check/route.ts
Normal file
62
web/app/api/alerts/check/route.ts
Normal 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);
|
||||
});
|
||||
30
web/app/api/alerts/route.ts
Normal file
30
web/app/api/alerts/route.ts
Normal 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 });
|
||||
});
|
||||
64
web/app/api/charts/[id]/route.ts
Normal file
64
web/app/api/charts/[id]/route.ts
Normal 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 });
|
||||
});
|
||||
36
web/app/api/charts/route.ts
Normal file
36
web/app/api/charts/route.ts
Normal 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 });
|
||||
});
|
||||
71
web/app/api/ingest/[combinator]/route.ts
Normal file
71
web/app/api/ingest/[combinator]/route.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
11
web/app/api/sessions/route.ts
Normal file
11
web/app/api/sessions/route.ts
Normal 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);
|
||||
});
|
||||
191
web/app/api/signals/route.ts
Normal file
191
web/app/api/signals/route.ts
Normal 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
59
web/app/api/ups/route.ts
Normal 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
BIN
web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
17
web/app/globals.css
Normal file
17
web/app/globals.css
Normal 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
17
web/app/layout.tsx
Normal 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
94
web/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user