refactor: extract signals filter builder, add ESLint 10 config, fix low-hanging issues

This commit is contained in:
Sebastian Seedorf
2026-06-04 14:09:12 +02:00
parent cf9bb33ecb
commit 4b05f2968e
34 changed files with 2145 additions and 188 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers'; import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const PUT = withAuth(async (req: NextRequest, { params }) => { export const PUT = withAuth(async (req: NextRequest, { params }) => {
const { id } = await params; const { id } = await params;

View File

@@ -1,9 +1,11 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import pool from '@/lib/db';
import type { AlertConfig, TriggeredAlert } from '@/lib/types';
import { withAuth } from '@/lib/apiHelpers'; import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
import { getServerLocaleMap } from '@/lib/localeServer'; import { getServerLocaleMap } from '@/lib/localeServer';
import { resolveName } from '@/lib/localization'; import { resolveName } from '@/lib/localization';
import type { AlertConfig, TriggeredAlert } from '@/lib/types';
export const GET = withAuth(async () => { export const GET = withAuth(async () => {
const alertsResult = await pool.query<AlertConfig>( const alertsResult = await pool.query<AlertConfig>(

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers'; import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const GET = withAuth(async () => { export const GET = withAuth(async () => {
const result = await pool.query('SELECT * FROM alerts ORDER BY created_at DESC'); const result = await pool.query('SELECT * FROM alerts ORDER BY created_at DESC');

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers'; import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const PUT = withAuth(async (req: NextRequest, { params }) => { export const PUT = withAuth(async (req: NextRequest, { params }) => {
const { id } = await params; const { id } = await params;

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers'; import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const GET = withAuth(async () => { export const GET = withAuth(async () => {
const result = await pool.query('SELECT * FROM charts ORDER BY pos_y ASC, pos_x ASC'); const result = await pool.query('SELECT * FROM charts ORDER BY pos_y ASC, pos_x ASC');

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers'; import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
interface CircuitNetwork { interface CircuitNetwork {
green: Record<string, number>; green: Record<string, number>;

View File

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

View File

@@ -1,8 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import { withAuth, buildItemFilter } from '@/lib/apiHelpers';
import pool from '@/lib/db'; import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
import { getServerLocaleMap } from '@/lib/localeServer';
import { matchKeys } from '@/lib/localization';
export const GET = withAuth(async (req: NextRequest) => { export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams; const p = req.nextUrl.searchParams;
@@ -14,59 +13,30 @@ export const GET = withAuth(async (req: NextRequest) => {
const to = p.get('to'); const to = p.get('to');
const useRegex = p.get('regex') === 'true'; const useRegex = p.get('regex') === 'true';
const orderBy = p.get('order_by') ?? 'value_asc'; const orderBy = p.get('order_by') ?? 'value_asc';
const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null; const limitStr = p.get('limit');
const limit = limitStr ? parseInt(limitStr, 10) : null;
const conditions: string[] = []; const conditions: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
let i = 1; const param = { current: 1 };
if (combinators.length > 0) { if (combinators.length > 0) {
conditions.push(`combinator = ANY($${i++})`); conditions.push(`combinator = ANY($${param.current++})`);
values.push(combinators); values.push(combinators);
} }
if (itemsWhitelist.length > 0) { const wlClause = buildItemFilter(itemsWhitelist, useRegex, true, values, param);
if (useRegex) { if (wlClause) conditions.push(wlClause);
const localeMap = getServerLocaleMap();
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) {
orConds.push(`item_key = ANY($${i++})`);
values.push(localeKeys);
}
conditions.push(`(${orConds.join(' OR ')})`);
} else {
conditions.push(`item_key = ANY($${i++})`);
values.push(itemsWhitelist);
}
}
if (itemsBlacklist.length > 0) { const blClause = buildItemFilter(itemsBlacklist, useRegex, false, values, param);
if (useRegex) { if (blClause) conditions.push(blClause);
const localeMap = getServerLocaleMap();
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) {
andConds.push(`item_key != ALL($${i++})`);
values.push(localeKeys);
}
conditions.push(`(${andConds.join(' AND ')})`);
} else {
conditions.push(`item_key != ALL($${i++})`);
values.push(itemsBlacklist);
}
}
if (from) { if (from) {
conditions.push(`real_time >= $${i++}`); conditions.push(`real_time >= $${param.current++}`);
values.push(new Date(from)); values.push(new Date(from));
} }
if (to) { if (to) {
conditions.push(`real_time <= $${i++}`); conditions.push(`real_time <= $${param.current++}`);
values.push(new Date(to)); values.push(new Date(to));
} }
@@ -83,46 +53,18 @@ export const GET = withAuth(async (req: NextRequest) => {
if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) { if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) {
const baseConditions: string[] = []; const baseConditions: string[] = [];
const baseValues: unknown[] = []; const baseValues: unknown[] = [];
let j = 1; const baseParam = { current: 1 };
if (combinators.length > 0) { if (combinators.length > 0) {
baseConditions.push(`combinator = ANY($${j++})`); baseConditions.push(`combinator = ANY($${baseParam.current++})`);
baseValues.push(combinators); baseValues.push(combinators);
} }
if (itemsWhitelist.length > 0) {
if (useRegex) { const wlBase = buildItemFilter(itemsWhitelist, useRegex, true, baseValues, baseParam);
const localeMap = getServerLocaleMap(); if (wlBase) baseConditions.push(wlBase);
const localeKeys = [...new Set(itemsWhitelist.flatMap((p) => matchKeys(p, localeMap)))];
const sqlPattern = itemsWhitelist.map((p) => `(${p})`).join('|'); const blBase = buildItemFilter(itemsBlacklist, useRegex, false, baseValues, baseParam);
const orConds = [`item_key ~* $${j++}`]; if (blBase) baseConditions.push(blBase);
baseValues.push(sqlPattern);
if (localeKeys.length > 0) {
orConds.push(`item_key = ANY($${j++})`);
baseValues.push(localeKeys);
}
baseConditions.push(`(${orConds.join(' OR ')})`);
} else {
baseConditions.push(`item_key = ANY($${j++})`);
baseValues.push(itemsWhitelist);
}
}
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 andConds = [`item_key !~* $${j++}`];
baseValues.push(sqlPattern);
if (localeKeys.length > 0) {
andConds.push(`item_key != ALL($${j++})`);
baseValues.push(localeKeys);
}
baseConditions.push(`(${andConds.join(' AND ')})`);
} else {
baseConditions.push(`item_key != ALL($${j++})`);
baseValues.push(itemsBlacklist);
}
}
const baseWhere = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : ''; const baseWhere = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : '';
const baseWhereAnd = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : ''; const baseWhereAnd = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : '';
@@ -155,25 +97,27 @@ export const GET = withAuth(async (req: NextRequest) => {
SELECT combinator, item_key, delta SELECT combinator, item_key, delta
FROM deltas FROM deltas
ORDER BY delta ${orderBy === 'delta_asc' ? 'ASC' : 'DESC'} ORDER BY delta ${orderBy === 'delta_asc' ? 'ASC' : 'DESC'}
LIMIT $${j} LIMIT $${baseParam.current}
`; `;
const deltaResult = await pool.query<{ combinator: string; item_key: string; delta: number }>( const deltaResult = await pool.query<{
deltaQuery, combinator: string;
[...baseValues, limit], item_key: string;
); delta: number;
}>(deltaQuery, [...baseValues, limit]);
const top = deltaResult.rows; const top = deltaResult.rows;
if (top.length === 0) return NextResponse.json([]); if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map( const seriesConditions = top.map(
(_, idx) => `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, (_, idx) =>
`(combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1})`,
); );
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`; const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const orderCase = top const orderCase = top
.map( .map(
(_, idx) => (_, idx) =>
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, `WHEN combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1} THEN ${idx}`,
) )
.join(' '); .join(' ');
const result = await pool.query( const result = await pool.query(
@@ -187,7 +131,11 @@ export const GET = withAuth(async (req: NextRequest) => {
(orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') && (orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') &&
limit !== null limit !== null
) { ) {
const latestVals = await pool.query<{ combinator: string; item_key: string; val: number }>( const latestVals = await pool.query<{
combinator: string;
item_key: string;
val: number;
}>(
`SELECT DISTINCT ON (combinator, item_key) `SELECT DISTINCT ON (combinator, item_key)
combinator, item_key, ${valueCol} AS val combinator, item_key, ${valueCol} AS val
FROM signals ${where} FROM signals ${where}
@@ -205,13 +153,14 @@ export const GET = withAuth(async (req: NextRequest) => {
if (top.length === 0) return NextResponse.json([]); if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map( const seriesConditions = top.map(
(_, idx) => `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`, (_, idx) =>
`(combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1})`,
); );
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`; const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const orderCase = top const orderCase = top
.map( .map(
(_, idx) => (_, idx) =>
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`, `WHEN combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1} THEN ${idx}`,
) )
.join(' '); .join(' ');
const result = await pool.query( const result = await pool.query(

View File

@@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers'; import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const GET = withAuth(async (req: NextRequest) => { export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams; const p = req.nextUrl.searchParams;
const combinator = p.get('combinator'); const combinator = p.get('combinator');
const from = p.get('from') ? new Date(p.get('from')!) : new Date(Date.now() - 86_400_000); const fromVal = p.get('from');
const to = p.get('to') ? new Date(p.get('to')!) : new Date(); const toVal = p.get('to');
const from = fromVal ? new Date(fromVal) : new Date(Date.now() - 86_400_000);
const to = toVal ? new Date(toVal) : new Date();
const conditions = ['real_time BETWEEN $1 AND $2']; const conditions = ['real_time BETWEEN $1 AND $2'];
const values: unknown[] = [from, to]; const values: unknown[] = [from, to];

View File

@@ -1,15 +1,17 @@
'use client'; 'use client';
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { AppProvider, useApp } from '@/lib/context'; import { useEffect, useState, Suspense } from 'react';
import { setToken, fetchAlerts } from '@/lib/api';
import { getLocaleMap } from '@/lib/localization';
import type { AlertConfig } from '@/lib/types';
import type { LocaleMap } from '@/lib/localization'; import type { LocaleMap } from '@/lib/localization';
import TimeRangeSelector from '@/components/TimeRangeSelector'; import type { AlertConfig } from '@/lib/types';
import AlertPanel from '@/components/AlertPanel'; import AlertPanel from '@/components/AlertPanel';
import Dashboard from '@/components/Dashboard'; import Dashboard from '@/components/Dashboard';
import TimeRangeSelector from '@/components/TimeRangeSelector';
import { setToken, fetchAlerts } from '@/lib/api';
import { AppProvider, useApp } from '@/lib/context';
import { getLocaleMap } from '@/lib/localization';
function AppShell({ alerts }: { alerts: AlertConfig[] }) { function AppShell({ alerts }: { alerts: AlertConfig[] }) {
const { triggeredAlerts } = useApp(); const { triggeredAlerts } = useApp();

View File

@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'fs';
const CSV = 'public/factorio_item_colors.csv'; const CSV = 'public/factorio_item_colors.csv';
function hexToHsl(h: string): [number, number, number] { function hexToHsl(h: string): [number, number, number] {
let r = parseInt(h.slice(1, 3), 16) / 255, const r = parseInt(h.slice(1, 3), 16) / 255,
g = parseInt(h.slice(3, 5), 16) / 255, g = parseInt(h.slice(3, 5), 16) / 255,
b = parseInt(h.slice(5, 7), 16) / 255; b = parseInt(h.slice(5, 7), 16) / 255;
const mx = Math.max(r, g, b), const mx = Math.max(r, g, b),
@@ -11,7 +11,7 @@ function hexToHsl(h: string): [number, number, number] {
if (mx === mn) return [0, 0, Math.round(l * 100)]; if (mx === mn) return [0, 0, Math.round(l * 100)];
const d = mx - mn, const d = mx - mn,
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let hue = 0; let hue: number;
if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 60; if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (mx === g) hue = ((b - r) / d + 2) * 60; else if (mx === g) hue = ((b - r) / d + 2) * 60;
else hue = ((r - g) / d + 4) * 60; else hue = ((r - g) / d + 4) * 60;
@@ -61,7 +61,14 @@ function hueDist(a: number, b: number): number {
return Math.min(d, 360 - d); return Math.min(d, 360 - d);
} }
const lines = readFileSync(CSV, 'utf-8').trim().split('\n'); let rawText: string;
try {
rawText = readFileSync(CSV, 'utf-8');
} catch (e) {
console.error('Failed to read CSV:', e);
process.exit(1);
}
const lines = rawText.trim().split('\n');
const header = lines[0]; const header = lines[0];
const raw = lines const raw = lines
.slice(1) .slice(1)
@@ -108,8 +115,9 @@ for (let i = 0; i < n; i++) {
const groups = new Map<number, typeof hsl>(); const groups = new Map<number, typeof hsl>();
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const root = find(i); const root = find(i);
if (!groups.has(root)) groups.set(root, []); const group = groups.get(root) ?? [];
groups.get(root)!.push(hsl[i]); if (!groups.has(root)) groups.set(root, group);
group.push(hsl[i]);
} }
let fixed = 0; let fixed = 0;

View File

@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useApp } from '@/lib/context';
import { fetchAlerts, createAlert, updateAlert, deleteAlert } from '@/lib/api';
import { resolveName, resolveKey } from '@/lib/localization';
import type { AlertConfig } from '@/lib/types'; import type { AlertConfig } from '@/lib/types';
import { fetchAlerts, createAlert, updateAlert, deleteAlert } from '@/lib/api';
import { useApp } from '@/lib/context';
import { resolveName, resolveKey } from '@/lib/localization';
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -121,6 +123,7 @@ function AlertForm({
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
aria-label={submitLabel}
onClick={onSubmit} onClick={onSubmit}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5" className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5"
> >
@@ -128,6 +131,7 @@ function AlertForm({
</button> </button>
{onCancel && ( {onCancel && (
<button <button
aria-label="Cancel"
onClick={onCancel} onClick={onCancel}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700" className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
> >
@@ -224,7 +228,11 @@ export default function AlertPanel({ open, onClose }: Props) {
> >
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700"> <div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<span className="font-semibold text-white">Alerts</span> <span className="font-semibold text-white">Alerts</span>
<button onClick={onClose} className="text-gray-400 hover:text-white"> <button
aria-label="Close alerts panel"
onClick={onClose}
className="text-gray-400 hover:text-white"
>
</button> </button>
</div> </div>
@@ -288,12 +296,14 @@ export default function AlertPanel({ open, onClose }: Props) {
</div> </div>
<div className="flex gap-1 ml-2 shrink-0"> <div className="flex gap-1 ml-2 shrink-0">
<button <button
aria-label="Edit alert"
onClick={() => startEdit(a)} onClick={() => startEdit(a)}
className="text-gray-500 hover:text-indigo-400" className="text-gray-500 hover:text-indigo-400"
> >
</button> </button>
<button <button
aria-label="Delete alert"
onClick={() => handleDelete(a.id)} onClick={() => handleDelete(a.id)}
className="text-gray-500 hover:text-red-400" className="text-gray-500 hover:text-red-400"
> >

View File

@@ -12,12 +12,14 @@ export function Header({ title, onEdit, onDelete }: HeaderProps) {
<span className="text-sm font-medium text-gray-200 truncate">{title}</span> <span className="text-sm font-medium text-gray-200 truncate">{title}</span>
<div className="flex gap-1 ml-2 shrink-0"> <div className="flex gap-1 ml-2 shrink-0">
<button <button
aria-label="Edit chart"
onClick={onEdit} onClick={onEdit}
className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700" className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"
> >
</button> </button>
<button <button
aria-label="Delete chart"
onClick={onDelete} onClick={onDelete}
className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700" className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700"
> >
@@ -38,7 +40,7 @@ interface CardShellProps extends HeaderProps {
empty: boolean; empty: boolean;
children: React.ReactNode; children: React.ReactNode;
/** Ref to the div where the uPlot legend will be mounted */ /** Ref to the div where the uPlot legend will be mounted */
legendContainerRef?: React.RefObject<HTMLDivElement>; legendContainerRef?: React.RefObject<HTMLDivElement | null>;
} }
export function CardShell({ export function CardShell({

View File

@@ -13,12 +13,14 @@ export default function DividerCard({ title, onEdit, onDelete }: Props) {
<div className="flex-1 h-px bg-gray-600" /> <div className="flex-1 h-px bg-gray-600" />
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
<button <button
aria-label="Edit divider"
onClick={onEdit} onClick={onEdit}
className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700" className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"
> >
</button> </button>
<button <button
aria-label="Delete divider"
onClick={onDelete} onClick={onDelete}
className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700" className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700"
> >

View File

@@ -1,12 +1,9 @@
'use client'; 'use client';
import 'uplot/dist/uPlot.min.css'; import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useApp } from '@/lib/context'; import uPlot from 'uplot';
import { resolveName } from '@/lib/localization';
import { getColorMap } from '@/lib/colors';
import type { ColorMap } from '@/lib/colors';
import { CardShell } from './CardShell'; import { CardShell } from './CardShell';
import { import {
makeYScale, makeYScale,
@@ -17,8 +14,13 @@ import {
} from './plotHelpers'; } from './plotHelpers';
import { buildSeriesData } from './seriesData'; import { buildSeriesData } from './seriesData';
import { usePlot } from './usePlot'; import { usePlot } from './usePlot';
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types'; import type { ColorMap } from '@/lib/colors';
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow, TimeMode } from '@/lib/types';
import { getColorMap } from '@/lib/colors';
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
interface Props { interface Props {
config: ChartConfig; config: ChartConfig;
@@ -81,7 +83,17 @@ export default function SignalsChart({
el, el,
); );
}, },
[rows, sessions, alerts, config, timeMode, localeMap], [
rows,
sessions,
alerts,
config.signal_type,
config.y_min,
config.y_max,
config.y_scale,
timeMode,
localeMap,
],
); );
return ( return (

View File

@@ -1,12 +1,15 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
import { formatSI } from '@/lib/formatNumber';
import { getColorMap, getItemColor } from '@/lib/colors';
import type { ColorMap } from '@/lib/colors';
import { CardShell } from './CardShell'; import { CardShell } from './CardShell';
import type { ColorMap } from '@/lib/colors';
import type { ChartConfig, SignalRow } from '@/lib/types'; import type { ChartConfig, SignalRow } from '@/lib/types';
import { getColorMap, getItemColor } from '@/lib/colors';
import { useApp } from '@/lib/context';
import { formatSI } from '@/lib/formatNumber';
import { resolveName } from '@/lib/localization';
interface Props { interface Props {
config: ChartConfig; config: ChartConfig;
rows: SignalRow[]; rows: SignalRow[];

View File

@@ -2,12 +2,14 @@
import 'uplot/dist/uPlot.min.css'; import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { CardShell } from './CardShell'; import { CardShell } from './CardShell';
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers'; import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
import { formatSI } from '@/lib/formatNumber';
import { usePlot } from './usePlot'; import { usePlot } from './usePlot';
import type { ChartConfig, UpsRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types'; import type { ChartConfig, UpsRow, TimeMode } from '@/lib/types';
import { formatSI } from '@/lib/formatNumber';
interface Props { interface Props {
config: ChartConfig; config: ChartConfig;

View File

@@ -1,9 +1,16 @@
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow, UpsRow } from '@/lib/types'; import DividerCard from './DividerCard';
import type { TimeMode } from '@/lib/types';
import UpsChart from './UpsChart';
import SignalsChart from './SignalsChart'; import SignalsChart from './SignalsChart';
import TableViz from './TableViz'; import TableViz from './TableViz';
import DividerCard from './DividerCard'; import UpsChart from './UpsChart';
import type {
AlertConfig,
ChartConfig,
SessionBoundary,
SignalRow,
UpsRow,
TimeMode,
} from '@/lib/types';
export interface ChartCardProps { export interface ChartCardProps {
config: ChartConfig; config: ChartConfig;

View File

@@ -1,8 +1,9 @@
import uPlot from 'uplot';
import type { ChartConfig } from '@/lib/types';
import { formatSI } from '@/lib/formatNumber';
import type { ColorMap } from '@/lib/colors'; import type { ColorMap } from '@/lib/colors';
import type { ChartConfig } from '@/lib/types';
import type uPlot from 'uplot';
import { getItemColor } from '@/lib/colors'; import { getItemColor } from '@/lib/colors';
import { formatSI } from '@/lib/formatNumber';
const SEMANTIC_GREEN = '#4ade80'; const SEMANTIC_GREEN = '#4ade80';
const SEMANTIC_RED = '#f87171'; const SEMANTIC_RED = '#f87171';

View File

@@ -1,5 +1,4 @@
import type { SignalRow, ChartConfig } from '@/lib/types'; import type { SignalRow, ChartConfig, TimeMode } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
const MAX_SERIES = 80; const MAX_SERIES = 80;
@@ -29,20 +28,25 @@ export function buildSeriesData(
? parseInt(row.game_tick, 10) ? parseInt(row.game_tick, 10)
: new Date(row.real_time).getTime() / 1000; : new Date(row.real_time).getTime() / 1000;
if (!seriesMap.has(key)) seriesMap.set(key, new Map()); if (!seriesMap.has(key)) seriesMap.set(key, new Map());
seriesMap.get(key)!.set(x, val); seriesMap.get(key)?.set(x, val);
} }
} }
if (seriesMap.size === 0) return null; if (seriesMap.size === 0) return null;
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES); const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
const allXs = [...new Set(keys.flatMap((k) => [...seriesMap.get(k)!.keys()]))].sort( const allXs = [
(a, b) => a - b, ...new Set(
); keys.flatMap((k) => {
const m = seriesMap.get(k);
return m ? [...m.keys()] : [];
}),
),
].sort((a, b) => a - b);
const data = keys.map((k) => { const data = keys.map((k) => {
const m = seriesMap.get(k)!; const m = seriesMap.get(k);
return allXs.map((x) => m.get(x)); // undefined = gap return m ? allXs.map((x) => m.get(x)) : []; // undefined = gap
}); });
return { keys, allXs, data }; return { keys, allXs, data };

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef, type DependencyList } from 'react';
import uPlot from 'uplot';
import type uPlot from 'uplot';
export type BuildFn = ( export type BuildFn = (
el: HTMLDivElement, el: HTMLDivElement,
w: number, w: number,
h: number, h: number,
legendRef: React.RefObject<HTMLDivElement>, legendRef: React.RefObject<HTMLDivElement | null>,
) => uPlot | null; ) => uPlot | null;
/** Converts a data index to the pixel x position uPlot expects for setCursor */ /** Converts a data index to the pixel x position uPlot expects for setCursor */
@@ -24,14 +25,13 @@ function idxToPixel(plot: uPlot, idx: number): number {
*/ */
export function usePlot( export function usePlot(
build: BuildFn, build: BuildFn,
// eslint-disable-next-line @typescript-eslint/no-explicit-any deps: DependencyList,
deps: any[],
): { ): {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
legendRef: React.RefObject<HTMLDivElement>; legendRef: React.RefObject<HTMLDivElement | null>;
} { } {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const legendRef = useRef<HTMLDivElement>(null!); const legendRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(null); const plotRef = useRef<uPlot | null>(null);
const lastIdxRef = useRef<number>(0); const lastIdxRef = useRef<number>(0);
@@ -64,7 +64,8 @@ export function usePlot(
}); });
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // deps is intentionally dynamic — passed by parent to allow external rebuild triggers
// eslint-disable-next-line react-x/exhaustive-deps
}, deps); }, deps);
useEffect(() => { useEffect(() => {

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { ChartConfig } from '@/lib/types';
import { useApp } from '@/lib/context'; import { useApp } from '@/lib/context';
import { resolveKey } from '@/lib/localization'; import { resolveKey } from '@/lib/localization';
import type { ChartConfig } from '@/lib/types';
type DraftChart = Omit<ChartConfig, 'id'>; type DraftChart = Omit<ChartConfig, 'id'>;

View File

@@ -2,10 +2,15 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import GridLayout from 'react-grid-layout'; import GridLayout from 'react-grid-layout';
import ChartCard from './ChartCard';
import ChartEditor from './ChartEditor';
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
import type { Layout, LayoutItem } from 'react-grid-layout'; import type { Layout, LayoutItem } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useApp } from '@/lib/context';
import { import {
fetchCharts, fetchCharts,
createChart, createChart,
@@ -15,9 +20,7 @@ import {
fetchSessions, fetchSessions,
fetchUps, fetchUps,
} from '@/lib/api'; } from '@/lib/api';
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types'; import { useApp } from '@/lib/context';
import ChartCard from './ChartCard';
import ChartEditor from './ChartEditor';
const COLS = 6; const COLS = 6;
const ROW_HEIGHT = 80; const ROW_HEIGHT = 80;
@@ -211,6 +214,7 @@ export default function Dashboard({ alerts }: Props) {
</GridLayout> </GridLayout>
<button <button
aria-label="Create chart"
onClick={() => setCreatingChart(true)} onClick={() => setCreatingChart(true)}
className="fixed bottom-6 right-6 w-12 h-12 bg-indigo-600 hover:bg-indigo-500 text-white rounded-full text-2xl shadow-lg flex items-center justify-center z-30" className="fixed bottom-6 right-6 w-12 h-12 bg-indigo-600 hover:bg-indigo-500 text-white rounded-full text-2xl shadow-lg flex items-center justify-center z-30"
> >

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useApp } from '@/lib/context';
import type { TimeRange, TimeMode } from '@/lib/types'; import type { TimeRange, TimeMode } from '@/lib/types';
import { useApp } from '@/lib/context';
const RANGES: TimeRange[] = ['30m', '1h', '6h', '24h', '7d', '30d']; const RANGES: TimeRange[] = ['30m', '1h', '6h', '24h', '7d', '30d'];
export default function TimeRangeSelector() { export default function TimeRangeSelector() {

62
web/eslint.config.mjs Normal file
View File

@@ -0,0 +1,62 @@
import js from '@eslint/js';
import importX from 'eslint-plugin-import-x';
import reactX from 'eslint-plugin-react-x';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['**/node_modules', '**/.next', '**/dist', '**/out'],
},
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
},
js.configs.recommended,
tseslint.configs.recommended,
reactX.configs.recommended,
{
plugins: {
'import-x': importX,
},
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-expect-error': 'allow-with-description',
'ts-ignore': 'allow-with-description',
'ts-nocheck': true,
'ts-check': true,
},
],
'react-x/exhaustive-deps': 'error',
'react-x/use-state': 'off',
'react-x/no-context-provider': 'off',
'react-x/no-array-index-key': 'off',
'react-x/no-use-context': 'off',
'import-x/no-duplicates': 'error',
'import-x/order': [
'warn',
{
groups: [
'builtin',
'external',
'internal',
['parent', 'sibling'],
'index',
'object',
'type',
],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
},
},
);

View File

@@ -1,5 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import { isAuthorized, unauthorized } from './auth'; import { isAuthorized, unauthorized } from './auth';
import { getServerLocaleMap } from './localeServer';
import { matchKeys } from './localization';
type RouteContext = { params: Promise<Record<string, string>> }; type RouteContext = { params: Promise<Record<string, string>> };
type Handler = (req: NextRequest, ctx: RouteContext) => Promise<NextResponse>; type Handler = (req: NextRequest, ctx: RouteContext) => Promise<NextResponse>;
@@ -24,3 +27,53 @@ export function withAuth(handler: Handler): Handler {
} }
}; };
} }
/**
* Builds a SQL WHERE clause fragment for item whitelist/blacklist filtering.
*
* - `isWhitelist=true`: `item_key ~* $N OR item_key = ANY($N+1)` (regex), `item_key = ANY($N)` (exact)
* - `isWhitelist=false`: `item_key !~* $N AND item_key != ALL($N+1)` (regex), `item_key != ALL($N)` (exact)
*
* Mutates `values` and `param.current` to track parameter bindings.
*/
export function buildItemFilter(
items: string[],
useRegex: boolean,
isWhitelist: boolean,
values: unknown[],
param: { current: number },
): string | null {
if (items.length === 0) return null;
if (useRegex) {
const localeMap = getServerLocaleMap();
const localeKeys = [...new Set(items.flatMap((p) => matchKeys(p, localeMap)))];
const sqlPattern = items.map((p) => `(${p})`).join('|');
if (isWhitelist) {
const orConds: string[] = [`item_key ~* $${param.current++}`];
values.push(sqlPattern);
if (localeKeys.length > 0) {
orConds.push(`item_key = ANY($${param.current++})`);
values.push(localeKeys);
}
return `(${orConds.join(' OR ')})`;
} else {
const andConds: string[] = [`item_key !~* $${param.current++}`];
values.push(sqlPattern);
if (localeKeys.length > 0) {
andConds.push(`item_key != ALL($${param.current++})`);
values.push(localeKeys);
}
return `(${andConds.join(' AND ')})`;
}
}
if (isWhitelist) {
values.push(items);
return `item_key = ANY($${param.current++})`;
} else {
values.push(items);
return `item_key != ALL($${param.current++})`;
}
}

View File

@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
/** /**
* Returns true if the request carries a valid API token. * Returns true if the request carries a valid API token.

View File

@@ -1,11 +1,13 @@
'use client'; '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'; import { checkAlerts } from './api';
import { buildReverseMap } from './localization'; import { buildReverseMap } from './localization';
import { TIME_RANGE_MS } from './types';
import type { LocaleMap, ReverseMap } from './localization'; import type { LocaleMap, ReverseMap } from './localization';
import type { TimeRange, TimeMode, TriggeredAlert } from './types';
interface AppContextValue { interface AppContextValue {
timeRange: TimeRange; timeRange: TimeRange;
@@ -22,14 +24,12 @@ interface AppContextValue {
const AppContext = createContext<AppContextValue | null>(null); const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({ export function AppProvider({
token: _token,
localeMap, localeMap,
children, children,
}: { }: {
token: string;
localeMap: LocaleMap; localeMap: LocaleMap;
children: React.ReactNode; children: React.ReactNode;
}) { } & { token?: string }) {
const [timeRange, setTimeRange] = useState<TimeRange>('6h'); const [timeRange, setTimeRange] = useState<TimeRange>('6h');
const [timeMode, setTimeMode] = useState<TimeMode>('real'); const [timeMode, setTimeMode] = useState<TimeMode>('real');
const [triggeredAlerts, setTriggeredAlerts] = useState<TriggeredAlert[]>([]); const [triggeredAlerts, setTriggeredAlerts] = useState<TriggeredAlert[]>([]);

View File

@@ -1,7 +1,6 @@
import { Pool } from 'pg'; import { Pool } from 'pg';
declare global { declare global {
// eslint-disable-next-line no-var
var __pgPool: Pool | undefined; var __pgPool: Pool | undefined;
} }

View File

@@ -1,6 +1,8 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { parseCsv } from './localization'; import { parseCsv } from './localization';
import type { LocaleMap } from './localization'; import type { LocaleMap } from './localization';
declare global { declare global {

View File

@@ -1,6 +1,7 @@
import pool from '@/lib/db';
import type { SessionBoundary } from '@/lib/types'; import type { SessionBoundary } from '@/lib/types';
import pool from '@/lib/db';
/** /**
* Returns session-start timestamps where any gap > 30 min exists * Returns session-start timestamps where any gap > 30 min exists
* in the global tick_timing timeline. * in the global tick_timing timeline.

View File

@@ -74,7 +74,7 @@ exports.up = (pgm) => {
EXCEPTION WHEN duplicate_object THEN NULL; END $$`); EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table')); ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`); EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN pgm.sql(`DO $$ BEGIN

1823
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "echo 'no lint'", "lint": "eslint . --max-warnings 0",
"format": "prettier --check .", "format": "prettier --check .",
"format:fix": "prettier --write .", "format:fix": "prettier --write .",
"migrate": "node-pg-migrate up -m migrations", "migrate": "node-pg-migrate up -m migrations",
@@ -24,14 +24,20 @@
"uplot": "^1.6.32" "uplot": "^1.6.32"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24", "@types/node": "^24",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^10.4.1",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react-x": "^5.8.11",
"globals": "^17.6.0",
"node-pg-migrate": "^8.0.4", "node-pg-migrate": "^8.0.4",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"typescript-eslint": "^8.60.1"
} }
} }