chore: add prettier with config and format all files

This commit is contained in:
Sebastian Seedorf
2026-06-04 11:44:20 +02:00
parent d212ae3f30
commit cf9bb33ecb
50 changed files with 1290 additions and 714 deletions

6
web/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
out
dist
charts
public/*.csv

7
web/.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100
}

View File

@@ -1,5 +1,7 @@
<!-- BEGIN:nextjs-agent-rules --> <!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know # 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. 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 --> <!-- END:nextjs-agent-rules -->

View File

@@ -19,4 +19,5 @@ DATABASE_URL=postgresql://factorio:factorio@localhost:5432/factorio npm run migr
# 4. Start dev server # 4. Start dev server
cp .env.local.example .env.local # fill in API_TOKEN cp .env.local.example .env.local # fill in API_TOKEN
npm run dev npm run dev
```

View File

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

View File

@@ -13,7 +13,10 @@ export const GET = withAuth(async () => {
if (alertsResult.rows.length === 0) return NextResponse.json([]); if (alertsResult.rows.length === 0) return NextResponse.json([]);
const latestResult = await pool.query<{ const latestResult = await pool.query<{
combinator: string; item_key: string; green: number; red: number; combinator: string;
item_key: string;
green: number;
red: number;
}>( }>(
`SELECT DISTINCT ON (combinator, item_key) combinator, item_key, green, red `SELECT DISTINCT ON (combinator, item_key) combinator, item_key, green, red
FROM signals FROM signals
@@ -22,9 +25,7 @@ export const GET = withAuth(async () => {
const localeMap = getServerLocaleMap(); const localeMap = getServerLocaleMap();
const latestMap = new Map( const latestMap = new Map(latestResult.rows.map((r) => [`${r.combinator}::${r.item_key}`, r]));
latestResult.rows.map(r => [`${r.combinator}::${r.item_key}`, r]),
);
const triggered: TriggeredAlert[] = []; const triggered: TriggeredAlert[] = [];
@@ -49,14 +50,15 @@ export const GET = withAuth(async () => {
const value = alert.signal_type === 'green' ? vals.green : vals.red; const value = alert.signal_type === 'green' ? vals.green : vals.red;
const fired = alert.condition === 'above' ? value > alert.threshold : value < alert.threshold; const fired = alert.condition === 'above' ? value > alert.threshold : value < alert.threshold;
if (fired) triggered.push({ if (fired)
...alert, triggered.push({
current_value: value, ...alert,
combinator_match: combinator, current_value: value,
matched_item_key: item_key, combinator_match: combinator,
}); matched_item_key: item_key,
});
} }
} }
return NextResponse.json(triggered); return NextResponse.json(triggered);
}); });

View File

@@ -10,15 +10,16 @@ export const GET = withAuth(async () => {
export const POST = withAuth(async (req: NextRequest) => { export const POST = withAuth(async (req: NextRequest) => {
const body = await req.json(); const body = await req.json();
const { const {
item_key, item_key_is_regex = false, item_key,
combinator = null, signal_type = 'green', condition, threshold, item_key_is_regex = false,
combinator = null,
signal_type = 'green',
condition,
threshold,
} = body; } = body;
if (!item_key || !condition || threshold === undefined) { if (!item_key || !condition || threshold === undefined) {
return NextResponse.json( return NextResponse.json({ error: 'item_key, condition, threshold required' }, { status: 400 });
{ error: 'item_key, condition, threshold required' },
{ status: 400 },
);
} }
const result = await pool.query( const result = await pool.query(
@@ -27,4 +28,4 @@ export const POST = withAuth(async (req: NextRequest) => {
[item_key, item_key_is_regex, combinator, signal_type, condition, threshold], [item_key, item_key_is_regex, combinator, signal_type, condition, threshold],
); );
return NextResponse.json(result.rows[0], { status: 201 }); return NextResponse.json(result.rows[0], { status: 201 });
}); });

View File

@@ -6,17 +6,25 @@ export const PUT = withAuth(async (req: NextRequest, { params }) => {
const { id } = await params; const { id } = await params;
const body = await req.json(); const body = await req.json();
const { const {
title, pos_x, pos_y, width, height, title,
signal_type, chart_type, viz_type, pos_x,
filter_items_regex, y_scale, pos_y,
series_limit, order_by, width,
height,
signal_type,
chart_type,
viz_type,
filter_items_regex,
y_scale,
series_limit,
order_by,
} = body; } = body;
const hasFilterCombinators = 'filter_combinators' in body; const hasFilterCombinators = 'filter_combinators' in body;
const hasFilterItems = 'filter_items' in body; const hasFilterItems = 'filter_items' in body;
const hasFilterItemsExclude = 'filter_items_exclude' in body; const hasFilterItemsExclude = 'filter_items_exclude' in body;
const hasYMin = 'y_min' in body; const hasYMin = 'y_min' in body;
const hasYMax = 'y_max' in body; const hasYMax = 'y_max' in body;
const result = await pool.query( const result = await pool.query(
`UPDATE charts SET `UPDATE charts SET
@@ -40,15 +48,28 @@ export const PUT = withAuth(async (req: NextRequest, { params }) => {
WHERE id = $23 WHERE id = $23
RETURNING *`, RETURNING *`,
[ [
title, pos_x, pos_y, width, height, signal_type, chart_type, viz_type, title,
hasFilterCombinators, body.filter_combinators ?? null, pos_x,
hasFilterItems, body.filter_items ?? null, pos_y,
hasFilterItemsExclude, body.filter_items_exclude ?? null, 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, filter_items_regex,
hasYMin, body.y_min ?? null, hasYMin,
hasYMax, body.y_max ?? null, body.y_min ?? null,
hasYMax,
body.y_max ?? null,
y_scale, y_scale,
series_limit, order_by, series_limit,
order_by,
id, id,
], ],
); );
@@ -61,4 +82,4 @@ export const DELETE = withAuth(async (_req: NextRequest, { params }) => {
const result = await pool.query('DELETE FROM charts WHERE id = $1', [id]); const result = await pool.query('DELETE FROM charts WHERE id = $1', [id]);
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }); if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
}); });

View File

@@ -11,12 +11,22 @@ export const POST = withAuth(async (req: NextRequest) => {
const body = await req.json(); const body = await req.json();
const { const {
title, title,
pos_x = 0, pos_y = 0, width = 2, height = 4, pos_x = 0,
signal_type = 'both', chart_type = 'signals', viz_type = 'line', pos_y = 0,
filter_combinators = null, filter_items = null, width = 2,
filter_items_exclude = null, filter_items_regex = false, height = 4,
y_min = null, y_max = null, y_scale = 'linear', signal_type = 'both',
series_limit = 20, order_by = 'value_asc', 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 = 'value_asc',
} = body; } = body;
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 }); if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
@@ -28,9 +38,25 @@ export const POST = withAuth(async (req: NextRequest) => {
y_min,y_max,y_scale,series_limit,order_by) 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) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *`, RETURNING *`,
[title,pos_x,pos_y,width,height,signal_type,chart_type,viz_type, [
filter_combinators,filter_items,filter_items_exclude,filter_items_regex, title,
y_min,y_max,y_scale,series_limit,order_by], 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 }); return NextResponse.json(result.rows[0], { status: 201 });
}); });

View File

@@ -4,11 +4,11 @@ import { withAuth } from '@/lib/apiHelpers';
interface CircuitNetwork { interface CircuitNetwork {
green: Record<string, number>; green: Record<string, number>;
red: Record<string, number>; red: Record<string, number>;
} }
interface IngestBody { interface IngestBody {
game_tick: number; game_tick: number;
circuit_network: CircuitNetwork; circuit_network: CircuitNetwork;
logistic_network: Record<string, number>; logistic_network: Record<string, number>;
} }
@@ -31,10 +31,10 @@ export const POST = withAuth(async (req: NextRequest, { params }) => {
return NextResponse.json({ error: 'Missing game_tick' }, { status: 400 }); return NextResponse.json({ error: 'Missing game_tick' }, { status: 400 });
} }
const green = circuit_network?.green ?? {}; const green = circuit_network?.green ?? {};
const red = circuit_network?.red ?? {}; const red = circuit_network?.red ?? {};
const logistic = logistic_network ?? {}; const logistic = logistic_network ?? {};
const allKeys = new Set([...Object.keys(green), ...Object.keys(red), ...Object.keys(logistic)]); const allKeys = new Set([...Object.keys(green), ...Object.keys(red), ...Object.keys(logistic)]);
if (allKeys.size === 0) return NextResponse.json({ ok: true, rows: 0 }); if (allKeys.size === 0) return NextResponse.json({ ok: true, rows: 0 });
@@ -47,7 +47,15 @@ export const POST = withAuth(async (req: NextRequest, { params }) => {
let idx = 1; let idx = 1;
for (const key of allKeys) { for (const key of allKeys) {
placeholders.push(`($${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++})`); 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); values.push(
realTime,
game_tick,
combinator,
key,
green[key] ?? 0,
red[key] ?? 0,
logistic[key] ?? null,
);
} }
await client.query( await client.query(
@@ -68,4 +76,4 @@ export const POST = withAuth(async (req: NextRequest, { params }) => {
} finally { } finally {
client.release(); client.release();
} }
}); });

View File

@@ -5,7 +5,7 @@ import { withAuth } from '@/lib/apiHelpers';
export const GET = withAuth(async (req: NextRequest) => { export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams; const p = req.nextUrl.searchParams;
const from = p.get('from') ? new Date(p.get('from')!) : new Date(Date.now() - 86_400_000); 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 to = p.get('to') ? new Date(p.get('to')!) : new Date();
const boundaries = await getSessionBoundaries(from, to); const boundaries = await getSessionBoundaries(from, to);
return NextResponse.json(boundaries); return NextResponse.json(boundaries);
}); });

View File

@@ -6,15 +6,15 @@ import { matchKeys } from '@/lib/localization';
export const GET = withAuth(async (req: NextRequest) => { export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams; const p = req.nextUrl.searchParams;
const combinators = p.getAll('combinator'); const combinators = p.getAll('combinator');
const itemsWhitelist = p.getAll('item'); const itemsWhitelist = p.getAll('item');
const itemsBlacklist = p.getAll('exclude'); const itemsBlacklist = p.getAll('exclude');
const signalType = p.get('signal') ?? 'both'; const signalType = p.get('signal') ?? 'both';
const from = p.get('from'); const from = p.get('from');
const to = p.get('to'); const to = p.get('to');
const useRegex = p.get('regex') === 'true'; const useRegex = p.get('regex') === 'true';
const orderBy = p.get('order_by') ?? 'value_asc'; const orderBy = p.get('order_by') ?? 'value_asc';
const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null; const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null;
const conditions: string[] = []; const conditions: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
@@ -28,8 +28,8 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsWhitelist.length > 0) { if (itemsWhitelist.length > 0) {
if (useRegex) { if (useRegex) {
const localeMap = getServerLocaleMap(); const localeMap = getServerLocaleMap();
const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))]; const localeKeys = [...new Set(itemsWhitelist.flatMap((p) => matchKeys(p, localeMap)))];
const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|'); const sqlPattern = itemsWhitelist.map((p) => `(${p})`).join('|');
const orConds = [`item_key ~* $${i++}`]; const orConds = [`item_key ~* $${i++}`];
values.push(sqlPattern); values.push(sqlPattern);
if (localeKeys.length > 0) { if (localeKeys.length > 0) {
@@ -46,8 +46,8 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsBlacklist.length > 0) { if (itemsBlacklist.length > 0) {
if (useRegex) { if (useRegex) {
const localeMap = getServerLocaleMap(); const localeMap = getServerLocaleMap();
const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))]; const localeKeys = [...new Set(itemsBlacklist.flatMap((p) => matchKeys(p, localeMap)))];
const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|'); const sqlPattern = itemsBlacklist.map((p) => `(${p})`).join('|');
const andConds = [`item_key !~* $${i++}`]; const andConds = [`item_key !~* $${i++}`];
values.push(sqlPattern); values.push(sqlPattern);
if (localeKeys.length > 0) { if (localeKeys.length > 0) {
@@ -61,16 +61,24 @@ export const GET = withAuth(async (req: NextRequest) => {
} }
} }
if (from) { conditions.push(`real_time >= $${i++}`); values.push(new Date(from)); } if (from) {
if (to) { conditions.push(`real_time <= $${i++}`); values.push(new Date(to)); } 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 where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const valueCol = signalType === 'red' ? 'red' : 'green'; const valueCol = signalType === 'red' ? 'red' : 'green';
const selectCols = const selectCols =
signalType === 'green' ? 'real_time, game_tick, combinator, item_key, green' signalType === 'green'
: signalType === 'red' ? 'real_time, game_tick, combinator, item_key, red' ? 'real_time, game_tick, combinator, item_key, green'
: 'real_time, game_tick, combinator, item_key, green, red'; : 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) { if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) {
const baseConditions: string[] = []; const baseConditions: string[] = [];
@@ -84,8 +92,8 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsWhitelist.length > 0) { if (itemsWhitelist.length > 0) {
if (useRegex) { if (useRegex) {
const localeMap = getServerLocaleMap(); const localeMap = getServerLocaleMap();
const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))]; const localeKeys = [...new Set(itemsWhitelist.flatMap((p) => matchKeys(p, localeMap)))];
const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|'); const sqlPattern = itemsWhitelist.map((p) => `(${p})`).join('|');
const orConds = [`item_key ~* $${j++}`]; const orConds = [`item_key ~* $${j++}`];
baseValues.push(sqlPattern); baseValues.push(sqlPattern);
if (localeKeys.length > 0) { if (localeKeys.length > 0) {
@@ -101,8 +109,8 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsBlacklist.length > 0) { if (itemsBlacklist.length > 0) {
if (useRegex) { if (useRegex) {
const localeMap = getServerLocaleMap(); const localeMap = getServerLocaleMap();
const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))]; const localeKeys = [...new Set(itemsBlacklist.flatMap((p) => matchKeys(p, localeMap)))];
const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|'); const sqlPattern = itemsBlacklist.map((p) => `(${p})`).join('|');
const andConds = [`item_key !~* $${j++}`]; const andConds = [`item_key !~* $${j++}`];
baseValues.push(sqlPattern); baseValues.push(sqlPattern);
if (localeKeys.length > 0) { if (localeKeys.length > 0) {
@@ -116,7 +124,7 @@ export const GET = withAuth(async (req: NextRequest) => {
} }
} }
const baseWhere = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : ''; const baseWhere = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : '';
const baseWhereAnd = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : ''; const baseWhereAnd = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : '';
const deltaQuery = ` const deltaQuery = `
@@ -158,21 +166,27 @@ export const GET = withAuth(async (req: NextRequest) => {
const top = deltaResult.rows; const top = deltaResult.rows;
if (top.length === 0) return NextResponse.json([]); if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map((_, idx) => const seriesConditions = top.map(
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, (_, idx) => `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
); );
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`; const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const orderCase = top.map((_, idx) => const orderCase = top
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, .map(
).join(' '); (_, idx) =>
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`,
)
.join(' ');
const result = await pool.query( const result = await pool.query(
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`, `SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
[...values, ...top.flatMap(r => [r.combinator, r.item_key])], [...values, ...top.flatMap((r) => [r.combinator, r.item_key])],
); );
return NextResponse.json(result.rows); return NextResponse.json(result.rows);
} }
if ((orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') && limit !== null) { if (
(orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') &&
limit !== null
) {
const latestVals = await pool.query<{ combinator: string; item_key: string; val: number }>( const latestVals = await pool.query<{ combinator: string; item_key: string; val: number }>(
`SELECT DISTINCT ON (combinator, item_key) `SELECT DISTINCT ON (combinator, item_key)
combinator, item_key, ${valueCol} AS val combinator, item_key, ${valueCol} AS val
@@ -182,23 +196,27 @@ export const GET = withAuth(async (req: NextRequest) => {
); );
let sorted = latestVals.rows; let sorted = latestVals.rows;
if (orderBy === 'value_asc') sorted = [...sorted].sort((a, b) => a.val - b.val); 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 === '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)); if (orderBy === 'abs_desc')
sorted = [...sorted].sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
const top = sorted.slice(0, limit); const top = sorted.slice(0, limit);
if (top.length === 0) return NextResponse.json([]); if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map((_, idx) => const seriesConditions = top.map(
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, (_, idx) => `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
); );
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`; const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const orderCase = top.map((_, idx) => const orderCase = top
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, .map(
).join(' '); (_, idx) =>
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`,
)
.join(' ');
const result = await pool.query( const result = await pool.query(
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`, `SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
[...values, ...top.flatMap(r => [r.combinator, r.item_key])], [...values, ...top.flatMap((r) => [r.combinator, r.item_key])],
); );
return NextResponse.json(result.rows); return NextResponse.json(result.rows);
} }
@@ -209,4 +227,4 @@ export const GET = withAuth(async (req: NextRequest) => {
values, values,
); );
return NextResponse.json(result.rows); return NextResponse.json(result.rows);
}); });

View File

@@ -6,7 +6,7 @@ export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams; const p = req.nextUrl.searchParams;
const combinator = p.get('combinator'); const combinator = p.get('combinator');
const from = p.get('from') ? new Date(p.get('from')!) : new Date(Date.now() - 86_400_000); 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 to = p.get('to') ? new Date(p.get('to')!) : new Date();
const conditions = ['real_time BETWEEN $1 AND $2']; const conditions = ['real_time BETWEEN $1 AND $2'];
const values: unknown[] = [from, to]; const values: unknown[] = [from, to];
@@ -17,7 +17,9 @@ export const GET = withAuth(async (req: NextRequest) => {
} }
const result = await pool.query<{ const result = await pool.query<{
real_time: Date; game_tick: string; combinator: string; real_time: Date;
game_tick: string;
combinator: string;
}>( }>(
`SELECT real_time, game_tick, combinator `SELECT real_time, game_tick, combinator
FROM tick_timing FROM tick_timing
@@ -43,17 +45,17 @@ export const GET = withAuth(async (req: NextRequest) => {
const prev = combiRows[i - 1]; const prev = combiRows[i - 1];
const curr = combiRows[i]; const curr = combiRows[i];
const deltaRealMs = curr.real_time.getTime() - prev.real_time.getTime(); const deltaRealMs = curr.real_time.getTime() - prev.real_time.getTime();
const deltaTicks = parseInt(curr.game_tick, 10) - parseInt(prev.game_tick, 10); const deltaTicks = parseInt(curr.game_tick, 10) - parseInt(prev.game_tick, 10);
// Skip session gaps and bad data // Skip session gaps and bad data
if (deltaRealMs > 30 * 60 * 1000 || deltaRealMs <= 0 || deltaTicks <= 0) continue; if (deltaRealMs > 30 * 60 * 1000 || deltaRealMs <= 0 || deltaTicks <= 0) continue;
points.push({ points.push({
real_time: curr.real_time.toISOString(), real_time: curr.real_time.toISOString(),
game_tick: parseInt(curr.game_tick, 10), game_tick: parseInt(curr.game_tick, 10),
combinator: combi, combinator: combi,
ups: Math.round((deltaTicks / deltaRealMs) * 1000 * 10) / 10, ups: Math.round((deltaTicks / deltaRealMs) * 1000 * 10) / 10,
}); });
} }
} }
return NextResponse.json(points); return NextResponse.json(points);
}); });

View File

@@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
:root { :root {
--background: #111827; --background: #111827;
@@ -15,4 +15,3 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: ui-sans-serif, system-ui, sans-serif; font-family: ui-sans-serif, system-ui, sans-serif;
} }

View File

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

View File

@@ -48,8 +48,8 @@ function DashboardApp() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token') ?? ''; const token = searchParams.get('token') ?? '';
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [alerts, setAlerts] = useState<AlertConfig[]>([]); const [alerts, setAlerts] = useState<AlertConfig[]>([]);
const [localeMap, setLocaleMap] = useState<LocaleMap>(new Map()); const [localeMap, setLocaleMap] = useState<LocaleMap>(new Map());
useEffect(() => { useEffect(() => {
@@ -72,9 +72,7 @@ function DashboardApp() {
if (!ready) { if (!ready) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-gray-400"> <div className="flex min-h-screen items-center justify-center text-gray-400">Loading</div>
Loading
</div>
); );
} }
@@ -91,4 +89,4 @@ export default function Page() {
<DashboardApp /> <DashboardApp />
</Suspense> </Suspense>
); );
} }

View File

@@ -2,10 +2,15 @@ import { readFileSync, writeFileSync } from 'fs';
const CSV = 'public/factorio_item_colors.csv'; const CSV = 'public/factorio_item_colors.csv';
function hexToHsl(h: string): [number, number, number] { function hexToHsl(h: string): [number, number, number] {
let r = parseInt(h.slice(1, 3), 16) / 255, g = parseInt(h.slice(3, 5), 16) / 255, b = parseInt(h.slice(5, 7), 16) / 255; let r = parseInt(h.slice(1, 3), 16) / 255,
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2; g = parseInt(h.slice(3, 5), 16) / 255,
b = parseInt(h.slice(5, 7), 16) / 255;
const mx = Math.max(r, g, b),
mn = Math.min(r, g, b),
l = (mx + mn) / 2;
if (mx === mn) return [0, 0, Math.round(l * 100)]; if (mx === mn) return [0, 0, Math.round(l * 100)];
const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); const d = mx - mn,
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let hue = 0; let hue = 0;
if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 60; if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (mx === g) hue = ((b - r) / d + 2) * 60; else if (mx === g) hue = ((b - r) / d + 2) * 60;
@@ -13,12 +18,37 @@ function hexToHsl(h: string): [number, number, number] {
return [Math.round(hue), Math.round(s * 100), Math.round(l * 100)]; return [Math.round(hue), Math.round(s * 100), Math.round(l * 100)];
} }
function hslToHex(h: number, s: number, l: number): string { function hslToHex(h: number, s: number, l: number): string {
s /= 100; l /= 100; s /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = l - c / 2; l /= 100;
let r = 0, g = 0, b = 0; const c = (1 - Math.abs(2 * l - 1)) * s,
if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; } x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; } m = l - c / 2;
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0'); let r = 0,
g = 0,
b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
const to = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, '0');
return '#' + to(r) + to(g) + to(b); return '#' + to(r) + to(g) + to(b);
} }
function djb2(s: string): number { function djb2(s: string): number {
@@ -33,30 +63,44 @@ function hueDist(a: number, b: number): number {
const lines = readFileSync(CSV, 'utf-8').trim().split('\n'); const lines = readFileSync(CSV, 'utf-8').trim().split('\n');
const header = lines[0]; const header = lines[0];
const raw = lines.slice(1).filter(l => l.trim()).map(l => { const raw = lines
const [k, c] = l.split(','); .slice(1)
return { key: k.trim(), color: c.trim() }; .filter((l) => l.trim())
}); .map((l) => {
const [k, c] = l.split(',');
return { key: k.trim(), color: c.trim() };
});
// Dedup by key // Dedup by key
const seen = new Set<string>(); const seen = new Set<string>();
const rows: typeof raw = []; const rows: typeof raw = [];
for (const r of raw) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); } for (const r of raw) {
if (seen.has(r.key)) continue;
seen.add(r.key);
rows.push(r);
}
const n = rows.length; const n = rows.length;
const parent = Array.from({ length: n }, (_, i) => i); const parent = Array.from({ length: n }, (_, i) => i);
function find(x: number): number { function find(x: number): number {
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } while (parent[x] !== x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x; return x;
} }
function union(a: number, b: number) { parent[find(a)] = find(b); } function union(a: number, b: number) {
parent[find(a)] = find(b);
}
const hsl = rows.map((r, i) => ({ ...r, hsl: hexToHsl(r.color), idx: i })); const hsl = rows.map((r, i) => ({ ...r, hsl: hexToHsl(r.color), idx: i }));
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) { for (let j = i + 1; j < n; j++) {
if (hueDist(hsl[i].hsl[0], hsl[j].hsl[0]) <= 0.5 && if (
Math.abs(hsl[i].hsl[2] - hsl[j].hsl[2]) <= 0.5) hueDist(hsl[i].hsl[0], hsl[j].hsl[0]) <= 0.5 &&
Math.abs(hsl[i].hsl[2] - hsl[j].hsl[2]) <= 0.5
)
union(i, j); union(i, j);
} }
} }
@@ -75,13 +119,13 @@ for (const [, items] of groups) {
const [oh, os, ol] = items[0].hsl; const [oh, os, ol] = items[0].hsl;
for (let i = 1; i < items.length; i++) { for (let i = 1; i < items.length; i++) {
const hash = djb2(items[i].key); const hash = djb2(items[i].key);
const h = (oh + (hash % 5 - 2) + i * 7 + 360) % 360; const h = (oh + ((hash % 5) - 2) + i * 7 + 360) % 360;
const l = Math.max(0, Math.min(100, ol + ((hash >> 4) % 5 - 2))); const l = Math.max(0, Math.min(100, ol + (((hash >> 4) % 5) - 2)));
items[i].color = hslToHex(h, os, l); items[i].color = hslToHex(h, os, l);
fixed++; fixed++;
} }
} }
hsl.sort((a, b) => a.idx - b.idx); hsl.sort((a, b) => a.idx - b.idx);
writeFileSync(CSV, header + '\n' + hsl.map(r => `${r.key},${r.color}`).join('\n') + '\n'); writeFileSync(CSV, header + '\n' + hsl.map((r) => `${r.key},${r.color}`).join('\n') + '\n');
if (fixed) console.log(`Fixed ${fixed} close colors`); if (fixed) console.log(`Fixed ${fixed} close colors`);

View File

@@ -3,4 +3,4 @@ name: factorio-signal-exporter
description: Factorio Signal Exporter — Next.js dashboard with TimescaleDB description: Factorio Signal Exporter — Next.js dashboard with TimescaleDB
type: application type: application
version: 0.0.0-dev version: 0.0.0-dev
appVersion: "latest" appVersion: 'latest'

View File

@@ -13,10 +13,10 @@ imagePullSecrets: []
app: app:
replicaCount: 1 replicaCount: 1
## API token for ingest POST and dashboard GET ?token= ## API token for ingest POST and dashboard GET ?token=
apiToken: "" apiToken: ''
## If set, use this existing K8s secret instead of creating one. ## If set, use this existing K8s secret instead of creating one.
## Secret must contain keys: API_TOKEN, DATABASE_URL ## Secret must contain keys: API_TOKEN, DATABASE_URL
existingSecret: "" existingSecret: ''
db: db:
## TimescaleDB credentials — used to build DATABASE_URL and configure the StatefulSet ## TimescaleDB credentials — used to build DATABASE_URL and configure the StatefulSet
@@ -25,7 +25,7 @@ db:
name: factorio name: factorio
port: 5432 port: 5432
storage: 10Gi storage: 10Gi
storageClassName: "" storageClassName: ''
service: service:
type: ClusterIP type: ClusterIP
@@ -33,14 +33,14 @@ service:
ingress: ingress:
enabled: false enabled: false
className: "" className: ''
host: factorio.example.com host: factorio.example.com
tls: false tls: false
tlsSecretName: "" tlsSecretName: ''
annotations: {} annotations: {}
resources: {} resources: {}
nodeSelector: {} nodeSelector: {}
tolerations: [] tolerations: []
affinity: {} affinity: {}

View File

@@ -7,34 +7,43 @@ import { resolveName, resolveKey } from '@/lib/localization';
import type { AlertConfig } from '@/lib/types'; import type { AlertConfig } from '@/lib/types';
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void; 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 inputCls =
const selectCls = 'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none'; '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 { interface AlertFormState {
itemKey: string; itemKey: string;
itemKeyIsRegex: boolean; itemKeyIsRegex: boolean;
combinator: string; combinator: string;
signalType: 'green' | 'red'; signalType: 'green' | 'red';
condition: 'above' | 'below'; condition: 'above' | 'below';
threshold: string; threshold: string;
} }
function emptyForm(): AlertFormState { function emptyForm(): AlertFormState {
return { itemKey: '', itemKeyIsRegex: false, combinator: '', signalType: 'green', condition: 'below', threshold: '0' }; return {
itemKey: '',
itemKeyIsRegex: false,
combinator: '',
signalType: 'green',
condition: 'below',
threshold: '0',
};
} }
function alertToForm(a: AlertConfig): AlertFormState { function alertToForm(a: AlertConfig): AlertFormState {
return { return {
itemKey: a.item_key, itemKey: a.item_key,
itemKeyIsRegex: a.item_key_is_regex, itemKeyIsRegex: a.item_key_is_regex,
combinator: a.combinator ?? '', combinator: a.combinator ?? '',
signalType: a.signal_type, signalType: a.signal_type,
condition: a.condition, condition: a.condition,
threshold: String(a.threshold), threshold: String(a.threshold),
}; };
} }
@@ -50,48 +59,78 @@ function Tooltip({ text }: { text: string }) {
} }
function AlertForm({ function AlertForm({
value, onChange, onSubmit, onCancel, submitLabel, value,
onChange,
onSubmit,
onCancel,
submitLabel,
}: { }: {
value: AlertFormState; value: AlertFormState;
onChange: (s: AlertFormState) => void; onChange: (s: AlertFormState) => void;
onSubmit: () => void; onSubmit: () => void;
onCancel?: () => void; onCancel?: () => void;
submitLabel: string; submitLabel: string;
}) { }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<input <input
value={value.itemKey} value={value.itemKey}
onChange={e => onChange({ ...value, itemKey: e.target.value })} onChange={(e) => onChange({ ...value, itemKey: e.target.value })}
placeholder={value.itemKeyIsRegex ? 'iron-.*|Iron Plate' : 'Iron Plate or item-key'} placeholder={value.itemKeyIsRegex ? 'iron-.*|Iron Plate' : 'Iron Plate or item-key'}
className={inputCls} className={inputCls}
/> />
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
<input type="checkbox" checked={value.itemKeyIsRegex} <input
onChange={e => onChange({ ...value, itemKeyIsRegex: e.target.checked })} type="checkbox"
className="accent-indigo-500" /> checked={value.itemKeyIsRegex}
onChange={(e) => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
className="accent-indigo-500"
/>
Item key is regex Item key is regex
</label> </label>
<input value={value.combinator} onChange={e => onChange({ ...value, combinator: e.target.value })} <input
placeholder="combinator (empty = all)" className={inputCls} /> value={value.combinator}
onChange={(e) => onChange({ ...value, combinator: e.target.value })}
placeholder="combinator (empty = all)"
className={inputCls}
/>
<div className="flex gap-2"> <div className="flex gap-2">
<select value={value.signalType} onChange={e => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })} className={selectCls}> <select
value={value.signalType}
onChange={(e) => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })}
className={selectCls}
>
<option value="green">Green</option> <option value="green">Green</option>
<option value="red">Red</option> <option value="red">Red</option>
</select> </select>
<select value={value.condition} onChange={e => onChange({ ...value, condition: e.target.value as 'above' | 'below' })} className={selectCls}> <select
value={value.condition}
onChange={(e) => onChange({ ...value, condition: e.target.value as 'above' | 'below' })}
className={selectCls}
>
<option value="below">Below</option> <option value="below">Below</option>
<option value="above">Above</option> <option value="above">Above</option>
</select> </select>
</div> </div>
<input type="number" value={value.threshold} onChange={e => onChange({ ...value, threshold: e.target.value })} <input
placeholder="threshold" className={inputCls} /> type="number"
value={value.threshold}
onChange={(e) => onChange({ ...value, threshold: e.target.value })}
placeholder="threshold"
className={inputCls}
/>
<div className="flex gap-2"> <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"> <button
onClick={onSubmit}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5"
>
{submitLabel} {submitLabel}
</button> </button>
{onCancel && ( {onCancel && (
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"> <button
onClick={onCancel}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
>
Cancel Cancel
</button> </button>
)} )}
@@ -102,10 +141,10 @@ function AlertForm({
export default function AlertPanel({ open, onClose }: Props) { export default function AlertPanel({ open, onClose }: Props) {
const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp(); const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp();
const [alerts, setAlerts] = useState<AlertConfig[]>([]); const [alerts, setAlerts] = useState<AlertConfig[]>([]);
const [newForm, setNewForm] = useState<AlertFormState>(emptyForm()); const [newForm, setNewForm] = useState<AlertFormState>(emptyForm());
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<AlertFormState>(emptyForm()); const [editForm, setEditForm] = useState<AlertFormState>(emptyForm());
const prevTriggeredCount = useRef(0); const prevTriggeredCount = useRef(0);
useEffect(() => { useEffect(() => {
@@ -115,8 +154,8 @@ export default function AlertPanel({ open, onClose }: Props) {
useEffect(() => { useEffect(() => {
if (triggeredAlerts.length > prevTriggeredCount.current) { if (triggeredAlerts.length > prevTriggeredCount.current) {
try { try {
const ctx = new AudioContext(); const ctx = new AudioContext();
const osc = ctx.createOscillator(); const osc = ctx.createOscillator();
const gain = ctx.createGain(); const gain = ctx.createGain();
osc.connect(gain); osc.connect(gain);
gain.connect(ctx.destination); gain.connect(ctx.destination);
@@ -125,7 +164,9 @@ export default function AlertPanel({ open, onClose }: Props) {
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
osc.start(); osc.start();
osc.stop(ctx.currentTime + 0.4); osc.stop(ctx.currentTime + 0.4);
} catch { /* AudioContext blocked */ } } catch {
/* AudioContext blocked */
}
} }
prevTriggeredCount.current = triggeredAlerts.length; prevTriggeredCount.current = triggeredAlerts.length;
}, [triggeredAlerts.length]); }, [triggeredAlerts.length]);
@@ -139,14 +180,14 @@ export default function AlertPanel({ open, onClose }: Props) {
async function handleCreate() { async function handleCreate() {
if (!newForm.itemKey.trim()) return; if (!newForm.itemKey.trim()) return;
const created = await createAlert({ const created = await createAlert({
item_key: normalizeItemKey(newForm), item_key: normalizeItemKey(newForm),
item_key_is_regex: newForm.itemKeyIsRegex, item_key_is_regex: newForm.itemKeyIsRegex,
combinator: newForm.combinator.trim() || null, combinator: newForm.combinator.trim() || null,
signal_type: newForm.signalType, signal_type: newForm.signalType,
condition: newForm.condition, condition: newForm.condition,
threshold: parseInt(newForm.threshold, 10), threshold: parseInt(newForm.threshold, 10),
}); });
setAlerts(a => [created, ...a]); setAlerts((a) => [created, ...a]);
setNewForm(emptyForm()); setNewForm(emptyForm());
await refreshAlerts(); await refreshAlerts();
} }
@@ -154,21 +195,21 @@ export default function AlertPanel({ open, onClose }: Props) {
async function handleEdit(id: string) { async function handleEdit(id: string) {
if (!editForm.itemKey.trim()) return; if (!editForm.itemKey.trim()) return;
const updated = await updateAlert(id, { const updated = await updateAlert(id, {
item_key: normalizeItemKey(editForm), item_key: normalizeItemKey(editForm),
item_key_is_regex: editForm.itemKeyIsRegex, item_key_is_regex: editForm.itemKeyIsRegex,
combinator: editForm.combinator.trim() || null, combinator: editForm.combinator.trim() || null,
signal_type: editForm.signalType, signal_type: editForm.signalType,
condition: editForm.condition, condition: editForm.condition,
threshold: parseInt(editForm.threshold, 10), threshold: parseInt(editForm.threshold, 10),
}); });
setAlerts(a => a.map(x => x.id === id ? updated : x)); setAlerts((a) => a.map((x) => (x.id === id ? updated : x)));
setEditingId(null); setEditingId(null);
await refreshAlerts(); await refreshAlerts();
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
await deleteAlert(id); await deleteAlert(id);
setAlerts(a => a.filter(x => x.id !== id)); setAlerts((a) => a.filter((x) => x.id !== id));
await refreshAlerts(); await refreshAlerts();
} }
@@ -178,21 +219,29 @@ export default function AlertPanel({ open, onClose }: Props) {
} }
return ( 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={`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"> <div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<span className="font-semibold text-white">Alerts</span> <span className="font-semibold text-white">Alerts</span>
<button onClick={onClose} className="text-gray-400 hover:text-white"></button> <button onClick={onClose} className="text-gray-400 hover:text-white">
</button>
</div> </div>
{triggeredAlerts.length > 0 && ( {triggeredAlerts.length > 0 && (
<div className="px-4 py-2 bg-red-900/40 border-b border-red-800"> <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> <p className="text-red-300 text-xs font-semibold mb-1">
🔴 TRIGGERED ({triggeredAlerts.length})
</p>
{triggeredAlerts.map((a, i) => ( {triggeredAlerts.map((a, i) => (
<div key={i} className="text-xs text-red-200 flex items-center gap-1 flex-wrap"> <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>{resolveName(a.matched_item_key, localeMap)}</span>
<span className="text-red-400">({a.combinator_match})</span> <span className="text-red-400">({a.combinator_match})</span>
<span>[{a.signal_type}]</span> <span>[{a.signal_type}]</span>
<span>= {a.current_value} {a.condition} {a.threshold}</span> <span>
= {a.current_value} {a.condition} {a.threshold}
</span>
{a.item_key_is_regex && a.matched_item_key !== a.item_key && ( {a.item_key_is_regex && a.matched_item_key !== a.item_key && (
<Tooltip text={`Matched by regex: /${a.item_key}/`} /> <Tooltip text={`Matched by regex: /${a.item_key}/`} />
)} )}
@@ -203,12 +252,17 @@ export default function AlertPanel({ open, onClose }: Props) {
<div className="p-4 border-b border-gray-700 space-y-2"> <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> <p className="text-xs text-gray-400 font-semibold uppercase">New Alert</p>
<AlertForm value={newForm} onChange={setNewForm} onSubmit={handleCreate} submitLabel="Add Alert" /> <AlertForm
value={newForm}
onChange={setNewForm}
onSubmit={handleCreate}
submitLabel="Add Alert"
/>
</div> </div>
<div className="overflow-y-auto flex-1 p-4 space-y-2"> <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.length === 0 && <p className="text-gray-500 text-sm">No alerts configured.</p>}
{alerts.map(a => ( {alerts.map((a) => (
<div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300"> <div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300">
{editingId === a.id ? ( {editingId === a.id ? (
<AlertForm <AlertForm
@@ -221,18 +275,30 @@ export default function AlertPanel({ open, onClose }: Props) {
) : ( ) : (
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<span className="font-medium text-white">{resolveName(a.item_key, localeMap)}</span> <span className="font-medium text-white">
{a.item_key_is_regex && ( {resolveName(a.item_key, localeMap)}
<Tooltip text={`Regex pattern: /${a.item_key}/`} /> </span>
)} {a.item_key_is_regex && <Tooltip text={`Regex pattern: /${a.item_key}/`} />}
{a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>} {a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>}
<br /> <br />
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>[{a.signal_type}]</span> <span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>
{' '}{a.condition} {a.threshold} [{a.signal_type}]
</span>{' '}
{a.condition} {a.threshold}
</div> </div>
<div className="flex gap-1 ml-2 shrink-0"> <div className="flex gap-1 ml-2 shrink-0">
<button onClick={() => startEdit(a)} className="text-gray-500 hover:text-indigo-400"></button> <button
<button onClick={() => handleDelete(a.id)} className="text-gray-500 hover:text-red-400"></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>
)} )}
@@ -241,4 +307,4 @@ export default function AlertPanel({ open, onClose }: Props) {
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,8 +1,8 @@
import React, { useRef, useState, useCallback } from 'react'; import React, { useRef, useState, useCallback } from 'react';
interface HeaderProps { interface HeaderProps {
title: string; title: string;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
} }
@@ -11,8 +11,18 @@ export function Header({ title, onEdit, onDelete }: HeaderProps) {
<div className="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 shrink-0"> <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> <span className="text-sm font-medium text-gray-200 truncate">{title}</span>
<div className="flex gap-1 ml-2 shrink-0"> <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
<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> 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>
</div> </div>
); );
@@ -25,64 +35,81 @@ export function EmptyState() {
} }
interface CardShellProps extends HeaderProps { interface CardShellProps extends HeaderProps {
empty: boolean; empty: boolean;
children: React.ReactNode; children: React.ReactNode;
/** Ref to the div where the uPlot legend will be mounted */ /** Ref to the div where the uPlot legend will be mounted */
legendContainerRef?: React.RefObject<HTMLDivElement>; legendContainerRef?: React.RefObject<HTMLDivElement>;
} }
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) { export function CardShell({
title,
onEdit,
onDelete,
empty,
children,
legendContainerRef,
}: CardShellProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [legendHeight, setLegendHeight] = useState<number | null>(null); const [legendHeight, setLegendHeight] = useState<number | null>(null);
const dragRef = useRef<{ startY: number; startH: number } | null>(null); const dragRef = useRef<{ startY: number; startH: number } | null>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback(
e.preventDefault(); (e: React.MouseEvent) => {
e.stopPropagation(); e.preventDefault();
const el = legendContainerRef?.current; e.stopPropagation();
if (!el) return; const el = legendContainerRef?.current;
dragRef.current = { startY: e.clientY, startH: el.offsetHeight }; if (!el) return;
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
function onMove(ev: MouseEvent) { function onMove(ev: MouseEvent) {
if (!dragRef.current) return; if (!dragRef.current) return;
const delta = dragRef.current.startY - ev.clientY; const delta = dragRef.current.startY - ev.clientY;
const containerH = containerRef.current?.offsetHeight ?? 400; const containerH = containerRef.current?.offsetHeight ?? 400;
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta)); const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
setLegendHeight(newH); setLegendHeight(newH);
} }
function onUp() { function onUp() {
dragRef.current = null; dragRef.current = null;
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
} }
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
}, [legendContainerRef]); },
[legendContainerRef],
);
return ( return (
<div ref={containerRef} className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden"> <div
ref={containerRef}
className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden"
>
<Header title={title} onEdit={onEdit} onDelete={onDelete} /> <Header title={title} onEdit={onEdit} onDelete={onDelete} />
{empty ? ( {empty ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<> <>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">{children}</div>
{children} {legendContainerRef && (
</div> <>
{legendContainerRef && <> <div
<div onMouseDown={handleMouseDown}
onMouseDown={handleMouseDown} className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600" />
/> <div
<div ref={legendContainerRef}
ref={legendContainerRef} className="shrink-0 overflow-y-auto 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"
className="shrink-0 overflow-y-auto 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" style={
style={legendHeight != null ? { height: legendHeight, maxHeight: legendHeight } : { maxHeight: '25%' }} legendHeight != null
/> ? { height: legendHeight, maxHeight: legendHeight }
<style>{'.u-legend .u-series:first-child { display: none; }'}</style> : { maxHeight: '25%' }
</>} }
/>
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
</>
)}
</> </>
)} )}
</div> </div>
); );
} }

View File

@@ -1,18 +1,30 @@
interface Props { interface Props {
title: string; title: string;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
} }
export default function DividerCard({ title, onEdit, onDelete }: Props) { export default function DividerCard({ title, onEdit, onDelete }: Props) {
return ( return (
<div className="h-full flex items-center bg-gray-900/30 rounded border border-gray-600/40 px-4 gap-4 overflow-hidden"> <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> <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-1 h-px bg-gray-600" />
<div className="flex gap-1 shrink-0"> <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
<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> 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>
</div> </div>
); );
} }

View File

@@ -8,27 +8,43 @@ import { resolveName } from '@/lib/localization';
import { getColorMap } from '@/lib/colors'; import { getColorMap } from '@/lib/colors';
import type { ColorMap } from '@/lib/colors'; import type { ColorMap } from '@/lib/colors';
import { CardShell } from './CardShell'; import { CardShell } from './CardShell';
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers'; import {
makeYScale,
makeAnnotationHooks,
makeSignalsSeries,
makeSignalsAxes,
CURSOR_NO_DRAG,
} from './plotHelpers';
import { buildSeriesData } from './seriesData'; import { buildSeriesData } from './seriesData';
import { usePlot } from './usePlot'; import { usePlot } from './usePlot';
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types'; import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types'; import type { TimeMode } from '@/lib/types';
interface Props { interface Props {
config: ChartConfig; config: ChartConfig;
rows: SignalRow[]; rows: SignalRow[];
sessions: SessionBoundary[]; sessions: SessionBoundary[];
alerts: AlertConfig[]; alerts: AlertConfig[];
timeMode: TimeMode; timeMode: TimeMode;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
} }
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) { export default function SignalsChart({
config,
rows,
sessions,
alerts,
timeMode,
onEdit,
onDelete,
}: Props) {
const { localeMap } = useApp(); const { localeMap } = useApp();
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE'; const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
const [colorMap, setColorMap] = useState<ColorMap>(new Map()); const [colorMap, setColorMap] = useState<ColorMap>(new Map());
useEffect(() => { getColorMap().then(setColorMap); }, []); useEffect(() => {
getColorMap().then(setColorMap);
}, []);
const { containerRef, legendRef } = usePlot( const { containerRef, legendRef } = usePlot(
(el, w, h, lRef) => { (el, w, h, lRef) => {
@@ -36,35 +52,47 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode,
if (!data) return null; if (!data) return null;
const { keys, allXs, data: seriesData } = data; const { keys, allXs, data: seriesData } = data;
const sessionXs = sessions.map(s => timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000); const sessionXs = sessions.map((s) =>
timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000,
);
const alertThresholds = alerts const alertThresholds = alerts
.filter(a => config.signal_type === 'both' || config.signal_type === a.signal_type) .filter((a) => config.signal_type === 'both' || config.signal_type === a.signal_type)
.map(a => a.threshold); .map((a) => a.threshold);
return new uPlot({ return new uPlot(
width: w, {
height: h, width: w,
cursor: CURSOR_NO_DRAG, height: h,
legend: { cursor: CURSOR_NO_DRAG,
mount: (_u, legendEl) => { legend: {
if (lRef.current) lRef.current.appendChild(legendEl); mount: (_u, legendEl) => {
if (lRef.current) lRef.current.appendChild(legendEl);
},
}, },
series: makeSignalsSeries(keys, timeMode, (key) => resolveName(key, localeMap), colorMap),
axes: makeSignalsAxes(timeMode, locale),
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
}, },
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap), colorMap), [allXs, ...seriesData],
axes: makeSignalsAxes(timeMode, locale), el,
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], [rows, sessions, alerts, config, timeMode, localeMap],
); );
return ( return (
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0} legendContainerRef={legendRef}> <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" /> <div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
</CardShell> </CardShell>
); );
} }

View File

@@ -8,16 +8,18 @@ import { CardShell } from './CardShell';
import type { ChartConfig, SignalRow } from '@/lib/types'; import type { ChartConfig, SignalRow } from '@/lib/types';
interface Props { interface Props {
config: ChartConfig; config: ChartConfig;
rows: SignalRow[]; rows: SignalRow[];
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
} }
export default function TableViz({ config, rows, onEdit, onDelete }: Props) { export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
const { localeMap } = useApp(); const { localeMap } = useApp();
const [colorMap, setColorMap] = useState<ColorMap>(new Map()); const [colorMap, setColorMap] = useState<ColorMap>(new Map());
useEffect(() => { getColorMap().then(setColorMap); }, []); useEffect(() => {
getColorMap().then(setColorMap);
}, []);
const latest = new Map<string, { green?: number; red?: number }>(); const latest = new Map<string, { green?: number; red?: number }>();
for (const row of rows) { for (const row of rows) {
@@ -33,8 +35,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
<tr> <tr>
<th className="text-left px-2 py-1">Item</th> <th className="text-left px-2 py-1">Item</th>
<th className="text-left px-2 py-1">Combinator</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 !== 'red' && (
{config.signal_type !== 'green' && <th className="text-right px-2 py-1 text-red-400">Red (NP)</th>} <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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -43,13 +49,17 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
return ( return (
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50"> <tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
<td className="px-2 py-0.5 flex items-center gap-1.5"> <td className="px-2 py-0.5 flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full shrink-0" <span
style={{ backgroundColor: getItemColor(item_key, colorMap) }} /> className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getItemColor(item_key, colorMap) }}
/>
{resolveName(item_key, localeMap)} {resolveName(item_key, localeMap)}
</td> </td>
<td className="px-2 py-0.5 text-gray-500">{combinator}</td> <td className="px-2 py-0.5 text-gray-500">{combinator}</td>
{config.signal_type !== 'red' && ( {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'}`}> <td
className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}
>
{vals.green != null ? formatSI(vals.green, undefined, 0) : '--'} {vals.green != null ? formatSI(vals.green, undefined, 0) : '--'}
</td> </td>
)} )}
@@ -66,4 +76,4 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
</div> </div>
</CardShell> </CardShell>
); );
} }

