From cf9bb33ecb33d10f2e31bc16e2147f8e0e5b0458 Mon Sep 17 00:00:00 2001 From: Sebastian Seedorf Date: Thu, 4 Jun 2026 11:44:20 +0200 Subject: [PATCH] chore: add prettier with config and format all files --- web/.prettierignore | 6 + web/.prettierrc | 7 + web/AGENTS.md | 2 + web/README.md | 3 +- web/app/api/alerts/[id]/route.ts | 5 +- web/app/api/alerts/check/route.ts | 24 +- web/app/api/alerts/route.ts | 15 +- web/app/api/charts/[id]/route.ts | 53 ++- web/app/api/charts/route.ts | 46 ++- web/app/api/ingest/[combinator]/route.ts | 24 +- web/app/api/sessions/route.ts | 4 +- web/app/api/signals/route.ts | 94 ++--- web/app/api/ups/route.ts | 16 +- web/app/globals.css | 3 +- web/app/layout.tsx | 14 +- web/app/page.tsx | 10 +- web/bin/fix-colors.ts | 86 +++-- .../factorio-signal-exporter/Chart.yaml | 2 +- .../factorio-signal-exporter/values.yaml | 12 +- web/components/AlertPanel.tsx | 196 +++++++---- web/components/ChartCard/CardShell.tsx | 117 ++++--- web/components/ChartCard/DividerCard.tsx | 24 +- web/components/ChartCard/SignalsChart.tsx | 82 +++-- web/components/ChartCard/TableViz.tsx | 30 +- web/components/ChartCard/UpsChart.tsx | 71 ++-- web/components/ChartCard/index.tsx | 29 +- web/components/ChartCard/plotHelpers.ts | 133 ++++---- web/components/ChartCard/seriesData.ts | 32 +- web/components/ChartCard/usePlot.ts | 31 +- web/components/ChartEditor.tsx | 323 ++++++++++++------ web/components/Dashboard.tsx | 123 ++++--- web/components/TimeRangeSelector.tsx | 6 +- web/docker-compose.yml | 4 +- web/lib/api.ts | 73 +++- web/lib/apiHelpers.ts | 2 +- web/lib/auth.ts | 2 +- web/lib/colors.ts | 4 +- web/lib/context.tsx | 58 ++-- web/lib/db.ts | 2 +- web/lib/localeServer.ts | 11 +- web/lib/localization.ts | 12 +- web/lib/sessions.ts | 17 +- web/lib/types.ts | 10 +- web/migrations/001_initial_schema.js | 128 ++++--- web/migrations/002_add_y_scale_delta_order.js | 10 +- .../003_remove_stacked_add_divider.js | 18 +- web/next.config.ts | 6 +- web/package-lock.json | 17 + web/package.json | 5 +- web/postcss.config.mjs | 2 +- 50 files changed, 1290 insertions(+), 714 deletions(-) create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..75261c4 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,6 @@ +node_modules +.next +out +dist +charts +public/*.csv diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..7ab2c25 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/web/AGENTS.md b/web/AGENTS.md index 8bd0e39..c153a9b 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,5 +1,7 @@ + # 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. + diff --git a/web/README.md b/web/README.md index c7b702e..6b73cdd 100644 --- a/web/README.md +++ b/web/README.md @@ -19,4 +19,5 @@ DATABASE_URL=postgresql://factorio:factorio@localhost:5432/factorio npm run migr # 4. Start dev server cp .env.local.example .env.local # fill in API_TOKEN -npm run dev \ No newline at end of file +npm run dev +``` diff --git a/web/app/api/alerts/[id]/route.ts b/web/app/api/alerts/[id]/route.ts index 1f2339b..df342f9 100644 --- a/web/app/api/alerts/[id]/route.ts +++ b/web/app/api/alerts/[id]/route.ts @@ -5,7 +5,8 @@ import { withAuth } from '@/lib/apiHelpers'; export const PUT = withAuth(async (req: NextRequest, { params }) => { const { id } = await params; const body = await req.json(); - const { item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active } = body; + const { item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active } = + body; const result = await pool.query( `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]); if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ ok: true }); -}); \ No newline at end of file +}); diff --git a/web/app/api/alerts/check/route.ts b/web/app/api/alerts/check/route.ts index ac08790..e7832a0 100644 --- a/web/app/api/alerts/check/route.ts +++ b/web/app/api/alerts/check/route.ts @@ -13,7 +13,10 @@ export const GET = withAuth(async () => { if (alertsResult.rows.length === 0) return NextResponse.json([]); 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 FROM signals @@ -22,9 +25,7 @@ export const GET = withAuth(async () => { const localeMap = getServerLocaleMap(); - const latestMap = new Map( - latestResult.rows.map(r => [`${r.combinator}::${r.item_key}`, r]), - ); + const latestMap = new Map(latestResult.rows.map((r) => [`${r.combinator}::${r.item_key}`, r])); const triggered: TriggeredAlert[] = []; @@ -49,14 +50,15 @@ export const GET = withAuth(async () => { const value = alert.signal_type === 'green' ? vals.green : vals.red; const fired = alert.condition === 'above' ? value > alert.threshold : value < alert.threshold; - if (fired) triggered.push({ - ...alert, - current_value: value, - combinator_match: combinator, - matched_item_key: item_key, - }); + if (fired) + triggered.push({ + ...alert, + current_value: value, + combinator_match: combinator, + matched_item_key: item_key, + }); } } return NextResponse.json(triggered); -}); \ No newline at end of file +}); diff --git a/web/app/api/alerts/route.ts b/web/app/api/alerts/route.ts index 98a7387..30c0baf 100644 --- a/web/app/api/alerts/route.ts +++ b/web/app/api/alerts/route.ts @@ -10,15 +10,16 @@ export const GET = withAuth(async () => { export const POST = withAuth(async (req: NextRequest) => { const body = await req.json(); const { - item_key, item_key_is_regex = false, - combinator = null, signal_type = 'green', condition, threshold, + item_key, + item_key_is_regex = false, + combinator = null, + signal_type = 'green', + condition, + threshold, } = body; if (!item_key || !condition || threshold === undefined) { - return NextResponse.json( - { error: 'item_key, condition, threshold required' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'item_key, condition, threshold required' }, { status: 400 }); } 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], ); return NextResponse.json(result.rows[0], { status: 201 }); -}); \ No newline at end of file +}); diff --git a/web/app/api/charts/[id]/route.ts b/web/app/api/charts/[id]/route.ts index 322ac02..7b7bd85 100644 --- a/web/app/api/charts/[id]/route.ts +++ b/web/app/api/charts/[id]/route.ts @@ -6,17 +6,25 @@ export const PUT = withAuth(async (req: NextRequest, { params }) => { const { id } = await params; const body = await req.json(); const { - title, pos_x, pos_y, width, height, - signal_type, chart_type, viz_type, - filter_items_regex, y_scale, - series_limit, order_by, + title, + pos_x, + pos_y, + width, + height, + signal_type, + chart_type, + viz_type, + filter_items_regex, + y_scale, + series_limit, + order_by, } = body; - const hasFilterCombinators = 'filter_combinators' in body; - const hasFilterItems = 'filter_items' in body; + const hasFilterCombinators = 'filter_combinators' in body; + const hasFilterItems = 'filter_items' in body; const hasFilterItemsExclude = 'filter_items_exclude' in body; - const hasYMin = 'y_min' in body; - const hasYMax = 'y_max' in body; + const hasYMin = 'y_min' in body; + const hasYMax = 'y_max' in body; const result = await pool.query( `UPDATE charts SET @@ -40,15 +48,28 @@ export const PUT = withAuth(async (req: NextRequest, { params }) => { WHERE id = $23 RETURNING *`, [ - title, pos_x, pos_y, width, height, signal_type, chart_type, viz_type, - hasFilterCombinators, body.filter_combinators ?? null, - hasFilterItems, body.filter_items ?? null, - hasFilterItemsExclude, body.filter_items_exclude ?? null, + title, + pos_x, + pos_y, + width, + height, + signal_type, + chart_type, + viz_type, + hasFilterCombinators, + body.filter_combinators ?? null, + hasFilterItems, + body.filter_items ?? null, + hasFilterItemsExclude, + body.filter_items_exclude ?? null, filter_items_regex, - hasYMin, body.y_min ?? null, - hasYMax, body.y_max ?? null, + hasYMin, + body.y_min ?? null, + hasYMax, + body.y_max ?? null, y_scale, - series_limit, order_by, + series_limit, + order_by, 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]); if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ ok: true }); -}); \ No newline at end of file +}); diff --git a/web/app/api/charts/route.ts b/web/app/api/charts/route.ts index 670a2cf..5dbfa2d 100644 --- a/web/app/api/charts/route.ts +++ b/web/app/api/charts/route.ts @@ -11,12 +11,22 @@ export const POST = withAuth(async (req: NextRequest) => { const body = await req.json(); const { title, - pos_x = 0, pos_y = 0, width = 2, height = 4, - signal_type = 'both', chart_type = 'signals', viz_type = 'line', - filter_combinators = null, filter_items = null, - filter_items_exclude = null, filter_items_regex = false, - y_min = null, y_max = null, y_scale = 'linear', - series_limit = 20, order_by = 'value_asc', + pos_x = 0, + pos_y = 0, + width = 2, + height = 4, + signal_type = 'both', + chart_type = 'signals', + viz_type = 'line', + filter_combinators = null, + filter_items = null, + filter_items_exclude = null, + filter_items_regex = false, + y_min = null, + y_max = null, + y_scale = 'linear', + series_limit = 20, + order_by = 'value_asc', } = body; 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) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING *`, - [title,pos_x,pos_y,width,height,signal_type,chart_type,viz_type, - filter_combinators,filter_items,filter_items_exclude,filter_items_regex, - y_min,y_max,y_scale,series_limit,order_by], + [ + title, + pos_x, + pos_y, + width, + height, + signal_type, + chart_type, + viz_type, + filter_combinators, + filter_items, + filter_items_exclude, + filter_items_regex, + y_min, + y_max, + y_scale, + series_limit, + order_by, + ], ); return NextResponse.json(result.rows[0], { status: 201 }); -}); \ No newline at end of file +}); diff --git a/web/app/api/ingest/[combinator]/route.ts b/web/app/api/ingest/[combinator]/route.ts index f59e282..189174a 100644 --- a/web/app/api/ingest/[combinator]/route.ts +++ b/web/app/api/ingest/[combinator]/route.ts @@ -4,11 +4,11 @@ import { withAuth } from '@/lib/apiHelpers'; interface CircuitNetwork { green: Record; - red: Record; + red: Record; } interface IngestBody { - game_tick: number; + game_tick: number; circuit_network: CircuitNetwork; logistic_network: Record; } @@ -31,10 +31,10 @@ export const POST = withAuth(async (req: NextRequest, { params }) => { return NextResponse.json({ error: 'Missing game_tick' }, { status: 400 }); } - const green = circuit_network?.green ?? {}; - const red = circuit_network?.red ?? {}; - const logistic = logistic_network ?? {}; - const allKeys = new Set([...Object.keys(green), ...Object.keys(red), ...Object.keys(logistic)]); + const green = circuit_network?.green ?? {}; + const red = circuit_network?.red ?? {}; + const logistic = logistic_network ?? {}; + const allKeys = new Set([...Object.keys(green), ...Object.keys(red), ...Object.keys(logistic)]); if (allKeys.size === 0) return NextResponse.json({ ok: true, rows: 0 }); @@ -47,7 +47,15 @@ export const POST = withAuth(async (req: NextRequest, { params }) => { let idx = 1; for (const key of allKeys) { placeholders.push(`($${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++})`); - values.push(realTime, game_tick, combinator, key, green[key] ?? 0, red[key] ?? 0, logistic[key] ?? null); + values.push( + realTime, + game_tick, + combinator, + key, + green[key] ?? 0, + red[key] ?? 0, + logistic[key] ?? null, + ); } await client.query( @@ -68,4 +76,4 @@ export const POST = withAuth(async (req: NextRequest, { params }) => { } finally { client.release(); } -}); \ No newline at end of file +}); diff --git a/web/app/api/sessions/route.ts b/web/app/api/sessions/route.ts index a602d38..75c6f92 100644 --- a/web/app/api/sessions/route.ts +++ b/web/app/api/sessions/route.ts @@ -5,7 +5,7 @@ import { withAuth } from '@/lib/apiHelpers'; export const GET = withAuth(async (req: NextRequest) => { const p = req.nextUrl.searchParams; const from = p.get('from') ? new Date(p.get('from')!) : new Date(Date.now() - 86_400_000); - const to = p.get('to') ? new Date(p.get('to')!) : new Date(); + const to = p.get('to') ? new Date(p.get('to')!) : new Date(); const boundaries = await getSessionBoundaries(from, to); return NextResponse.json(boundaries); -}); \ No newline at end of file +}); diff --git a/web/app/api/signals/route.ts b/web/app/api/signals/route.ts index 890a8ff..1ad11a0 100644 --- a/web/app/api/signals/route.ts +++ b/web/app/api/signals/route.ts @@ -6,15 +6,15 @@ import { matchKeys } from '@/lib/localization'; export const GET = withAuth(async (req: NextRequest) => { const p = req.nextUrl.searchParams; - const combinators = p.getAll('combinator'); + const combinators = p.getAll('combinator'); const itemsWhitelist = p.getAll('item'); const itemsBlacklist = p.getAll('exclude'); - const signalType = p.get('signal') ?? 'both'; - const from = p.get('from'); - const to = p.get('to'); - const useRegex = p.get('regex') === 'true'; - const orderBy = p.get('order_by') ?? 'value_asc'; - const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null; + const signalType = p.get('signal') ?? 'both'; + const from = p.get('from'); + const to = p.get('to'); + const useRegex = p.get('regex') === 'true'; + const orderBy = p.get('order_by') ?? 'value_asc'; + const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null; const conditions: string[] = []; const values: unknown[] = []; @@ -28,8 +28,8 @@ export const GET = withAuth(async (req: NextRequest) => { if (itemsWhitelist.length > 0) { if (useRegex) { const localeMap = getServerLocaleMap(); - const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))]; - const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|'); + const localeKeys = [...new Set(itemsWhitelist.flatMap((p) => matchKeys(p, localeMap)))]; + const sqlPattern = itemsWhitelist.map((p) => `(${p})`).join('|'); const orConds = [`item_key ~* $${i++}`]; values.push(sqlPattern); if (localeKeys.length > 0) { @@ -46,8 +46,8 @@ export const GET = withAuth(async (req: NextRequest) => { if (itemsBlacklist.length > 0) { if (useRegex) { const localeMap = getServerLocaleMap(); - const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))]; - const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|'); + const localeKeys = [...new Set(itemsBlacklist.flatMap((p) => matchKeys(p, localeMap)))]; + const sqlPattern = itemsBlacklist.map((p) => `(${p})`).join('|'); const andConds = [`item_key !~* $${i++}`]; values.push(sqlPattern); 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 (to) { conditions.push(`real_time <= $${i++}`); values.push(new Date(to)); } + if (from) { + conditions.push(`real_time >= $${i++}`); + values.push(new Date(from)); + } + if (to) { + conditions.push(`real_time <= $${i++}`); + values.push(new Date(to)); + } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - const valueCol = signalType === 'red' ? 'red' : 'green'; + const valueCol = signalType === 'red' ? 'red' : 'green'; const selectCols = - signalType === 'green' ? 'real_time, game_tick, combinator, item_key, green' - : signalType === 'red' ? 'real_time, game_tick, combinator, item_key, red' - : 'real_time, game_tick, combinator, item_key, green, red'; + signalType === 'green' + ? 'real_time, game_tick, combinator, item_key, green' + : signalType === 'red' + ? 'real_time, game_tick, combinator, item_key, red' + : 'real_time, game_tick, combinator, item_key, green, red'; if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) { const baseConditions: string[] = []; @@ -84,8 +92,8 @@ export const GET = withAuth(async (req: NextRequest) => { if (itemsWhitelist.length > 0) { if (useRegex) { const localeMap = getServerLocaleMap(); - const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))]; - const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|'); + const localeKeys = [...new Set(itemsWhitelist.flatMap((p) => matchKeys(p, localeMap)))]; + const sqlPattern = itemsWhitelist.map((p) => `(${p})`).join('|'); const orConds = [`item_key ~* $${j++}`]; baseValues.push(sqlPattern); if (localeKeys.length > 0) { @@ -101,8 +109,8 @@ export const GET = withAuth(async (req: NextRequest) => { if (itemsBlacklist.length > 0) { if (useRegex) { const localeMap = getServerLocaleMap(); - const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))]; - const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|'); + const localeKeys = [...new Set(itemsBlacklist.flatMap((p) => matchKeys(p, localeMap)))]; + const sqlPattern = itemsBlacklist.map((p) => `(${p})`).join('|'); const andConds = [`item_key !~* $${j++}`]; baseValues.push(sqlPattern); 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 deltaQuery = ` @@ -158,21 +166,27 @@ export const GET = withAuth(async (req: NextRequest) => { const top = deltaResult.rows; if (top.length === 0) return NextResponse.json([]); - const seriesConditions = top.map((_, idx) => - `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, + const seriesConditions = top.map( + (_, idx) => `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, ); const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`; - const orderCase = top.map((_, idx) => - `WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, - ).join(' '); + const orderCase = top + .map( + (_, idx) => + `WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, + ) + .join(' '); const result = await pool.query( `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); } - 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 }>( `SELECT DISTINCT ON (combinator, item_key) combinator, item_key, ${valueCol} AS val @@ -182,23 +196,27 @@ export const GET = withAuth(async (req: NextRequest) => { ); 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 === '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); if (top.length === 0) return NextResponse.json([]); - const seriesConditions = top.map((_, idx) => - `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, + const seriesConditions = top.map( + (_, idx) => `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, ); const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`; - const orderCase = top.map((_, idx) => - `WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, - ).join(' '); + const orderCase = top + .map( + (_, idx) => + `WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, + ) + .join(' '); const result = await pool.query( `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); } @@ -209,4 +227,4 @@ export const GET = withAuth(async (req: NextRequest) => { values, ); return NextResponse.json(result.rows); -}); \ No newline at end of file +}); diff --git a/web/app/api/ups/route.ts b/web/app/api/ups/route.ts index f2b24f5..f935cf4 100644 --- a/web/app/api/ups/route.ts +++ b/web/app/api/ups/route.ts @@ -6,7 +6,7 @@ export const GET = withAuth(async (req: NextRequest) => { const p = req.nextUrl.searchParams; const combinator = p.get('combinator'); const from = p.get('from') ? new Date(p.get('from')!) : new Date(Date.now() - 86_400_000); - const to = p.get('to') ? new Date(p.get('to')!) : new Date(); + const to = p.get('to') ? new Date(p.get('to')!) : new Date(); const conditions = ['real_time BETWEEN $1 AND $2']; const values: unknown[] = [from, to]; @@ -17,7 +17,9 @@ export const GET = withAuth(async (req: NextRequest) => { } 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 FROM tick_timing @@ -43,17 +45,17 @@ export const GET = withAuth(async (req: NextRequest) => { const prev = combiRows[i - 1]; const curr = combiRows[i]; const deltaRealMs = curr.real_time.getTime() - prev.real_time.getTime(); - const deltaTicks = parseInt(curr.game_tick, 10) - parseInt(prev.game_tick, 10); + const deltaTicks = parseInt(curr.game_tick, 10) - parseInt(prev.game_tick, 10); // Skip session gaps and bad data if (deltaRealMs > 30 * 60 * 1000 || deltaRealMs <= 0 || deltaTicks <= 0) continue; points.push({ - real_time: curr.real_time.toISOString(), - game_tick: parseInt(curr.game_tick, 10), + real_time: curr.real_time.toISOString(), + game_tick: parseInt(curr.game_tick, 10), combinator: combi, - ups: Math.round((deltaTicks / deltaRealMs) * 1000 * 10) / 10, + ups: Math.round((deltaTicks / deltaRealMs) * 1000 * 10) / 10, }); } } return NextResponse.json(points); -}); \ No newline at end of file +}); diff --git a/web/app/globals.css b/web/app/globals.css index 5d02aed..ba9405b 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,4 +1,4 @@ -@import "tailwindcss"; +@import 'tailwindcss'; :root { --background: #111827; @@ -15,4 +15,3 @@ body { color: var(--foreground); font-family: ui-sans-serif, system-ui, sans-serif; } - diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 8b14640..fc8e34b 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,17 +1,15 @@ -import type { Metadata } from "next"; -import "./globals.css"; +import type { Metadata } from 'next'; +import './globals.css'; export const metadata: Metadata = { - title: "Factorio Dashboard", - description: "Factorio signal monitor", + title: 'Factorio Dashboard', + description: 'Factorio signal monitor', }; -export default function RootLayout({ - children, -}: Readonly<{ children: React.ReactNode }>) { +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( {children} ); -} \ No newline at end of file +} diff --git a/web/app/page.tsx b/web/app/page.tsx index de9cbf1..3bfbccc 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -48,8 +48,8 @@ function DashboardApp() { const searchParams = useSearchParams(); const token = searchParams.get('token') ?? ''; - const [ready, setReady] = useState(false); - const [alerts, setAlerts] = useState([]); + const [ready, setReady] = useState(false); + const [alerts, setAlerts] = useState([]); const [localeMap, setLocaleMap] = useState(new Map()); useEffect(() => { @@ -72,9 +72,7 @@ function DashboardApp() { if (!ready) { return ( -
- Loading… -
+
Loading…
); } @@ -91,4 +89,4 @@ export default function Page() { ); -} \ No newline at end of file +} diff --git a/web/bin/fix-colors.ts b/web/bin/fix-colors.ts index d82a82d..4f5b243 100644 --- a/web/bin/fix-colors.ts +++ b/web/bin/fix-colors.ts @@ -2,10 +2,15 @@ import { readFileSync, writeFileSync } from 'fs'; const CSV = 'public/factorio_item_colors.csv'; 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; - const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2; + 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; + 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)]; - 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; if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 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)]; } function hslToHex(h: number, s: number, l: number): string { - s /= 100; l /= 100; - const c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = l - c / 2; - 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'); + s /= 100; + l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s, + x = c * (1 - Math.abs(((h / 60) % 2) - 1)), + m = l - c / 2; + 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); } 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 header = lines[0]; -const raw = lines.slice(1).filter(l => l.trim()).map(l => { - const [k, c] = l.split(','); - return { key: k.trim(), color: c.trim() }; -}); +const raw = lines + .slice(1) + .filter((l) => l.trim()) + .map((l) => { + const [k, c] = l.split(','); + return { key: k.trim(), color: c.trim() }; + }); // Dedup by key const seen = new Set(); 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 parent = Array.from({ length: n }, (_, i) => i); 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; } -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 })); for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { - if (hueDist(hsl[i].hsl[0], hsl[j].hsl[0]) <= 0.5 && - Math.abs(hsl[i].hsl[2] - hsl[j].hsl[2]) <= 0.5) + if ( + 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); } } @@ -75,13 +119,13 @@ for (const [, items] of groups) { const [oh, os, ol] = items[0].hsl; for (let i = 1; i < items.length; i++) { const hash = djb2(items[i].key); - const h = (oh + (hash % 5 - 2) + i * 7 + 360) % 360; - const l = Math.max(0, Math.min(100, ol + ((hash >> 4) % 5 - 2))); + const h = (oh + ((hash % 5) - 2) + i * 7 + 360) % 360; + const l = Math.max(0, Math.min(100, ol + (((hash >> 4) % 5) - 2))); items[i].color = hslToHex(h, os, l); fixed++; } } 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`); diff --git a/web/charts/factorio-signal-exporter/Chart.yaml b/web/charts/factorio-signal-exporter/Chart.yaml index 366d0b2..3cb5010 100644 --- a/web/charts/factorio-signal-exporter/Chart.yaml +++ b/web/charts/factorio-signal-exporter/Chart.yaml @@ -3,4 +3,4 @@ name: factorio-signal-exporter description: Factorio Signal Exporter — Next.js dashboard with TimescaleDB type: application version: 0.0.0-dev -appVersion: "latest" \ No newline at end of file +appVersion: 'latest' diff --git a/web/charts/factorio-signal-exporter/values.yaml b/web/charts/factorio-signal-exporter/values.yaml index 84e2154..e94d0f1 100644 --- a/web/charts/factorio-signal-exporter/values.yaml +++ b/web/charts/factorio-signal-exporter/values.yaml @@ -13,10 +13,10 @@ imagePullSecrets: [] app: replicaCount: 1 ## API token for ingest POST and dashboard GET ?token= - apiToken: "" + apiToken: '' ## If set, use this existing K8s secret instead of creating one. ## Secret must contain keys: API_TOKEN, DATABASE_URL - existingSecret: "" + existingSecret: '' db: ## TimescaleDB credentials — used to build DATABASE_URL and configure the StatefulSet @@ -25,7 +25,7 @@ db: name: factorio port: 5432 storage: 10Gi - storageClassName: "" + storageClassName: '' service: type: ClusterIP @@ -33,14 +33,14 @@ service: ingress: enabled: false - className: "" + className: '' host: factorio.example.com tls: false - tlsSecretName: "" + tlsSecretName: '' annotations: {} resources: {} nodeSelector: {} tolerations: [] -affinity: {} \ No newline at end of file +affinity: {} diff --git a/web/components/AlertPanel.tsx b/web/components/AlertPanel.tsx index 47b57a0..063e948 100644 --- a/web/components/AlertPanel.tsx +++ b/web/components/AlertPanel.tsx @@ -7,34 +7,43 @@ import { resolveName, resolveKey } from '@/lib/localization'; import type { AlertConfig } from '@/lib/types'; interface Props { - open: boolean; + open: boolean; onClose: () => void; } -const inputCls = 'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500'; -const selectCls = 'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none'; +const inputCls = + 'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500'; +const selectCls = + 'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none'; interface AlertFormState { - itemKey: string; + itemKey: string; itemKeyIsRegex: boolean; - combinator: string; - signalType: 'green' | 'red'; - condition: 'above' | 'below'; - threshold: string; + combinator: string; + signalType: 'green' | 'red'; + condition: 'above' | 'below'; + threshold: string; } 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 { return { - itemKey: a.item_key, + itemKey: a.item_key, itemKeyIsRegex: a.item_key_is_regex, - combinator: a.combinator ?? '', - signalType: a.signal_type, - condition: a.condition, - threshold: String(a.threshold), + combinator: a.combinator ?? '', + signalType: a.signal_type, + condition: a.condition, + threshold: String(a.threshold), }; } @@ -50,48 +59,78 @@ function Tooltip({ text }: { text: string }) { } function AlertForm({ - value, onChange, onSubmit, onCancel, submitLabel, + value, + onChange, + onSubmit, + onCancel, + submitLabel, }: { - value: AlertFormState; - onChange: (s: AlertFormState) => void; - onSubmit: () => void; - onCancel?: () => void; + value: AlertFormState; + onChange: (s: AlertFormState) => void; + onSubmit: () => void; + onCancel?: () => void; submitLabel: string; }) { return (
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'} className={inputCls} /> - onChange({ ...value, combinator: e.target.value })} - placeholder="combinator (empty = all)" className={inputCls} /> + onChange({ ...value, combinator: e.target.value })} + placeholder="combinator (empty = all)" + className={inputCls} + />
- onChange({ ...value, signalType: e.target.value as 'green' | 'red' })} + className={selectCls} + > - onChange({ ...value, condition: e.target.value as 'above' | 'below' })} + className={selectCls} + >
- onChange({ ...value, threshold: e.target.value })} - placeholder="threshold" className={inputCls} /> + onChange({ ...value, threshold: e.target.value })} + placeholder="threshold" + className={inputCls} + />
- {onCancel && ( - )} @@ -102,10 +141,10 @@ function AlertForm({ export default function AlertPanel({ open, onClose }: Props) { const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp(); - const [alerts, setAlerts] = useState([]); - const [newForm, setNewForm] = useState(emptyForm()); + const [alerts, setAlerts] = useState([]); + const [newForm, setNewForm] = useState(emptyForm()); const [editingId, setEditingId] = useState(null); - const [editForm, setEditForm] = useState(emptyForm()); + const [editForm, setEditForm] = useState(emptyForm()); const prevTriggeredCount = useRef(0); useEffect(() => { @@ -115,8 +154,8 @@ export default function AlertPanel({ open, onClose }: Props) { useEffect(() => { if (triggeredAlerts.length > prevTriggeredCount.current) { try { - const ctx = new AudioContext(); - const osc = ctx.createOscillator(); + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); @@ -125,7 +164,9 @@ export default function AlertPanel({ open, onClose }: Props) { gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); osc.start(); osc.stop(ctx.currentTime + 0.4); - } catch { /* AudioContext blocked */ } + } catch { + /* AudioContext blocked */ + } } prevTriggeredCount.current = triggeredAlerts.length; }, [triggeredAlerts.length]); @@ -139,14 +180,14 @@ export default function AlertPanel({ open, onClose }: Props) { async function handleCreate() { if (!newForm.itemKey.trim()) return; const created = await createAlert({ - item_key: normalizeItemKey(newForm), + item_key: normalizeItemKey(newForm), item_key_is_regex: newForm.itemKeyIsRegex, - combinator: newForm.combinator.trim() || null, - signal_type: newForm.signalType, - condition: newForm.condition, - threshold: parseInt(newForm.threshold, 10), + combinator: newForm.combinator.trim() || null, + signal_type: newForm.signalType, + condition: newForm.condition, + threshold: parseInt(newForm.threshold, 10), }); - setAlerts(a => [created, ...a]); + setAlerts((a) => [created, ...a]); setNewForm(emptyForm()); await refreshAlerts(); } @@ -154,21 +195,21 @@ export default function AlertPanel({ open, onClose }: Props) { async function handleEdit(id: string) { if (!editForm.itemKey.trim()) return; const updated = await updateAlert(id, { - item_key: normalizeItemKey(editForm), + item_key: normalizeItemKey(editForm), item_key_is_regex: editForm.itemKeyIsRegex, - combinator: editForm.combinator.trim() || null, - signal_type: editForm.signalType, - condition: editForm.condition, - threshold: parseInt(editForm.threshold, 10), + combinator: editForm.combinator.trim() || null, + signal_type: editForm.signalType, + condition: editForm.condition, + threshold: parseInt(editForm.threshold, 10), }); - setAlerts(a => a.map(x => x.id === id ? updated : x)); + setAlerts((a) => a.map((x) => (x.id === id ? updated : x))); setEditingId(null); await refreshAlerts(); } async function handleDelete(id: string) { await deleteAlert(id); - setAlerts(a => a.filter(x => x.id !== id)); + setAlerts((a) => a.filter((x) => x.id !== id)); await refreshAlerts(); } @@ -178,21 +219,29 @@ export default function AlertPanel({ open, onClose }: Props) { } return ( -
+
Alerts - +
{triggeredAlerts.length > 0 && (
-

🔴 TRIGGERED ({triggeredAlerts.length})

+

+ 🔴 TRIGGERED ({triggeredAlerts.length}) +

{triggeredAlerts.map((a, i) => (
{resolveName(a.matched_item_key, localeMap)} ({a.combinator_match}) [{a.signal_type}] - = {a.current_value} {a.condition} {a.threshold} + + = {a.current_value} {a.condition} {a.threshold} + {a.item_key_is_regex && a.matched_item_key !== a.item_key && ( )} @@ -203,12 +252,17 @@ export default function AlertPanel({ open, onClose }: Props) {

New Alert

- +
{alerts.length === 0 &&

No alerts configured.

} - {alerts.map(a => ( + {alerts.map((a) => (
{editingId === a.id ? (
- {resolveName(a.item_key, localeMap)} - {a.item_key_is_regex && ( - - )} + + {resolveName(a.item_key, localeMap)} + + {a.item_key_is_regex && } {a.combinator && @ {a.combinator}}
- [{a.signal_type}] - {' '}{a.condition} {a.threshold} + + [{a.signal_type}] + {' '} + {a.condition} {a.threshold}
- - + +
)} @@ -241,4 +307,4 @@ export default function AlertPanel({ open, onClose }: Props) {
); -} \ No newline at end of file +} diff --git a/web/components/ChartCard/CardShell.tsx b/web/components/ChartCard/CardShell.tsx index f753c0a..9c4b707 100644 --- a/web/components/ChartCard/CardShell.tsx +++ b/web/components/ChartCard/CardShell.tsx @@ -1,8 +1,8 @@ import React, { useRef, useState, useCallback } from 'react'; interface HeaderProps { - title: string; - onEdit: () => void; + title: string; + onEdit: () => void; onDelete: () => void; } @@ -11,8 +11,18 @@ export function Header({ title, onEdit, onDelete }: HeaderProps) {
{title}
- - + +
); @@ -25,64 +35,81 @@ export function EmptyState() { } interface CardShellProps extends HeaderProps { - empty: boolean; - children: React.ReactNode; + empty: boolean; + children: React.ReactNode; /** Ref to the div where the uPlot legend will be mounted */ legendContainerRef?: React.RefObject; } -export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) { +export function CardShell({ + title, + onEdit, + onDelete, + empty, + children, + legendContainerRef, +}: CardShellProps) { const containerRef = useRef(null); const [legendHeight, setLegendHeight] = useState(null); const dragRef = useRef<{ startY: number; startH: number } | null>(null); - const handleMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const el = legendContainerRef?.current; - if (!el) return; - dragRef.current = { startY: e.clientY, startH: el.offsetHeight }; + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const el = legendContainerRef?.current; + if (!el) return; + dragRef.current = { startY: e.clientY, startH: el.offsetHeight }; - function onMove(ev: MouseEvent) { - if (!dragRef.current) return; - const delta = dragRef.current.startY - ev.clientY; - const containerH = containerRef.current?.offsetHeight ?? 400; - const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta)); - setLegendHeight(newH); - } - function onUp() { - dragRef.current = null; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - } - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }, [legendContainerRef]); + function onMove(ev: MouseEvent) { + if (!dragRef.current) return; + const delta = dragRef.current.startY - ev.clientY; + const containerH = containerRef.current?.offsetHeight ?? 400; + const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta)); + setLegendHeight(newH); + } + function onUp() { + dragRef.current = null; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + [legendContainerRef], + ); return ( -
+
{empty ? ( ) : ( <> -
- {children} -
- {legendContainerRef && <> -
-
- - } +
{children}
+ {legendContainerRef && ( + <> +
+
+ + + )} )}
); -} \ No newline at end of file +} diff --git a/web/components/ChartCard/DividerCard.tsx b/web/components/ChartCard/DividerCard.tsx index 267486f..a9d5ad9 100644 --- a/web/components/ChartCard/DividerCard.tsx +++ b/web/components/ChartCard/DividerCard.tsx @@ -1,18 +1,30 @@ interface Props { - title: string; - onEdit: () => void; + title: string; + onEdit: () => void; onDelete: () => void; } export default function DividerCard({ title, onEdit, onDelete }: Props) { return (
- {title} + + {title} +
- - + +
); -} \ No newline at end of file +} diff --git a/web/components/ChartCard/SignalsChart.tsx b/web/components/ChartCard/SignalsChart.tsx index b411274..51cde0c 100644 --- a/web/components/ChartCard/SignalsChart.tsx +++ b/web/components/ChartCard/SignalsChart.tsx @@ -8,27 +8,43 @@ import { resolveName } from '@/lib/localization'; import { getColorMap } from '@/lib/colors'; import type { ColorMap } from '@/lib/colors'; 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 { usePlot } from './usePlot'; import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types'; import type { TimeMode } from '@/lib/types'; interface Props { - config: ChartConfig; - rows: SignalRow[]; + config: ChartConfig; + rows: SignalRow[]; sessions: SessionBoundary[]; - alerts: AlertConfig[]; + alerts: AlertConfig[]; timeMode: TimeMode; - onEdit: () => void; + onEdit: () => 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 locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE'; const [colorMap, setColorMap] = useState(new Map()); - useEffect(() => { getColorMap().then(setColorMap); }, []); + useEffect(() => { + getColorMap().then(setColorMap); + }, []); const { containerRef, legendRef } = usePlot( (el, w, h, lRef) => { @@ -36,35 +52,47 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode, if (!data) return null; const { keys, allXs, data: seriesData } = data; - const sessionXs = sessions.map(s => timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000); + const sessionXs = sessions.map((s) => + timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000, + ); const alertThresholds = alerts - .filter(a => config.signal_type === 'both' || config.signal_type === a.signal_type) - .map(a => a.threshold); + .filter((a) => config.signal_type === 'both' || config.signal_type === a.signal_type) + .map((a) => a.threshold); - return new uPlot({ - width: w, - height: h, - cursor: CURSOR_NO_DRAG, - legend: { - mount: (_u, legendEl) => { - if (lRef.current) lRef.current.appendChild(legendEl); + return new uPlot( + { + width: w, + height: h, + cursor: CURSOR_NO_DRAG, + legend: { + mount: (_u, legendEl) => { + if (lRef.current) lRef.current.appendChild(legendEl); + }, }, + series: makeSignalsSeries(keys, timeMode, (key) => resolveName(key, localeMap), 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), - 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), - }, [allXs, ...seriesData], el); + [allXs, ...seriesData], + el, + ); }, [rows, sessions, alerts, config, timeMode, localeMap], ); return ( - +
} className="w-full h-full" /> ); -} \ No newline at end of file +} diff --git a/web/components/ChartCard/TableViz.tsx b/web/components/ChartCard/TableViz.tsx index 735e437..b72d053 100644 --- a/web/components/ChartCard/TableViz.tsx +++ b/web/components/ChartCard/TableViz.tsx @@ -8,16 +8,18 @@ import { CardShell } from './CardShell'; import type { ChartConfig, SignalRow } from '@/lib/types'; interface Props { - config: ChartConfig; - rows: SignalRow[]; - onEdit: () => void; + config: ChartConfig; + rows: SignalRow[]; + onEdit: () => void; onDelete: () => void; } export default function TableViz({ config, rows, onEdit, onDelete }: Props) { const { localeMap } = useApp(); const [colorMap, setColorMap] = useState(new Map()); - useEffect(() => { getColorMap().then(setColorMap); }, []); + useEffect(() => { + getColorMap().then(setColorMap); + }, []); const latest = new Map(); for (const row of rows) { @@ -33,8 +35,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) { Item Combinator - {config.signal_type !== 'red' && Green} - {config.signal_type !== 'green' && Red (NP)} + {config.signal_type !== 'red' && ( + Green + )} + {config.signal_type !== 'green' && ( + Red (NP) + )} @@ -43,13 +49,17 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) { return ( - + {resolveName(item_key, localeMap)} {combinator} {config.signal_type !== 'red' && ( - + {vals.green != null ? formatSI(vals.green, undefined, 0) : '--'} )} @@ -66,4 +76,4 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
); -} \ No newline at end of file +} diff --git a/web/components/ChartCard/UpsChart.tsx b/web/components/ChartCard/UpsChart.tsx index 18b5447..a4d963a 100644 --- a/web/components/ChartCard/UpsChart.tsx +++ b/web/components/ChartCard/UpsChart.tsx @@ -10,10 +10,10 @@ import type { ChartConfig, UpsRow } from '@/lib/types'; import type { TimeMode } from '@/lib/types'; interface Props { - config: ChartConfig; - upsRows: UpsRow[]; + config: ChartConfig; + upsRows: UpsRow[]; timeMode: TimeMode; - onEdit: () => void; + onEdit: () => void; onDelete: () => void; } @@ -27,42 +27,59 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete } ? a.game_tick - b.game_tick : new Date(a.real_time).getTime() - new Date(b.real_time).getTime(), ); - const xs = sorted.map(r => timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000); - const ys = sorted.map(r => r.ups); + const xs = sorted.map((r) => + timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000, + ); + const ys = sorted.map((r) => r.ups); const xAxis: uPlot.Axis = { ...AXIS_BASE, - values: timeMode === 'real' - ? (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()) - : (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)), + values: + timeMode === 'real' + ? (_u, vals) => + vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString())) + : (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))), }; - return new uPlot({ - width: w, - height: h, - cursor: CURSOR_NO_DRAG, - legend: { - mount: (_u, legendEl) => { - if (lRef.current) lRef.current.appendChild(legendEl); + return new uPlot( + { + width: w, + height: h, + cursor: CURSOR_NO_DRAG, + legend: { + mount: (_u, legendEl) => { + if (lRef.current) lRef.current.appendChild(legendEl); + }, + }, + series: [ + { label: timeMode === 'tick' ? 'Tick' : 'Time' }, + { label: 'UPS', stroke: '#4ade80', width: 1.5 }, + ], + axes: [ + xAxis, + { ...AXIS_BASE, 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: [ - { 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), - }, - }, [xs, ys], el); + [xs, ys], + el, + ); }, [upsRows, config.y_min, config.y_max, config.y_scale, timeMode], ); return ( - +
} className="w-full h-full" /> ); -} \ No newline at end of file +} diff --git a/web/components/ChartCard/index.tsx b/web/components/ChartCard/index.tsx index 41b9f42..e7315a2 100644 --- a/web/components/ChartCard/index.tsx +++ b/web/components/ChartCard/index.tsx @@ -6,20 +6,29 @@ import TableViz from './TableViz'; import DividerCard from './DividerCard'; export interface ChartCardProps { - config: ChartConfig; - rows: SignalRow[]; - upsRows: UpsRow[]; + config: ChartConfig; + rows: SignalRow[]; + upsRows: UpsRow[]; sessions: SessionBoundary[]; - alerts: AlertConfig[]; + alerts: AlertConfig[]; timeMode: TimeMode; - onEdit: () => void; + onEdit: () => void; onDelete: () => void; } export default function ChartCard(props: ChartCardProps) { const { config } = props; - if (config.chart_type === 'divider') return ; - if (config.chart_type === 'ups') return ; - if (config.viz_type === 'table') return ; - return ; -} \ No newline at end of file + if (config.chart_type === 'divider') + return ; + if (config.chart_type === 'ups') return ; + if (config.viz_type === 'table') + return ( + + ); + return ; +} diff --git a/web/components/ChartCard/plotHelpers.ts b/web/components/ChartCard/plotHelpers.ts index 091a3f3..e2567b3 100644 --- a/web/components/ChartCard/plotHelpers.ts +++ b/web/components/ChartCard/plotHelpers.ts @@ -5,18 +5,18 @@ import type { ColorMap } from '@/lib/colors'; import { getItemColor } from '@/lib/colors'; const SEMANTIC_GREEN = '#4ade80'; -const SEMANTIC_RED = '#f87171'; +const SEMANTIC_RED = '#f87171'; export interface SeriesStyle { color: string; - dash: number[] | undefined; + dash: number[] | undefined; } export function getSeriesStyle( - key: string, - uCombs: number, - uItems: number, - uSigs: number, + key: string, + uCombs: number, + uItems: number, + uSigs: number, colorMap: ColorMap = new Map(), ): SeriesStyle { const [combinator, item_key, sig] = key.split('::'); @@ -25,9 +25,15 @@ export function getSeriesStyle( return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined }; } 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. */ export function getSeriesLabel( - key: string, - uCombs: number, - uItems: number, - uSigs: number, + key: string, + uCombs: number, + uItems: number, + uSigs: number, displayName: string, ): string { const [combinator, , sig] = key.split('::'); @@ -46,7 +52,7 @@ export function getSeriesLabel( const parts: string[] = []; if (uItems > 1) parts.push(displayName); if (uCombs > 1) parts.push(`(${combinator})`); - if (uSigs > 1) parts.push(`[${sig}]`); + if (uSigs > 1) parts.push(`[${sig}]`); return parts.join(' '); } @@ -54,8 +60,8 @@ export function getSeriesLabel( export const AXIS_BASE: uPlot.Axis = { stroke: '#9ca3af', - ticks: { stroke: '#374151' }, - grid: { stroke: '#1f2937' }, + ticks: { stroke: '#374151' }, + grid: { stroke: '#1f2937' }, }; 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. */ export function makeYScale( - yMin: number | null, - yMax: number | null, + yMin: number | null, + yMax: number | null, yScale: ChartConfig['y_scale'] = 'linear', ): uPlot.Scale { if (yScale === 'log') { return { distr: 4, asinh: 1, - ...(yMin !== null || yMax !== null ? { - range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax], - } : {}), + ...(yMin !== null || yMax !== null + ? { + range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax], + } + : {}), }; } @@ -85,53 +93,61 @@ export function makeYScale( return { dir: 1, range: (_u, dataMin, dataMax) => { - const lo = yMin ?? (dataMin ?? 0); - const hi = yMax ?? (dataMax ?? 1); + const lo = yMin ?? dataMin ?? 0; + const hi = yMax ?? dataMax ?? 1; if (lo === hi) return [lo - 1, hi + 1]; - const pad = (yMin == null || yMax == null) ? Math.abs(hi - lo) * 0.05 : 0; + const pad = yMin == null || yMax == null ? Math.abs(hi - lo) * 0.05 : 0; return [lo - pad, hi + pad]; }, }; } export function makeAnnotationHooks( - sessionXs: number[], + sessionXs: number[], alertThresholds: number[], ): uPlot.Options['hooks'] { return { - draw: [(u) => { - const { ctx, bbox } = u; - ctx.save(); + draw: [ + (u) => { + const { ctx, bbox } = u; + ctx.save(); - ctx.strokeStyle = 'rgba(251,191,36,0.6)'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - for (const sx of sessionXs) { - const cx = Math.round(u.valToPos(sx, 'x', true)); - ctx.beginPath(); ctx.moveTo(cx, bbox.top); ctx.lineTo(cx, bbox.top + bbox.height); ctx.stroke(); - } + ctx.strokeStyle = 'rgba(251,191,36,0.6)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + for (const sx of sessionXs) { + const cx = Math.round(u.valToPos(sx, 'x', true)); + ctx.beginPath(); + ctx.moveTo(cx, bbox.top); + ctx.lineTo(cx, bbox.top + bbox.height); + ctx.stroke(); + } - ctx.strokeStyle = 'rgba(248,113,113,0.7)'; - ctx.setLineDash([6, 3]); - for (const t of alertThresholds) { - const cy = Math.round(u.valToPos(t, 'y', true)); - ctx.beginPath(); ctx.moveTo(bbox.left, cy); ctx.lineTo(bbox.left + bbox.width, cy); ctx.stroke(); - } + ctx.strokeStyle = 'rgba(248,113,113,0.7)'; + ctx.setLineDash([6, 3]); + for (const t of alertThresholds) { + const cy = Math.round(u.valToPos(t, 'y', true)); + ctx.beginPath(); + ctx.moveTo(bbox.left, cy); + ctx.lineTo(bbox.left + bbox.width, cy); + ctx.stroke(); + } - ctx.restore(); - }], + ctx.restore(); + }, + ], }; } export function makeSignalsSeries( - keys: string[], - timeMode: 'real' | 'tick', + keys: string[], + timeMode: 'real' | 'tick', resolveName: (key: string) => string, - colorMap: ColorMap = new Map(), + colorMap: ColorMap = new Map(), ): uPlot.Series[] { - const uCombs = new Set(keys.map(k => k.split('::')[0])).size; - const uItems = new Set(keys.map(k => k.split('::')[1])).size; - const uSigs = new Set(keys.map(k => k.split('::')[2])).size; + const uCombs = new Set(keys.map((k) => k.split('::')[0])).size; + const uItems = new Set(keys.map((k) => k.split('::')[1])).size; + const uSigs = new Set(keys.map((k) => k.split('::')[2])).size; const xSeries: uPlot.Series = { label: timeMode === 'tick' ? 'Tick' : 'Time', @@ -143,13 +159,13 @@ export function makeSignalsSeries( return [ xSeries, - ...keys.map(k => { + ...keys.map((k) => { const [, item_key] = k.split('::'); const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap); return { - label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)), + label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)), stroke: color, - width: 1.5, + width: 1.5, dash, }; }), @@ -160,16 +176,17 @@ export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPl return [ { ...AXIS_BASE, - values: timeMode === 'real' - ? (_u: uPlot, vals: (number | null)[]) => - vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()) - : (_u: uPlot, vals: (number | null)[]) => - vals.map(v => v == null ? '' : formatSI(v, locale)), + values: + timeMode === 'real' + ? (_u: uPlot, vals: (number | null)[]) => + vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString())) + : (_u: uPlot, vals: (number | null)[]) => + vals.map((v) => (v == null ? '' : formatSI(v, locale))), }, { ...AXIS_BASE, values: (_u: uPlot, vals: (number | null)[]) => - vals.map(v => v == null ? '' : formatSI(v, locale)), + vals.map((v) => (v == null ? '' : formatSI(v, locale))), }, ]; -} \ No newline at end of file +} diff --git a/web/components/ChartCard/seriesData.ts b/web/components/ChartCard/seriesData.ts index 6f1a4b4..edf85ed 100644 --- a/web/components/ChartCard/seriesData.ts +++ b/web/components/ChartCard/seriesData.ts @@ -4,26 +4,30 @@ import type { TimeMode } from '@/lib/types'; const MAX_SERIES = 80; export interface SeriesData { - keys: string[]; + keys: string[]; allXs: number[]; - data: (number | undefined)[][]; + data: (number | undefined)[][]; } export function buildSeriesData( - rows: SignalRow[], + rows: SignalRow[], signalType: ChartConfig['signal_type'], - timeMode: TimeMode, + timeMode: TimeMode, ): SeriesData | null { const seriesMap = new Map>(); 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 (val === undefined) continue; const key = `${row.combinator}::${row.item_key}::${sig}`; - const x = timeMode === 'tick' - ? parseInt(row.game_tick, 10) - : new Date(row.real_time).getTime() / 1000; + const x = + timeMode === 'tick' + ? parseInt(row.game_tick, 10) + : new Date(row.real_time).getTime() / 1000; if (!seriesMap.has(key)) seriesMap.set(key, new Map()); seriesMap.get(key)!.set(x, val); } @@ -31,13 +35,15 @@ export function buildSeriesData( if (seriesMap.size === 0) return null; - const keys = [...seriesMap.keys()].slice(0, MAX_SERIES); - const allXs = [...new Set(keys.flatMap(k => [...seriesMap.get(k)!.keys()]))].sort((a, b) => a - b); + const keys = [...seriesMap.keys()].slice(0, MAX_SERIES); + const allXs = [...new Set(keys.flatMap((k) => [...seriesMap.get(k)!.keys()]))].sort( + (a, b) => a - b, + ); - const data = keys.map(k => { + const data = keys.map((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 }; -} \ No newline at end of file +} diff --git a/web/components/ChartCard/usePlot.ts b/web/components/ChartCard/usePlot.ts index d5838d6..3cb1bd2 100644 --- a/web/components/ChartCard/usePlot.ts +++ b/web/components/ChartCard/usePlot.ts @@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef } from 'react'; import uPlot from 'uplot'; export type BuildFn = ( - el: HTMLDivElement, - w: number, - h: number, + el: HTMLDivElement, + w: number, + h: number, legendRef: React.RefObject, ) => uPlot | null; @@ -25,17 +25,21 @@ function idxToPixel(plot: uPlot, idx: number): number { export function usePlot( build: BuildFn, // eslint-disable-next-line @typescript-eslint/no-explicit-any - deps: any[], -): { containerRef: React.RefObject; legendRef: React.RefObject } { + deps: any[], +): { + containerRef: React.RefObject; + legendRef: React.RefObject; +} { const containerRef = useRef(null); - const legendRef = useRef(null!); - const plotRef = useRef(null); - const lastIdxRef = useRef(0); + const legendRef = useRef(null!); + const plotRef = useRef(null); + const lastIdxRef = useRef(0); const rebuild = useCallback(() => { const el = containerRef.current; if (!el) return; - const w = el.clientWidth, h = el.clientHeight; + const w = el.clientWidth, + h = el.clientHeight; if (w < 10 || h < 10) return; 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); useEffect(() => { rebuild(); - return () => { plotRef.current?.destroy(); plotRef.current = null; }; + return () => { + plotRef.current?.destroy(); + plotRef.current = null; + }; }, [rebuild]); useEffect(() => { @@ -77,4 +84,4 @@ export function usePlot( }, [rebuild]); return { containerRef, legendRef }; -} \ No newline at end of file +} diff --git a/web/components/ChartEditor.tsx b/web/components/ChartEditor.tsx index e167ae5..a1a02d0 100644 --- a/web/components/ChartEditor.tsx +++ b/web/components/ChartEditor.tsx @@ -9,7 +9,7 @@ type DraftChart = Omit; interface Props { initial?: ChartConfig; - onSave: (draft: DraftChart) => void; + onSave: (draft: DraftChart) => void; onClose: () => void; } @@ -21,32 +21,42 @@ const inputCls = * to raw item_keys. Unknown tokens are kept as-is. */ function normalizeList(raw: string, reverseMap: Map): 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; - return arr.map(t => resolveKey(t, reverseMap)); + return arr.map((t) => resolveKey(t, reverseMap)); } export default function ChartEditor({ initial, onSave, onClose }: Props) { const { reverseMap } = useApp(); - const [title, setTitle] = useState(initial?.title ?? ''); - const [chartType, setChartType] = useState(initial?.chart_type ?? 'signals'); - const [vizType, setVizType] = useState(initial?.viz_type ?? 'line'); - const [signalType, setSignalType] = useState(initial?.signal_type ?? 'both'); + const [title, setTitle] = useState(initial?.title ?? ''); + const [chartType, setChartType] = useState( + initial?.chart_type ?? 'signals', + ); + const [vizType, setVizType] = useState(initial?.viz_type ?? 'line'); + const [signalType, setSignalType] = useState( + initial?.signal_type ?? 'both', + ); const [combinators, setCombinators] = useState((initial?.filter_combinators ?? []).join(', ')); - const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', ')); - const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', ')); - const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false); - const [orderBy, setOrderBy] = useState(initial?.order_by ?? 'value_asc'); + const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', ')); + const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', ')); + const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false); + const [orderBy, setOrderBy] = useState(initial?.order_by ?? 'value_asc'); const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20); - const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? ''); - const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? ''); - const [yScale, setYScale] = useState(initial?.y_scale ?? 'linear'); - const [width, setWidth] = useState(initial?.width ?? 2); - const [height, setHeight] = useState(initial?.height ?? 4); + const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? ''); + const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? ''); + const [yScale, setYScale] = useState(initial?.y_scale ?? 'linear'); + const [width, setWidth] = useState(initial?.width ?? 2); + const [height, setHeight] = useState(initial?.height ?? 4); function splitCombinators(): string[] | null { - const arr = combinators.split(',').map(x => x.trim()).filter(Boolean); + const arr = combinators + .split(',') + .map((x) => x.trim()) + .filter(Boolean); 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. // Non-regex mode: resolve each comma-token (localized name or raw key) to raw key. - const filter_items = useRegex - ? (whitelist.trim() ? [whitelist.trim()] : null) + const filter_items = useRegex + ? whitelist.trim() + ? [whitelist.trim()] + : null : normalizeList(whitelist, reverseMap); const filter_items_exclude = useRegex - ? (blacklist.trim() ? [blacklist.trim()] : null) + ? blacklist.trim() + ? [blacklist.trim()] + : null : normalizeList(blacklist, reverseMap); onSave({ - title: title.trim(), - chart_type: chartType, - viz_type: chartType === 'divider' ? 'line' : vizType, - signal_type: signalType, - pos_x: initial?.pos_x ?? 0, - pos_y: initial?.pos_y ?? 0, - width, height, - filter_combinators: chartType === 'divider' ? null : splitCombinators(), - filter_items: chartType === 'divider' ? null : filter_items, + title: title.trim(), + chart_type: chartType, + viz_type: chartType === 'divider' ? 'line' : vizType, + signal_type: signalType, + pos_x: initial?.pos_x ?? 0, + pos_y: initial?.pos_y ?? 0, + width, + height, + filter_combinators: chartType === 'divider' ? null : splitCombinators(), + filter_items: chartType === 'divider' ? null : filter_items, filter_items_exclude: chartType === 'divider' ? null : filter_items_exclude, - filter_items_regex: useRegex, - order_by: orderBy, - series_limit: seriesLimit, - y_min: yMin !== '' ? parseFloat(yMin) : null, - y_max: yMax !== '' ? parseFloat(yMax) : null, - y_scale: yScale, + filter_items_regex: useRegex, + order_by: orderBy, + series_limit: seriesLimit, + y_min: yMin !== '' ? parseFloat(yMin) : null, + y_max: yMax !== '' ? parseFloat(yMax) : null, + y_scale: yScale, }); } @@ -86,115 +101,205 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) { const isDivider = chartType === 'divider'; return ( -
+
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} >

{initial ? 'Edit Chart' : 'New Chart'}

- setTitle(e.target.value)} className={`${inputCls} mb-3`} /> + setTitle(e.target.value)} + className={`${inputCls} mb-3`} + /> - setChartType(e.target.value as ChartConfig['chart_type'])} + className={`${inputCls} mb-3`} + > - {!isDivider && <> - - - } + {!isDivider && ( + <> + + + + )} - {isSignals && <> - - + {isSignals && ( + <> + + - - + + - - setSeriesLimit(Number(e.target.value))} - className={`${inputCls} mb-3`} /> + + setSeriesLimit(Number(e.target.value))} + className={`${inputCls} mb-3`} + /> - - setCombinators(e.target.value)} - placeholder="nauvis, nauvis-orbit" className={`${inputCls} mb-3`} /> - -
- -
- setWhitelist(e.target.value)} - placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'} - className={`${inputCls} mb-1`} /> -

Whitelist — localized names or item keys accepted (empty = all)

- setBlacklist(e.target.value)} - placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'} - className={`${inputCls} mb-1`} /> -

Blacklist — localized names or item keys accepted

- } + setCombinators(e.target.value)} + placeholder="nauvis, nauvis-orbit" + className={`${inputCls} mb-3`} + /> - {!isDivider && <> -
-
- - setYMin(e.target.value)} - placeholder="auto" className={inputCls} /> +
+ +
-
- - setYMax(e.target.value)} - placeholder="auto" className={inputCls} /> -
-
+ setWhitelist(e.target.value)} + placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'} + className={`${inputCls} mb-1`} + /> +