View File

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

View File

@@ -6,20 +6,29 @@ import TableViz from './TableViz';
import DividerCard from './DividerCard'; import DividerCard from './DividerCard';
export interface ChartCardProps { export interface ChartCardProps {
config: ChartConfig; config: ChartConfig;
rows: SignalRow[]; rows: SignalRow[];
upsRows: UpsRow[]; upsRows: UpsRow[];
sessions: SessionBoundary[]; sessions: SessionBoundary[];
alerts: AlertConfig[]; alerts: AlertConfig[];
timeMode: TimeMode; timeMode: TimeMode;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
} }
export default function ChartCard(props: ChartCardProps) { export default function ChartCard(props: ChartCardProps) {
const { config } = props; const { config } = props;
if (config.chart_type === 'divider') return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />; if (config.chart_type === 'divider')
if (config.chart_type === 'ups') return <UpsChart {...props} />; return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
if (config.viz_type === 'table') return <TableViz config={props.config} rows={props.rows} onEdit={props.onEdit} onDelete={props.onDelete} />; if (config.chart_type === 'ups') return <UpsChart {...props} />;
return <SignalsChart {...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

@@ -5,18 +5,18 @@ import type { ColorMap } from '@/lib/colors';
import { getItemColor } from '@/lib/colors'; import { getItemColor } from '@/lib/colors';
const SEMANTIC_GREEN = '#4ade80'; const SEMANTIC_GREEN = '#4ade80';
const SEMANTIC_RED = '#f87171'; const SEMANTIC_RED = '#f87171';
export interface SeriesStyle { export interface SeriesStyle {
color: string; color: string;
dash: number[] | undefined; dash: number[] | undefined;
} }
export function getSeriesStyle( export function getSeriesStyle(
key: string, key: string,
uCombs: number, uCombs: number,
uItems: number, uItems: number,
uSigs: number, uSigs: number,
colorMap: ColorMap = new Map(), colorMap: ColorMap = new Map(),
): SeriesStyle { ): SeriesStyle {
const [combinator, item_key, sig] = key.split('::'); const [combinator, item_key, sig] = key.split('::');
@@ -25,9 +25,15 @@ export function getSeriesStyle(
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined }; return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
} }
if (uItems > 1) { if (uItems > 1) {
return { color: getItemColor(item_key, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined }; return {
color: getItemColor(item_key, colorMap),
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
};
} }
return { color: getItemColor(combinator, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined }; return {
color: getItemColor(combinator, colorMap),
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
};
} }
/** /**
@@ -35,10 +41,10 @@ export function getSeriesStyle(
* @param displayName Pre-resolved localized name for the item. * @param displayName Pre-resolved localized name for the item.
*/ */
export function getSeriesLabel( export function getSeriesLabel(
key: string, key: string,
uCombs: number, uCombs: number,
uItems: number, uItems: number,
uSigs: number, uSigs: number,
displayName: string, displayName: string,
): string { ): string {
const [combinator, , sig] = key.split('::'); const [combinator, , sig] = key.split('::');
@@ -46,7 +52,7 @@ export function getSeriesLabel(
const parts: string[] = []; const parts: string[] = [];
if (uItems > 1) parts.push(displayName); if (uItems > 1) parts.push(displayName);
if (uCombs > 1) parts.push(`(${combinator})`); if (uCombs > 1) parts.push(`(${combinator})`);
if (uSigs > 1) parts.push(`[${sig}]`); if (uSigs > 1) parts.push(`[${sig}]`);
return parts.join(' '); return parts.join(' ');
} }
@@ -54,8 +60,8 @@ export function getSeriesLabel(
export const AXIS_BASE: uPlot.Axis = { export const AXIS_BASE: uPlot.Axis = {
stroke: '#9ca3af', stroke: '#9ca3af',
ticks: { stroke: '#374151' }, ticks: { stroke: '#374151' },
grid: { stroke: '#1f2937' }, grid: { stroke: '#1f2937' },
}; };
export const CURSOR_NO_DRAG: uPlot.Cursor = { export const CURSOR_NO_DRAG: uPlot.Cursor = {
@@ -67,17 +73,19 @@ export const CURSOR_NO_DRAG: uPlot.Cursor = {
* 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives. * 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives.
*/ */
export function makeYScale( export function makeYScale(
yMin: number | null, yMin: number | null,
yMax: number | null, yMax: number | null,
yScale: ChartConfig['y_scale'] = 'linear', yScale: ChartConfig['y_scale'] = 'linear',
): uPlot.Scale { ): uPlot.Scale {
if (yScale === 'log') { if (yScale === 'log') {
return { return {
distr: 4, distr: 4,
asinh: 1, asinh: 1,
...(yMin !== null || yMax !== null ? { ...(yMin !== null || yMax !== null
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax], ? {
} : {}), range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
}
: {}),
}; };
} }
@@ -85,53 +93,61 @@ export function makeYScale(
return { return {
dir: 1, dir: 1,
range: (_u, dataMin, dataMax) => { range: (_u, dataMin, dataMax) => {
const lo = yMin ?? (dataMin ?? 0); const lo = yMin ?? dataMin ?? 0;
const hi = yMax ?? (dataMax ?? 1); const hi = yMax ?? dataMax ?? 1;
if (lo === hi) return [lo - 1, hi + 1]; if (lo === hi) return [lo - 1, hi + 1];
const pad = (yMin == null || yMax == null) ? Math.abs(hi - lo) * 0.05 : 0; const pad = yMin == null || yMax == null ? Math.abs(hi - lo) * 0.05 : 0;
return [lo - pad, hi + pad]; return [lo - pad, hi + pad];
}, },
}; };
} }
export function makeAnnotationHooks( export function makeAnnotationHooks(
sessionXs: number[], sessionXs: number[],
alertThresholds: number[], alertThresholds: number[],
): uPlot.Options['hooks'] { ): uPlot.Options['hooks'] {
return { return {
draw: [(u) => { draw: [
const { ctx, bbox } = u; (u) => {
ctx.save(); const { ctx, bbox } = u;
ctx.save();
ctx.strokeStyle = 'rgba(251,191,36,0.6)'; ctx.strokeStyle = 'rgba(251,191,36,0.6)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]); ctx.setLineDash([4, 4]);
for (const sx of sessionXs) { for (const sx of sessionXs) {
const cx = Math.round(u.valToPos(sx, 'x', true)); 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.beginPath();
} ctx.moveTo(cx, bbox.top);
ctx.lineTo(cx, bbox.top + bbox.height);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(248,113,113,0.7)'; ctx.strokeStyle = 'rgba(248,113,113,0.7)';
ctx.setLineDash([6, 3]); ctx.setLineDash([6, 3]);
for (const t of alertThresholds) { for (const t of alertThresholds) {
const cy = Math.round(u.valToPos(t, 'y', true)); 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.beginPath();
} ctx.moveTo(bbox.left, cy);
ctx.lineTo(bbox.left + bbox.width, cy);
ctx.stroke();
}
ctx.restore(); ctx.restore();
}], },
],
}; };
} }
export function makeSignalsSeries( export function makeSignalsSeries(
keys: string[], keys: string[],
timeMode: 'real' | 'tick', timeMode: 'real' | 'tick',
resolveName: (key: string) => string, resolveName: (key: string) => string,
colorMap: ColorMap = new Map(), colorMap: ColorMap = new Map(),
): uPlot.Series[] { ): uPlot.Series[] {
const uCombs = new Set(keys.map(k => k.split('::')[0])).size; const uCombs = new Set(keys.map((k) => k.split('::')[0])).size;
const uItems = new Set(keys.map(k => k.split('::')[1])).size; const uItems = new Set(keys.map((k) => k.split('::')[1])).size;
const uSigs = new Set(keys.map(k => k.split('::')[2])).size; const uSigs = new Set(keys.map((k) => k.split('::')[2])).size;
const xSeries: uPlot.Series = { const xSeries: uPlot.Series = {
label: timeMode === 'tick' ? 'Tick' : 'Time', label: timeMode === 'tick' ? 'Tick' : 'Time',
@@ -143,13 +159,13 @@ export function makeSignalsSeries(
return [ return [
xSeries, xSeries,
...keys.map(k => { ...keys.map((k) => {
const [, item_key] = k.split('::'); const [, item_key] = k.split('::');
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap); const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
return { return {
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)), label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
stroke: color, stroke: color,
width: 1.5, width: 1.5,
dash, dash,
}; };
}), }),
@@ -160,16 +176,17 @@ export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPl
return [ return [
{ {
...AXIS_BASE, ...AXIS_BASE,
values: timeMode === 'real' values:
? (_u: uPlot, vals: (number | null)[]) => timeMode === 'real'
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()) ? (_u: uPlot, vals: (number | null)[]) =>
: (_u: uPlot, vals: (number | null)[]) => vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
vals.map(v => v == null ? '' : formatSI(v, locale)), : (_u: uPlot, vals: (number | null)[]) =>
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
}, },
{ {
...AXIS_BASE, ...AXIS_BASE,
values: (_u: uPlot, vals: (number | null)[]) => values: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => v == null ? '' : formatSI(v, locale)), vals.map((v) => (v == null ? '' : formatSI(v, locale))),
}, },
]; ];
} }

View File

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

View File

@@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef } from 'react';
import uPlot from 'uplot'; import uPlot from 'uplot';
export type BuildFn = ( export type BuildFn = (
el: HTMLDivElement, el: HTMLDivElement,
w: number, w: number,
h: number, h: number,
legendRef: React.RefObject<HTMLDivElement>, legendRef: React.RefObject<HTMLDivElement>,
) => uPlot | null; ) => uPlot | null;
@@ -25,17 +25,21 @@ function idxToPixel(plot: uPlot, idx: number): number {
export function usePlot( export function usePlot(
build: BuildFn, build: BuildFn,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
deps: any[], deps: any[],
): { containerRef: React.RefObject<HTMLDivElement | null>; legendRef: React.RefObject<HTMLDivElement> } { ): {
containerRef: React.RefObject<HTMLDivElement | null>;
legendRef: React.RefObject<HTMLDivElement>;
} {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const legendRef = useRef<HTMLDivElement>(null!); const legendRef = useRef<HTMLDivElement>(null!);
const plotRef = useRef<uPlot | null>(null); const plotRef = useRef<uPlot | null>(null);
const lastIdxRef = useRef<number>(0); const lastIdxRef = useRef<number>(0);
const rebuild = useCallback(() => { const rebuild = useCallback(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
const w = el.clientWidth, h = el.clientHeight; const w = el.clientWidth,
h = el.clientHeight;
if (w < 10 || h < 10) return; if (w < 10 || h < 10) return;
plotRef.current?.destroy(); plotRef.current?.destroy();
@@ -60,12 +64,15 @@ export function usePlot(
}); });
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps); }, deps);
useEffect(() => { useEffect(() => {
rebuild(); rebuild();
return () => { plotRef.current?.destroy(); plotRef.current = null; }; return () => {
plotRef.current?.destroy();
plotRef.current = null;
};
}, [rebuild]); }, [rebuild]);
useEffect(() => { useEffect(() => {
@@ -77,4 +84,4 @@ export function usePlot(
}, [rebuild]); }, [rebuild]);
return { containerRef, legendRef }; return { containerRef, legendRef };
} }

View File

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

View File

@@ -6,12 +6,20 @@ import type { Layout, LayoutItem } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useApp } from '@/lib/context'; import { useApp } from '@/lib/context';
import { fetchCharts, createChart, updateChart, deleteChart, fetchSignals, fetchSessions, fetchUps } from '@/lib/api'; import {
fetchCharts,
createChart,
updateChart,
deleteChart,
fetchSignals,
fetchSessions,
fetchUps,
} from '@/lib/api';
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types'; import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
import ChartCard from './ChartCard'; import ChartCard from './ChartCard';
import ChartEditor from './ChartEditor'; import ChartEditor from './ChartEditor';
const COLS = 6; const COLS = 6;
const ROW_HEIGHT = 80; const ROW_HEIGHT = 80;
interface Props { interface Props {
@@ -20,21 +28,25 @@ interface Props {
export default function Dashboard({ alerts }: Props) { export default function Dashboard({ alerts }: Props) {
const { timeRange, timeMode, getFromTo } = useApp(); const { timeRange, timeMode, getFromTo } = useApp();
const [charts, setCharts] = useState<ChartConfig[]>([]); const [charts, setCharts] = useState<ChartConfig[]>([]);
const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map()); const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map());
const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map()); const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map());
const [sessions, setSessions] = useState<SessionBoundary[]>([]); const [sessions, setSessions] = useState<SessionBoundary[]>([]);
const [editingChart, setEditingChart] = useState<ChartConfig | null>(null); const [editingChart, setEditingChart] = useState<ChartConfig | null>(null);
const [creatingChart, setCreatingChart] = useState(false); const [creatingChart, setCreatingChart] = useState(false);
const [containerWidth, setContainerWidth] = useState(1200); const [containerWidth, setContainerWidth] = useState(1200);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const chartsRef = useRef<ChartConfig[]>([]); const chartsRef = useRef<ChartConfig[]>([]);
const refreshingRef = useRef(false); const refreshingRef = useRef(false);
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { chartsRef.current = charts; }, [charts]); useEffect(() => {
useEffect(() => { fetchCharts().then(setCharts); }, []); chartsRef.current = charts;
}, [charts]);
useEffect(() => {
fetchCharts().then(setCharts);
}, []);
const refreshData = useCallback(async () => { const refreshData = useCallback(async () => {
if (refreshingRef.current) return; if (refreshingRef.current) return;
@@ -44,24 +56,27 @@ export default function Dashboard({ alerts }: Props) {
refreshingRef.current = true; refreshingRef.current = true;
try { try {
const { from, to } = getFromTo(); const { from, to } = getFromTo();
const signalCharts = current.filter(c => c.chart_type === 'signals'); const signalCharts = current.filter((c) => c.chart_type === 'signals');
const upsCharts = current.filter(c => c.chart_type === 'ups'); const upsCharts = current.filter((c) => c.chart_type === 'ups');
if (signalCharts.length === 0 && upsCharts.length === 0) return; if (signalCharts.length === 0 && upsCharts.length === 0) return;
const [newSessions, ...results] = await Promise.all([ const [newSessions, ...results] = await Promise.all([
fetchSessions(from, to), fetchSessions(from, to),
...signalCharts.map(c => fetchSignals({ ...signalCharts.map((c) =>
combinator: c.filter_combinators ?? undefined, fetchSignals({
item: c.filter_items ?? undefined, combinator: c.filter_combinators ?? undefined,
exclude: c.filter_items_exclude ?? undefined, item: c.filter_items ?? undefined,
signal: c.signal_type, exclude: c.filter_items_exclude ?? undefined,
time_mode: timeMode, signal: c.signal_type,
from, to, time_mode: timeMode,
regex: c.filter_items_regex || undefined, from,
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}), to,
})), regex: c.filter_items_regex || undefined,
...upsCharts.map(c => fetchUps({ combinator: c.filter_combinators?.[0], from, to })), ...(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[]); setSessions(newSessions as SessionBoundary[]);
@@ -98,47 +113,56 @@ export default function Dashboard({ alerts }: Props) {
async function handleCreate(draft: Omit<ChartConfig, 'id'>) { async function handleCreate(draft: Omit<ChartConfig, 'id'>) {
const created = await createChart(draft); const created = await createChart(draft);
setCharts(cs => [...cs, created]); setCharts((cs) => [...cs, created]);
setCreatingChart(false); setCreatingChart(false);
} }
async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) { async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) {
const updated = await updateChart(id, draft); const updated = await updateChart(id, draft);
setCharts(cs => cs.map(c => c.id === id ? updated : c)); setCharts((cs) => cs.map((c) => (c.id === id ? updated : c)));
setEditingChart(null); setEditingChart(null);
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
await deleteChart(id); await deleteChart(id);
setCharts(cs => cs.filter(c => c.id !== id)); setCharts((cs) => cs.filter((c) => c.id !== id));
} }
function handleLayoutChange(layout: Layout) { function handleLayoutChange(layout: Layout) {
const items = layout as readonly LayoutItem[]; const items = layout as readonly LayoutItem[];
const changed = items.filter(item => { const changed = items.filter((item) => {
const chart = chartsRef.current.find(c => c.id === item.i); const chart = chartsRef.current.find((c) => c.id === item.i);
return chart && ( return (
chart.pos_x !== item.x || chart.pos_y !== item.y || chart &&
chart.width !== item.w || chart.height !== item.h (chart.pos_x !== item.x ||
chart.pos_y !== item.y ||
chart.width !== item.w ||
chart.height !== item.h)
); );
}); });
if (changed.length === 0) return; if (changed.length === 0) return;
setCharts(cs => cs.map(c => { setCharts((cs) =>
const l = changed.find(item => item.i === c.id); cs.map((c) => {
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : 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); if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current);
layoutSaveTimer.current = setTimeout(() => { layoutSaveTimer.current = setTimeout(() => {
changed.forEach(item => changed.forEach((item) =>
updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }), updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }),
); );
}, 500); }, 500);
} }
const layout: Layout = charts.map(c => ({ const layout: Layout = charts.map((c) => ({
i: c.id, x: c.pos_x, y: c.pos_y, w: c.width, h: c.height, i: c.id,
x: c.pos_x,
y: c.pos_y,
w: c.width,
h: c.height,
minW: 1, minW: 1,
minH: c.chart_type === 'divider' ? 1 : 2, minH: c.chart_type === 'divider' ? 1 : 2,
})); }));
@@ -153,7 +177,8 @@ export default function Dashboard({ alerts }: Props) {
dragConfig={{ handle: '.drag-handle' }} dragConfig={{ handle: '.drag-handle' }}
resizeConfig={{ resizeConfig={{
handleComponent: (axis, ref) => ( handleComponent: (axis, ref) => (
<span ref={ref} <span
ref={ref}
className="react-resizable-handle react-resizable-handle-se" className="react-resizable-handle react-resizable-handle-se"
style={{ style={{
borderRight: '3px solid #4b5563', borderRight: '3px solid #4b5563',
@@ -164,16 +189,18 @@ export default function Dashboard({ alerts }: Props) {
), ),
}} }}
> >
{charts.map(c => ( {charts.map((c) => (
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing"> <div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
<ChartCard <ChartCard
config={c} config={c}
rows={signalData.get(c.id) ?? []} rows={signalData.get(c.id) ?? []}
upsRows={upsData.get(c.id) ?? []} upsRows={upsData.get(c.id) ?? []}
sessions={sessions} sessions={sessions}
alerts={alerts.filter(a => alerts={alerts.filter(
!c.filter_combinators || !a.combinator || (a) =>
c.filter_combinators.includes(a.combinator), !c.filter_combinators ||
!a.combinator ||
c.filter_combinators.includes(a.combinator),
)} )}
timeMode={timeMode} timeMode={timeMode}
onEdit={() => setEditingChart(c)} onEdit={() => setEditingChart(c)}
@@ -196,10 +223,10 @@ export default function Dashboard({ alerts }: Props) {
{editingChart && ( {editingChart && (
<ChartEditor <ChartEditor
initial={editingChart} initial={editingChart}
onSave={draft => handleUpdate(editingChart.id, draft)} onSave={(draft) => handleUpdate(editingChart.id, draft)}
onClose={() => setEditingChart(null)} onClose={() => setEditingChart(null)}
/> />
)} )}
</div> </div>
); );
} }

View File

@@ -11,7 +11,7 @@ export default function TimeRangeSelector() {
return ( return (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex rounded overflow-hidden border border-gray-700"> <div className="flex rounded overflow-hidden border border-gray-700">
{RANGES.map(r => ( {RANGES.map((r) => (
<button <button
key={r} key={r}
onClick={() => setTimeRange(r)} onClick={() => setTimeRange(r)}
@@ -27,7 +27,7 @@ export default function TimeRangeSelector() {
</div> </div>
<div className="flex rounded overflow-hidden border border-gray-700"> <div className="flex rounded overflow-hidden border border-gray-700">
{(['real', 'tick'] as TimeMode[]).map(m => ( {(['real', 'tick'] as TimeMode[]).map((m) => (
<button <button
key={m} key={m}
onClick={() => setTimeMode(m)} onClick={() => setTimeMode(m)}
@@ -43,4 +43,4 @@ export default function TimeRangeSelector() {
</div> </div>
</div> </div>
); );
} }

View File

@@ -7,9 +7,9 @@ services:
POSTGRES_PASSWORD: factorio POSTGRES_PASSWORD: factorio
POSTGRES_DB: factorio POSTGRES_DB: factorio
ports: ports:
- "5432:5432" - '5432:5432'
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
volumes: volumes:
db_data: db_data:

View File

@@ -1,20 +1,39 @@
import type { ChartConfig, AlertConfig, TriggeredAlert, SignalRow, SessionBoundary, UpsRow, TimeMode } from './types'; import type {
ChartConfig,
AlertConfig,
TriggeredAlert,
SignalRow,
SessionBoundary,
UpsRow,
TimeMode,
} from './types';
let _token = ''; let _token = '';
export function setToken(token: string) { _token = token; } export function setToken(token: string) {
_token = token;
}
function url(path: string, params: Record<string, string | string[] | boolean | number | undefined> = {}) { function url(
const u = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'); 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); u.searchParams.set('token', _token);
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
if (v === undefined) continue; if (v === undefined) continue;
if (Array.isArray(v)) v.forEach(val => u.searchParams.append(k, String(val))); if (Array.isArray(v)) v.forEach((val) => u.searchParams.append(k, String(val)));
else u.searchParams.set(k, String(v)); else u.searchParams.set(k, String(v));
} }
return u.toString(); return u.toString();
} }
async function get<T>(path: string, params?: Record<string, string | string[] | boolean | number | undefined>): Promise<T> { async function get<T>(
path: string,
params?: Record<string, string | string[] | boolean | number | undefined>,
): Promise<T> {
const res = await fetch(url(path, params)); const res = await fetch(url(path, params));
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json(); return res.json();
@@ -45,7 +64,11 @@ export function fetchSignals(params: {
return get('/api/signals', params as Record<string, string | string[]>); return get('/api/signals', params as Record<string, string | string[]>);
} }
export function fetchUps(params: { combinator?: string; from?: string; to?: string }): Promise<UpsRow[]> { export function fetchUps(params: {
combinator?: string;
from?: string;
to?: string;
}): Promise<UpsRow[]> {
return get('/api/ups', params); return get('/api/ups', params);
} }
@@ -53,13 +76,31 @@ export function fetchSessions(from: string, to: string): Promise<SessionBoundary
return get('/api/sessions', { from, to }); return get('/api/sessions', { from, to });
} }
export function fetchCharts(): Promise<ChartConfig[]> { return get('/api/charts'); } export function fetchCharts(): Promise<ChartConfig[]> {
export function createChart(body: Omit<ChartConfig, 'id'>): Promise<ChartConfig> { return mutate('POST', '/api/charts', body); } return get('/api/charts');
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 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 fetchAlerts(): Promise<AlertConfig[]> {
export function createAlert(body: Omit<AlertConfig, 'id' | 'active'>): Promise<AlertConfig> { return mutate('POST', '/api/alerts', body); } return get('/api/alerts');
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 createAlert(body: Omit<AlertConfig, 'id' | 'active'>): Promise<AlertConfig> {
export function checkAlerts(): Promise<TriggeredAlert[]> { return get('/api/alerts/check'); } 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');
}

View File

@@ -23,4 +23,4 @@ export function withAuth(handler: Handler): Handler {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
}; };
} }

View File

@@ -19,4 +19,4 @@ export function isAuthorized(req: NextRequest): boolean {
export function unauthorized(): NextResponse { export function unauthorized(): NextResponse {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }

View File

@@ -1,6 +1,8 @@
export type ColorMap = Map<string, string>; export type ColorMap = Map<string, string>;
declare global { var __colorMapCache: ColorMap | undefined; } declare global {
var __colorMapCache: ColorMap | undefined;
}
export function parseColorCsv(text: string): ColorMap { export function parseColorCsv(text: string): ColorMap {
const map: ColorMap = new Map(); const map: ColorMap = new Map();

View File

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

View File

@@ -11,4 +11,4 @@ if (process.env.NODE_ENV !== 'production') {
globalThis.__pgPool = pool; globalThis.__pgPool = pool;
} }
export default pool; export default pool;

View File

@@ -3,7 +3,9 @@ import path from 'path';
import { parseCsv } from './localization'; import { parseCsv } from './localization';
import type { LocaleMap } from './localization'; import type { LocaleMap } from './localization';
declare global { var __serverLocaleCache: LocaleMap | undefined; } declare global {
var __serverLocaleCache: LocaleMap | undefined;
}
/** /**
* Loads and merges EN + DE locale CSVs from the public directory. * Loads and merges EN + DE locale CSVs from the public directory.
@@ -23,7 +25,10 @@ export function getServerLocaleMap(): LocaleMap {
} }
} }
const merged: LocaleMap = new Map([...load('factorio_english_items.csv'), ...load('factorio_german_items.csv')]); const merged: LocaleMap = new Map([
...load('factorio_english_items.csv'),
...load('factorio_german_items.csv'),
]);
globalThis.__serverLocaleCache = merged; globalThis.__serverLocaleCache = merged;
return merged; return merged;
} }

View File

@@ -20,8 +20,10 @@ function splitCsvLine(line: string): string[] {
for (let i = 0; i < line.length; i++) { for (let i = 0; i < line.length; i++) {
const ch = line[i]; const ch = line[i];
if (ch === '"') { if (ch === '"') {
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; } if (inQuote && line[i + 1] === '"') {
else inQuote = !inQuote; cur += '"';
i++;
} else inQuote = !inQuote;
} else if (ch === ',' && !inQuote) { } else if (ch === ',' && !inQuote) {
cols.push(cur); cols.push(cur);
cur = ''; cur = '';
@@ -33,7 +35,9 @@ function splitCsvLine(line: string): string[] {
return cols; return cols;
} }
declare global { var __localeCache: LocaleMap | undefined; } declare global {
var __localeCache: LocaleMap | undefined;
}
async function loadCsv(path: string): Promise<LocaleMap> { async function loadCsv(path: string): Promise<LocaleMap> {
try { try {
@@ -100,4 +104,4 @@ export function matchKeys(pattern: string, map: LocaleMap): string[] {
if (re.test(key) || re.test(name)) result.add(key); if (re.test(key) || re.test(name)) result.add(key);
} }
return [...result]; return [...result];
} }

View File

@@ -5,10 +5,7 @@ import type { SessionBoundary } from '@/lib/types';
* Returns session-start timestamps where any gap > 30 min exists * Returns session-start timestamps where any gap > 30 min exists
* in the global tick_timing timeline. * in the global tick_timing timeline.
*/ */
export async function getSessionBoundaries( export async function getSessionBoundaries(from: Date, to: Date): Promise<SessionBoundary[]> {
from: Date,
to: Date,
): Promise<SessionBoundary[]> {
const result = await pool.query<{ real_time: Date; game_tick: string }>( const result = await pool.query<{ real_time: Date; game_tick: string }>(
`SELECT real_time, game_tick `SELECT real_time, game_tick
FROM tick_timing FROM tick_timing
@@ -20,10 +17,12 @@ export async function getSessionBoundaries(
const rows = result.rows; const rows = result.rows;
if (rows.length === 0) return []; if (rows.length === 0) return [];
const boundaries: SessionBoundary[] = [{ const boundaries: SessionBoundary[] = [
real_time: rows[0].real_time.toISOString(), {
game_tick: parseInt(rows[0].game_tick, 10), real_time: rows[0].real_time.toISOString(),
}]; game_tick: parseInt(rows[0].game_tick, 10),
},
];
for (let i = 1; i < rows.length; i++) { for (let i = 1; i < rows.length; i++) {
if (rows[i].real_time.getTime() - rows[i - 1].real_time.getTime() > 30 * 60 * 1000) { if (rows[i].real_time.getTime() - rows[i - 1].real_time.getTime() > 30 * 60 * 1000) {
@@ -35,4 +34,4 @@ export async function getSessionBoundaries(
} }
return boundaries; return boundaries;
} }

View File

@@ -31,7 +31,7 @@ export interface AlertConfig {
} }
export interface TriggeredAlert extends AlertConfig { export interface TriggeredAlert extends AlertConfig {
current_value: number; current_value: number;
combinator_match: string; combinator_match: string;
/** Actual matched item_key — differs from item_key when item_key_is_regex=true */ /** Actual matched item_key — differs from item_key when item_key_is_regex=true */
matched_item_key: string; matched_item_key: string;
@@ -63,9 +63,9 @@ export type TimeRange = '30m' | '1h' | '6h' | '24h' | '7d' | '30d';
export const TIME_RANGE_MS: Record<TimeRange, number> = { export const TIME_RANGE_MS: Record<TimeRange, number> = {
'30m': 30 * 60 * 1000, '30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000, '1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000, '6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000,
}; };

View File

@@ -2,51 +2,67 @@
exports.up = (pgm) => { exports.up = (pgm) => {
pgm.sql(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`); pgm.sql(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`);
pgm.createTable('signals', { pgm.createTable(
real_time: { type: 'timestamptz', notNull: true }, 'signals',
game_tick: { type: 'bigint', notNull: true }, {
combinator: { type: 'text', notNull: true }, real_time: { type: 'timestamptz', notNull: true },
item_key: { type: 'text', notNull: true }, game_tick: { type: 'bigint', notNull: true },
green: { type: 'integer', notNull: true, default: 0 }, combinator: { type: 'text', notNull: true },
red: { type: 'integer', notNull: true, default: 0 }, item_key: { type: 'text', notNull: true },
logistic: { type: 'integer' }, green: { type: 'integer', notNull: true, default: 0 },
}, { ifNotExists: true }); 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(`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_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(`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.sql(`SELECT add_retention_policy('signals', INTERVAL '30 days', if_not_exists => true)`);
pgm.createTable('tick_timing', { pgm.createTable(
real_time: { type: 'timestamptz', notNull: true }, 'tick_timing',
game_tick: { type: 'bigint', notNull: true }, {
combinator: { type: 'text', notNull: true }, real_time: { type: 'timestamptz', notNull: true },
}, { ifNotExists: 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(`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(
`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.sql(`SELECT add_retention_policy('tick_timing', INTERVAL '30 days', if_not_exists => true)`);
pgm.createTable('charts', { pgm.createTable(
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, 'charts',
title: { type: 'text', notNull: true }, {
pos_x: { type: 'integer', notNull: true, default: 0 }, id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
pos_y: { type: 'integer', notNull: true, default: 0 }, title: { type: 'text', notNull: true },
width: { type: 'integer', notNull: true, default: 2 }, pos_x: { type: 'integer', notNull: true, default: 0 },
height: { type: 'integer', notNull: true, default: 4 }, pos_y: { type: 'integer', notNull: true, default: 0 },
signal_type: { type: 'text', notNull: true, default: 'both' }, width: { type: 'integer', notNull: true, default: 2 },
chart_type: { type: 'text', notNull: true, default: 'signals' }, height: { type: 'integer', notNull: true, default: 4 },
viz_type: { type: 'text', notNull: true, default: 'line' }, signal_type: { type: 'text', notNull: true, default: 'both' },
filter_combinators: { type: 'text[]' }, chart_type: { type: 'text', notNull: true, default: 'signals' },
filter_items: { type: 'text[]' }, viz_type: { type: 'text', notNull: true, default: 'line' },
filter_items_exclude: { type: 'text[]' }, filter_combinators: { type: 'text[]' },
filter_items_regex: { type: 'boolean', notNull: true, default: false }, filter_items: { type: 'text[]' },
y_min: { type: 'real' }, filter_items_exclude: { type: 'text[]' },
y_max: { type: 'real' }, filter_items_regex: { type: 'boolean', notNull: true, default: false },
series_limit: { type: 'integer', notNull: true, default: 20 }, y_min: { type: 'real' },
order_by: { type: 'text', notNull: true, default: 'time' }, y_max: { type: 'real' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, series_limit: { type: 'integer', notNull: true, default: 20 },
}, { ifNotExists: true }); 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 // Use DO blocks so constraints are idempotent on existing DBs
pgm.sql(`DO $$ BEGIN pgm.sql(`DO $$ BEGIN
@@ -65,17 +81,21 @@ exports.up = (pgm) => {
ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc')); 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 $$`); EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.createTable('alerts', { pgm.createTable(
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, 'alerts',
item_key: { type: 'text', notNull: true }, {
item_key_is_regex: { type: 'boolean', notNull: true, default: false }, id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
combinator: { type: 'text' }, item_key: { type: 'text', notNull: true },
signal_type: { type: 'text', notNull: true, default: 'green' }, item_key_is_regex: { type: 'boolean', notNull: true, default: false },
condition: { type: 'text', notNull: true }, combinator: { type: 'text' },
threshold: { type: 'integer', notNull: true }, signal_type: { type: 'text', notNull: true, default: 'green' },
active: { type: 'boolean', notNull: true, default: true }, condition: { type: 'text', notNull: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, threshold: { type: 'integer', notNull: true },
}, { ifNotExists: true }); active: { type: 'boolean', notNull: true, default: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
},
{ ifNotExists: true },
);
pgm.sql(`DO $$ BEGIN pgm.sql(`DO $$ BEGIN
ALTER TABLE alerts ADD CONSTRAINT alerts_signal_type_check CHECK (signal_type IN ('green','red')); ALTER TABLE alerts ADD CONSTRAINT alerts_signal_type_check CHECK (signal_type IN ('green','red'));
@@ -85,10 +105,14 @@ exports.up = (pgm) => {
ALTER TABLE alerts ADD CONSTRAINT alerts_condition_check CHECK (condition IN ('above','below')); ALTER TABLE alerts ADD CONSTRAINT alerts_condition_check CHECK (condition IN ('above','below'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`); EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.createTable('settings', { pgm.createTable(
key: { type: 'text', primaryKey: true }, 'settings',
value: { type: 'text', notNull: true }, {
}, { ifNotExists: true }); key: { type: 'text', primaryKey: true },
value: { type: 'text', notNull: true },
},
{ ifNotExists: true },
);
}; };
exports.down = () => Promise.resolve(); exports.down = () => Promise.resolve();

View File

@@ -7,12 +7,16 @@ exports.up = (pgm) => {
EXCEPTION WHEN duplicate_object THEN NULL; END $$`); 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 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'))`); 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) => { exports.down = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`); 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 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 CONSTRAINT IF EXISTS charts_y_scale_check`);
pgm.sql(`ALTER TABLE charts DROP COLUMN IF EXISTS y_scale`); pgm.sql(`ALTER TABLE charts DROP COLUMN IF EXISTS y_scale`);
}; };

View File

@@ -1,10 +1,14 @@
/** @type {import('node-pg-migrate').MigrationBuilder} */ /** @type {import('node-pg-migrate').MigrationBuilder} */
exports.up = (pgm) => { exports.up = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`); 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 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 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'))`); 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 // Migrate any existing stacked charts to line
pgm.sql(`UPDATE charts SET viz_type = 'line' WHERE viz_type = 'stacked'`); pgm.sql(`UPDATE charts SET viz_type = 'line' WHERE viz_type = 'stacked'`);
@@ -12,8 +16,12 @@ exports.up = (pgm) => {
exports.down = (pgm) => { exports.down = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`); 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 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 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'))`); pgm.sql(
}; `ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`,
);
};

View File

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

17
web/package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"node-pg-migrate": "^8.0.4", "node-pg-migrate": "^8.0.4",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"prettier": "^3.8.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
@@ -1955,6 +1956,22 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@@ -6,7 +6,9 @@
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "echo 'no lint'",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"migrate": "node-pg-migrate up -m migrations", "migrate": "node-pg-migrate up -m migrations",
"migrate:down": "node-pg-migrate down -m migrations", "migrate:down": "node-pg-migrate down -m migrations",
"migrate:create": "node-pg-migrate create -m migrations" "migrate:create": "node-pg-migrate create -m migrations"
@@ -28,6 +30,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"node-pg-migrate": "^8.0.4", "node-pg-migrate": "^8.0.4",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"prettier": "^3.8.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }

View File

@@ -1,6 +1,6 @@
const config = { const config = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, '@tailwindcss/postcss': {},
}, },
}; };