+ Whitelist — localized names or item keys accepted (empty = all) +

+ setBlacklist(e.target.value)} + placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'} + className={`${inputCls} mb-1`} + /> +

+ Blacklist — localized names or item keys accepted +

+ + )} - - - } + {!isDivider && ( + <> +
+
+ + setYMin(e.target.value)} + placeholder="auto" + className={inputCls} + /> +
+
+ + setYMax(e.target.value)} + placeholder="auto" + className={inputCls} + /> +
+
+ + + + + )}
- setWidth(Number(e.target.value))} className={inputCls} /> + setWidth(Number(e.target.value))} + className={inputCls} + />
- setHeight(Number(e.target.value))} className={inputCls} /> + setHeight(Number(e.target.value))} + className={inputCls} + />
- - + +
); -} \ No newline at end of file +} diff --git a/web/components/Dashboard.tsx b/web/components/Dashboard.tsx index 3df6c5c..917c3bb 100644 --- a/web/components/Dashboard.tsx +++ b/web/components/Dashboard.tsx @@ -6,12 +6,20 @@ import type { Layout, LayoutItem } from 'react-grid-layout'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; import { useApp } from '@/lib/context'; -import { fetchCharts, createChart, updateChart, deleteChart, fetchSignals, fetchSessions, fetchUps } from '@/lib/api'; +import { + fetchCharts, + createChart, + updateChart, + deleteChart, + fetchSignals, + fetchSessions, + fetchUps, +} from '@/lib/api'; import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types'; import ChartCard from './ChartCard'; import ChartEditor from './ChartEditor'; -const COLS = 6; +const COLS = 6; const ROW_HEIGHT = 80; interface Props { @@ -20,21 +28,25 @@ interface Props { export default function Dashboard({ alerts }: Props) { const { timeRange, timeMode, getFromTo } = useApp(); - const [charts, setCharts] = useState([]); - const [signalData, setSignalData] = useState>(new Map()); - const [upsData, setUpsData] = useState>(new Map()); - const [sessions, setSessions] = useState([]); - const [editingChart, setEditingChart] = useState(null); - const [creatingChart, setCreatingChart] = useState(false); + const [charts, setCharts] = useState([]); + const [signalData, setSignalData] = useState>(new Map()); + const [upsData, setUpsData] = useState>(new Map()); + const [sessions, setSessions] = useState([]); + const [editingChart, setEditingChart] = useState(null); + const [creatingChart, setCreatingChart] = useState(false); const [containerWidth, setContainerWidth] = useState(1200); - const containerRef = useRef(null); - const chartsRef = useRef([]); - const refreshingRef = useRef(false); + const containerRef = useRef(null); + const chartsRef = useRef([]); + const refreshingRef = useRef(false); const layoutSaveTimer = useRef | null>(null); - useEffect(() => { chartsRef.current = charts; }, [charts]); - useEffect(() => { fetchCharts().then(setCharts); }, []); + useEffect(() => { + chartsRef.current = charts; + }, [charts]); + useEffect(() => { + fetchCharts().then(setCharts); + }, []); const refreshData = useCallback(async () => { if (refreshingRef.current) return; @@ -44,24 +56,27 @@ export default function Dashboard({ alerts }: Props) { refreshingRef.current = true; try { const { from, to } = getFromTo(); - const signalCharts = current.filter(c => c.chart_type === 'signals'); - const upsCharts = current.filter(c => c.chart_type === 'ups'); + const signalCharts = current.filter((c) => c.chart_type === 'signals'); + const upsCharts = current.filter((c) => c.chart_type === 'ups'); if (signalCharts.length === 0 && upsCharts.length === 0) return; const [newSessions, ...results] = await Promise.all([ fetchSessions(from, to), - ...signalCharts.map(c => fetchSignals({ - combinator: c.filter_combinators ?? undefined, - item: c.filter_items ?? undefined, - exclude: c.filter_items_exclude ?? undefined, - signal: c.signal_type, - time_mode: timeMode, - from, to, - regex: c.filter_items_regex || undefined, - ...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}), - })), - ...upsCharts.map(c => fetchUps({ combinator: c.filter_combinators?.[0], from, to })), + ...signalCharts.map((c) => + fetchSignals({ + combinator: c.filter_combinators ?? undefined, + item: c.filter_items ?? undefined, + exclude: c.filter_items_exclude ?? undefined, + signal: c.signal_type, + time_mode: timeMode, + from, + to, + regex: c.filter_items_regex || undefined, + ...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}), + }), + ), + ...upsCharts.map((c) => fetchUps({ combinator: c.filter_combinators?.[0], from, to })), ]); setSessions(newSessions as SessionBoundary[]); @@ -98,47 +113,56 @@ export default function Dashboard({ alerts }: Props) { async function handleCreate(draft: Omit) { const created = await createChart(draft); - setCharts(cs => [...cs, created]); + setCharts((cs) => [...cs, created]); setCreatingChart(false); } async function handleUpdate(id: string, draft: Omit) { 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); } async function handleDelete(id: string) { await deleteChart(id); - setCharts(cs => cs.filter(c => c.id !== id)); + setCharts((cs) => cs.filter((c) => c.id !== id)); } function handleLayoutChange(layout: Layout) { const items = layout as readonly LayoutItem[]; - const changed = items.filter(item => { - const chart = chartsRef.current.find(c => c.id === item.i); - return chart && ( - chart.pos_x !== item.x || chart.pos_y !== item.y || - chart.width !== item.w || chart.height !== item.h + const changed = items.filter((item) => { + const chart = chartsRef.current.find((c) => c.id === item.i); + return ( + chart && + (chart.pos_x !== item.x || + chart.pos_y !== item.y || + chart.width !== item.w || + chart.height !== item.h) ); }); if (changed.length === 0) return; - setCharts(cs => cs.map(c => { - const l = changed.find(item => item.i === c.id); - return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c; - })); + setCharts((cs) => + cs.map((c) => { + const l = changed.find((item) => item.i === c.id); + return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c; + }), + ); if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current); layoutSaveTimer.current = setTimeout(() => { - changed.forEach(item => + changed.forEach((item) => updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }), ); }, 500); } - const layout: Layout = charts.map(c => ({ - i: c.id, x: c.pos_x, y: c.pos_y, w: c.width, h: c.height, + const layout: Layout = charts.map((c) => ({ + i: c.id, + x: c.pos_x, + y: c.pos_y, + w: c.width, + h: c.height, minW: 1, minH: c.chart_type === 'divider' ? 1 : 2, })); @@ -153,7 +177,8 @@ export default function Dashboard({ alerts }: Props) { dragConfig={{ handle: '.drag-handle' }} resizeConfig={{ handleComponent: (axis, ref) => ( - - {charts.map(c => ( + {charts.map((c) => (
- !c.filter_combinators || !a.combinator || - c.filter_combinators.includes(a.combinator), + alerts={alerts.filter( + (a) => + !c.filter_combinators || + !a.combinator || + c.filter_combinators.includes(a.combinator), )} timeMode={timeMode} onEdit={() => setEditingChart(c)} @@ -196,10 +223,10 @@ export default function Dashboard({ alerts }: Props) { {editingChart && ( handleUpdate(editingChart.id, draft)} + onSave={(draft) => handleUpdate(editingChart.id, draft)} onClose={() => setEditingChart(null)} /> )}
); -} \ No newline at end of file +} diff --git a/web/components/TimeRangeSelector.tsx b/web/components/TimeRangeSelector.tsx index 0f12469..afdb036 100644 --- a/web/components/TimeRangeSelector.tsx +++ b/web/components/TimeRangeSelector.tsx @@ -11,7 +11,7 @@ export default function TimeRangeSelector() { return (
- {RANGES.map(r => ( + {RANGES.map((r) => (
- {(['real', 'tick'] as TimeMode[]).map(m => ( + {(['real', 'tick'] as TimeMode[]).map((m) => (
); -} \ No newline at end of file +} diff --git a/web/docker-compose.yml b/web/docker-compose.yml index 0edc766..76454f4 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -7,9 +7,9 @@ services: POSTGRES_PASSWORD: factorio POSTGRES_DB: factorio ports: - - "5432:5432" + - '5432:5432' volumes: - db_data:/var/lib/postgresql/data volumes: - db_data: \ No newline at end of file + db_data: diff --git a/web/lib/api.ts b/web/lib/api.ts index 7534422..b0e1b44 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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 = ''; -export function setToken(token: string) { _token = token; } +export function setToken(token: string) { + _token = token; +} -function url(path: string, params: Record = {}) { - const u = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'); +function url( + path: string, + params: Record = {}, +) { + const u = new URL( + path, + typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000', + ); u.searchParams.set('token', _token); for (const [k, v] of Object.entries(params)) { if (v === undefined) continue; - if (Array.isArray(v)) v.forEach(val => u.searchParams.append(k, String(val))); + if (Array.isArray(v)) v.forEach((val) => u.searchParams.append(k, String(val))); else u.searchParams.set(k, String(v)); } return u.toString(); } -async function get(path: string, params?: Record): Promise { +async function get( + path: string, + params?: Record, +): Promise { const res = await fetch(url(path, params)); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); @@ -45,7 +64,11 @@ export function fetchSignals(params: { return get('/api/signals', params as Record); } -export function fetchUps(params: { combinator?: string; from?: string; to?: string }): Promise { +export function fetchUps(params: { + combinator?: string; + from?: string; + to?: string; +}): Promise { return get('/api/ups', params); } @@ -53,13 +76,31 @@ export function fetchSessions(from: string, to: string): Promise { return get('/api/charts'); } -export function createChart(body: Omit): Promise { return mutate('POST', '/api/charts', body); } -export function updateChart(id: string, body: Partial): Promise { return mutate('PUT', `/api/charts/${id}`, body); } -export function deleteChart(id: string): Promise<{ ok: boolean }> { return mutate('DELETE', `/api/charts/${id}`); } +export function fetchCharts(): Promise { + return get('/api/charts'); +} +export function createChart(body: Omit): Promise { + return mutate('POST', '/api/charts', body); +} +export function updateChart(id: string, body: Partial): Promise { + 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 { return get('/api/alerts'); } -export function createAlert(body: Omit): Promise { return mutate('POST', '/api/alerts', body); } -export function updateAlert(id: string, body: Partial): Promise { 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 { return get('/api/alerts/check'); } \ No newline at end of file +export function fetchAlerts(): Promise { + return get('/api/alerts'); +} +export function createAlert(body: Omit): Promise { + return mutate('POST', '/api/alerts', body); +} +export function updateAlert(id: string, body: Partial): Promise { + 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 { + return get('/api/alerts/check'); +} diff --git a/web/lib/apiHelpers.ts b/web/lib/apiHelpers.ts index 4124744..fa67bc5 100644 --- a/web/lib/apiHelpers.ts +++ b/web/lib/apiHelpers.ts @@ -23,4 +23,4 @@ export function withAuth(handler: Handler): Handler { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }; -} \ No newline at end of file +} diff --git a/web/lib/auth.ts b/web/lib/auth.ts index bd6df87..cdde3f9 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -19,4 +19,4 @@ export function isAuthorized(req: NextRequest): boolean { export function unauthorized(): NextResponse { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); -} \ No newline at end of file +} diff --git a/web/lib/colors.ts b/web/lib/colors.ts index 5fde0c8..d370990 100644 --- a/web/lib/colors.ts +++ b/web/lib/colors.ts @@ -1,6 +1,8 @@ export type ColorMap = Map; -declare global { var __colorMapCache: ColorMap | undefined; } +declare global { + var __colorMapCache: ColorMap | undefined; +} export function parseColorCsv(text: string): ColorMap { const map: ColorMap = new Map(); diff --git a/web/lib/context.tsx b/web/lib/context.tsx index 81f3b89..5bfc4ba 100644 --- a/web/lib/context.tsx +++ b/web/lib/context.tsx @@ -1,12 +1,6 @@ 'use client'; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; import type { TimeRange, TimeMode, TriggeredAlert } from './types'; import { TIME_RANGE_MS } from './types'; import { checkAlerts } from './api'; @@ -14,15 +8,15 @@ import { buildReverseMap } from './localization'; import type { LocaleMap, ReverseMap } from './localization'; interface AppContextValue { - timeRange: TimeRange; - setTimeRange: (r: TimeRange) => void; - timeMode: TimeMode; - setTimeMode: (m: TimeMode) => void; + timeRange: TimeRange; + setTimeRange: (r: TimeRange) => void; + timeMode: TimeMode; + setTimeMode: (m: TimeMode) => void; triggeredAlerts: TriggeredAlert[]; - refreshAlerts: () => Promise; - getFromTo: () => { from: string; to: string }; - localeMap: LocaleMap; - reverseMap: ReverseMap; + refreshAlerts: () => Promise; + getFromTo: () => { from: string; to: string }; + localeMap: LocaleMap; + reverseMap: ReverseMap; } const AppContext = createContext(null); @@ -32,18 +26,18 @@ export function AppProvider({ localeMap, children, }: { - token: string; + token: string; localeMap: LocaleMap; - children: React.ReactNode; + children: React.ReactNode; }) { - const [timeRange, setTimeRange] = useState('6h'); - const [timeMode, setTimeMode] = useState('real'); + const [timeRange, setTimeRange] = useState('6h'); + const [timeMode, setTimeMode] = useState('real'); const [triggeredAlerts, setTriggeredAlerts] = useState([]); const reverseMap = buildReverseMap(localeMap); const getFromTo = useCallback(() => { - const to = new Date(); + const to = new Date(); const from = new Date(to.getTime() - TIME_RANGE_MS[timeRange]); return { from: from.toISOString(), to: to.toISOString() }; }, [timeRange]); @@ -54,20 +48,30 @@ export function AppProvider({ useEffect(() => { let cancelled = false; - const poll = () => checkAlerts().then(a => { if (!cancelled) setTriggeredAlerts(a); }); + const poll = () => + checkAlerts().then((a) => { + if (!cancelled) setTriggeredAlerts(a); + }); poll(); const id = setInterval(poll, 30_000); - return () => { cancelled = true; clearInterval(id); }; + return () => { + cancelled = true; + clearInterval(id); + }; }, []); return ( {children} @@ -80,4 +84,4 @@ export function useApp() { const ctx = useContext(AppContext); if (!ctx) throw new Error('useApp must be used within AppProvider'); return ctx; -} \ No newline at end of file +} diff --git a/web/lib/db.ts b/web/lib/db.ts index 884c942..520f433 100644 --- a/web/lib/db.ts +++ b/web/lib/db.ts @@ -11,4 +11,4 @@ if (process.env.NODE_ENV !== 'production') { globalThis.__pgPool = pool; } -export default pool; \ No newline at end of file +export default pool; diff --git a/web/lib/localeServer.ts b/web/lib/localeServer.ts index 1ad2cce..e82a4f6 100644 --- a/web/lib/localeServer.ts +++ b/web/lib/localeServer.ts @@ -3,7 +3,9 @@ import path from 'path'; import { parseCsv } 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. @@ -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; return merged; -} \ No newline at end of file +} diff --git a/web/lib/localization.ts b/web/lib/localization.ts index dc912ac..efa2fff 100644 --- a/web/lib/localization.ts +++ b/web/lib/localization.ts @@ -20,8 +20,10 @@ function splitCsvLine(line: string): string[] { for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { - if (inQuote && line[i + 1] === '"') { cur += '"'; i++; } - else inQuote = !inQuote; + if (inQuote && line[i + 1] === '"') { + cur += '"'; + i++; + } else inQuote = !inQuote; } else if (ch === ',' && !inQuote) { cols.push(cur); cur = ''; @@ -33,7 +35,9 @@ function splitCsvLine(line: string): string[] { return cols; } -declare global { var __localeCache: LocaleMap | undefined; } +declare global { + var __localeCache: LocaleMap | undefined; +} async function loadCsv(path: string): Promise { try { @@ -100,4 +104,4 @@ export function matchKeys(pattern: string, map: LocaleMap): string[] { if (re.test(key) || re.test(name)) result.add(key); } return [...result]; -} \ No newline at end of file +} diff --git a/web/lib/sessions.ts b/web/lib/sessions.ts index f88a080..f67a78a 100644 --- a/web/lib/sessions.ts +++ b/web/lib/sessions.ts @@ -5,10 +5,7 @@ import type { SessionBoundary } from '@/lib/types'; * Returns session-start timestamps where any gap > 30 min exists * in the global tick_timing timeline. */ -export async function getSessionBoundaries( - from: Date, - to: Date, -): Promise { +export async function getSessionBoundaries(from: Date, to: Date): Promise { const result = await pool.query<{ real_time: Date; game_tick: string }>( `SELECT real_time, game_tick FROM tick_timing @@ -20,10 +17,12 @@ export async function getSessionBoundaries( const rows = result.rows; if (rows.length === 0) return []; - const boundaries: SessionBoundary[] = [{ - real_time: rows[0].real_time.toISOString(), - game_tick: parseInt(rows[0].game_tick, 10), - }]; + const boundaries: SessionBoundary[] = [ + { + real_time: rows[0].real_time.toISOString(), + game_tick: parseInt(rows[0].game_tick, 10), + }, + ]; for (let i = 1; i < rows.length; i++) { if (rows[i].real_time.getTime() - rows[i - 1].real_time.getTime() > 30 * 60 * 1000) { @@ -35,4 +34,4 @@ export async function getSessionBoundaries( } return boundaries; -} \ No newline at end of file +} diff --git a/web/lib/types.ts b/web/lib/types.ts index 6080524..3c32ddc 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -31,7 +31,7 @@ export interface AlertConfig { } export interface TriggeredAlert extends AlertConfig { - current_value: number; + current_value: number; combinator_match: string; /** Actual matched item_key — differs from item_key when item_key_is_regex=true */ matched_item_key: string; @@ -63,9 +63,9 @@ export type TimeRange = '30m' | '1h' | '6h' | '24h' | '7d' | '30d'; export const TIME_RANGE_MS: Record = { '30m': 30 * 60 * 1000, - '1h': 60 * 60 * 1000, - '6h': 6 * 60 * 60 * 1000, + '1h': 60 * 60 * 1000, + '6h': 6 * 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, -}; \ No newline at end of file +}; diff --git a/web/migrations/001_initial_schema.js b/web/migrations/001_initial_schema.js index a0c7f7f..962a728 100644 --- a/web/migrations/001_initial_schema.js +++ b/web/migrations/001_initial_schema.js @@ -2,51 +2,67 @@ exports.up = (pgm) => { pgm.sql(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`); - pgm.createTable('signals', { - real_time: { type: 'timestamptz', notNull: true }, - game_tick: { type: 'bigint', notNull: true }, - combinator: { type: 'text', notNull: true }, - item_key: { type: 'text', notNull: true }, - green: { type: 'integer', notNull: true, default: 0 }, - red: { type: 'integer', notNull: true, default: 0 }, - logistic: { type: 'integer' }, - }, { ifNotExists: true }); + pgm.createTable( + 'signals', + { + real_time: { type: 'timestamptz', notNull: true }, + game_tick: { type: 'bigint', notNull: true }, + combinator: { type: 'text', notNull: true }, + item_key: { type: 'text', notNull: true }, + green: { type: 'integer', notNull: true, default: 0 }, + red: { type: 'integer', notNull: true, default: 0 }, + logistic: { type: 'integer' }, + }, + { ifNotExists: true }, + ); pgm.sql(`SELECT create_hypertable('signals', 'real_time', if_not_exists => true)`); - pgm.sql(`CREATE INDEX IF NOT EXISTS signals_combinator_item_key_real_time_idx ON signals (combinator, item_key, real_time DESC)`); + pgm.sql( + `CREATE INDEX IF NOT EXISTS signals_combinator_item_key_real_time_idx ON signals (combinator, item_key, real_time DESC)`, + ); pgm.sql(`CREATE INDEX IF NOT EXISTS signals_game_tick_idx ON signals (game_tick DESC)`); pgm.sql(`SELECT add_retention_policy('signals', INTERVAL '30 days', if_not_exists => true)`); - pgm.createTable('tick_timing', { - real_time: { type: 'timestamptz', notNull: true }, - game_tick: { type: 'bigint', notNull: true }, - combinator: { type: 'text', notNull: true }, - }, { ifNotExists: true }); + pgm.createTable( + 'tick_timing', + { + real_time: { type: 'timestamptz', notNull: true }, + game_tick: { type: 'bigint', notNull: true }, + combinator: { type: 'text', notNull: true }, + }, + { ifNotExists: true }, + ); pgm.sql(`SELECT create_hypertable('tick_timing', 'real_time', if_not_exists => true)`); - pgm.sql(`CREATE INDEX IF NOT EXISTS tick_timing_combinator_real_time_idx ON tick_timing (combinator, real_time DESC)`); + pgm.sql( + `CREATE INDEX IF NOT EXISTS tick_timing_combinator_real_time_idx ON tick_timing (combinator, real_time DESC)`, + ); pgm.sql(`SELECT add_retention_policy('tick_timing', INTERVAL '30 days', if_not_exists => true)`); - pgm.createTable('charts', { - id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, - title: { type: 'text', notNull: true }, - pos_x: { type: 'integer', notNull: true, default: 0 }, - pos_y: { type: 'integer', notNull: true, default: 0 }, - width: { type: 'integer', notNull: true, default: 2 }, - height: { type: 'integer', notNull: true, default: 4 }, - signal_type: { type: 'text', notNull: true, default: 'both' }, - chart_type: { type: 'text', notNull: true, default: 'signals' }, - viz_type: { type: 'text', notNull: true, default: 'line' }, - filter_combinators: { type: 'text[]' }, - filter_items: { type: 'text[]' }, - filter_items_exclude: { type: 'text[]' }, - filter_items_regex: { type: 'boolean', notNull: true, default: false }, - y_min: { type: 'real' }, - y_max: { type: 'real' }, - series_limit: { type: 'integer', notNull: true, default: 20 }, - order_by: { type: 'text', notNull: true, default: 'time' }, - created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, - }, { ifNotExists: true }); + pgm.createTable( + 'charts', + { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + title: { type: 'text', notNull: true }, + pos_x: { type: 'integer', notNull: true, default: 0 }, + pos_y: { type: 'integer', notNull: true, default: 0 }, + width: { type: 'integer', notNull: true, default: 2 }, + height: { type: 'integer', notNull: true, default: 4 }, + signal_type: { type: 'text', notNull: true, default: 'both' }, + chart_type: { type: 'text', notNull: true, default: 'signals' }, + viz_type: { type: 'text', notNull: true, default: 'line' }, + filter_combinators: { type: 'text[]' }, + filter_items: { type: 'text[]' }, + filter_items_exclude: { type: 'text[]' }, + filter_items_regex: { type: 'boolean', notNull: true, default: false }, + y_min: { type: 'real' }, + y_max: { type: 'real' }, + series_limit: { type: 'integer', notNull: true, default: 20 }, + order_by: { type: 'text', notNull: true, default: 'time' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }, + { ifNotExists: true }, + ); // Use DO blocks so constraints are idempotent on existing DBs pgm.sql(`DO $$ BEGIN @@ -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')); EXCEPTION WHEN duplicate_object THEN NULL; END $$`); - pgm.createTable('alerts', { - id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, - item_key: { type: 'text', notNull: true }, - item_key_is_regex: { type: 'boolean', notNull: true, default: false }, - combinator: { type: 'text' }, - signal_type: { type: 'text', notNull: true, default: 'green' }, - condition: { type: 'text', notNull: true }, - threshold: { type: 'integer', notNull: true }, - active: { type: 'boolean', notNull: true, default: true }, - created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, - }, { ifNotExists: true }); + pgm.createTable( + 'alerts', + { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + item_key: { type: 'text', notNull: true }, + item_key_is_regex: { type: 'boolean', notNull: true, default: false }, + combinator: { type: 'text' }, + signal_type: { type: 'text', notNull: true, default: 'green' }, + condition: { type: 'text', notNull: true }, + threshold: { type: 'integer', notNull: true }, + active: { type: 'boolean', notNull: true, default: true }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }, + { ifNotExists: true }, + ); pgm.sql(`DO $$ BEGIN ALTER TABLE alerts ADD CONSTRAINT alerts_signal_type_check CHECK (signal_type IN ('green','red')); @@ -85,10 +105,14 @@ exports.up = (pgm) => { ALTER TABLE alerts ADD CONSTRAINT alerts_condition_check CHECK (condition IN ('above','below')); EXCEPTION WHEN duplicate_object THEN NULL; END $$`); - pgm.createTable('settings', { - key: { type: 'text', primaryKey: true }, - value: { type: 'text', notNull: true }, - }, { ifNotExists: true }); + pgm.createTable( + 'settings', + { + key: { type: 'text', primaryKey: true }, + value: { type: 'text', notNull: true }, + }, + { ifNotExists: true }, + ); }; -exports.down = () => Promise.resolve(); \ No newline at end of file +exports.down = () => Promise.resolve(); diff --git a/web/migrations/002_add_y_scale_delta_order.js b/web/migrations/002_add_y_scale_delta_order.js index 08b3819..cfdb9bb 100644 --- a/web/migrations/002_add_y_scale_delta_order.js +++ b/web/migrations/002_add_y_scale_delta_order.js @@ -7,12 +7,16 @@ exports.up = (pgm) => { EXCEPTION WHEN duplicate_object THEN NULL; END $$`); pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`); - pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'))`); + pgm.sql( + `ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'))`, + ); }; exports.down = (pgm) => { pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`); - pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc'))`); + pgm.sql( + `ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc'))`, + ); pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_y_scale_check`); pgm.sql(`ALTER TABLE charts DROP COLUMN IF EXISTS y_scale`); -}; \ No newline at end of file +}; diff --git a/web/migrations/003_remove_stacked_add_divider.js b/web/migrations/003_remove_stacked_add_divider.js index 93bba5c..8771ce4 100644 --- a/web/migrations/003_remove_stacked_add_divider.js +++ b/web/migrations/003_remove_stacked_add_divider.js @@ -1,10 +1,14 @@ /** @type {import('node-pg-migrate').MigrationBuilder} */ exports.up = (pgm) => { pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`); - pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'))`); + pgm.sql( + `ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'))`, + ); pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`); - pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups','divider'))`); + pgm.sql( + `ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups','divider'))`, + ); // Migrate any existing stacked charts to line pgm.sql(`UPDATE charts SET viz_type = 'line' WHERE viz_type = 'stacked'`); @@ -12,8 +16,12 @@ exports.up = (pgm) => { exports.down = (pgm) => { pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`); - pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'))`); + pgm.sql( + `ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'))`, + ); pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`); - pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`); -}; \ No newline at end of file + pgm.sql( + `ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`, + ); +}; diff --git a/web/next.config.ts b/web/next.config.ts index de2d453..94647ad 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,7 +1,7 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - output: "standalone", + output: 'standalone', }; -export default nextConfig; \ No newline at end of file +export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json index dbb6334..b6ad153 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,6 +24,7 @@ "@types/react-dom": "^19.2.3", "node-pg-migrate": "^8.0.4", "postcss": "^8.5.14", + "prettier": "^3.8.3", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" } @@ -1955,6 +1956,22 @@ "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": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/web/package.json b/web/package.json index 1408741..3c6806d 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,9 @@ "dev": "next dev --turbopack", "build": "next build", "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:down": "node-pg-migrate down -m migrations", "migrate:create": "node-pg-migrate create -m migrations" @@ -28,6 +30,7 @@ "@types/react-dom": "^19.2.3", "node-pg-migrate": "^8.0.4", "postcss": "^8.5.14", + "prettier": "^3.8.3", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" } diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs index 61e3684..297374d 100644 --- a/web/postcss.config.mjs +++ b/web/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, };