refactor: extract signals filter builder, add ESLint 10 config, fix low-hanging issues
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
export const PUT = withAuth(async (req: NextRequest, { params }) => {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
import type { AlertConfig, TriggeredAlert } from '@/lib/types';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import pool from '@/lib/db';
|
||||
import { getServerLocaleMap } from '@/lib/localeServer';
|
||||
import { resolveName } from '@/lib/localization';
|
||||
import type { AlertConfig, TriggeredAlert } from '@/lib/types';
|
||||
|
||||
export const GET = withAuth(async () => {
|
||||
const alertsResult = await pool.query<AlertConfig>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
export const GET = withAuth(async () => {
|
||||
const result = await pool.query('SELECT * FROM alerts ORDER BY created_at DESC');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
export const PUT = withAuth(async (req: NextRequest, { params }) => {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
export const GET = withAuth(async () => {
|
||||
const result = await pool.query('SELECT * FROM charts ORDER BY pos_y ASC, pos_x ASC');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
interface CircuitNetwork {
|
||||
green: Record<string, number>;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSessionBoundaries } from '@/lib/sessions';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import { getSessionBoundaries } from '@/lib/sessions';
|
||||
|
||||
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 fromVal = p.get('from');
|
||||
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);
|
||||
return NextResponse.json(boundaries);
|
||||
});
|
||||
|
||||
@@ -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 { withAuth } from '@/lib/apiHelpers';
|
||||
import { getServerLocaleMap } from '@/lib/localeServer';
|
||||
import { matchKeys } from '@/lib/localization';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest) => {
|
||||
const p = req.nextUrl.searchParams;
|
||||
@@ -14,59 +13,30 @@ export const GET = withAuth(async (req: NextRequest) => {
|
||||
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 limitStr = p.get('limit');
|
||||
const limit = limitStr ? parseInt(limitStr, 10) : null;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
const param = { current: 1 };
|
||||
|
||||
if (combinators.length > 0) {
|
||||
conditions.push(`combinator = ANY($${i++})`);
|
||||
conditions.push(`combinator = ANY($${param.current++})`);
|
||||
values.push(combinators);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
const wlClause = buildItemFilter(itemsWhitelist, useRegex, true, values, param);
|
||||
if (wlClause) conditions.push(wlClause);
|
||||
|
||||
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 !~* $${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);
|
||||
}
|
||||
}
|
||||
const blClause = buildItemFilter(itemsBlacklist, useRegex, false, values, param);
|
||||
if (blClause) conditions.push(blClause);
|
||||
|
||||
if (from) {
|
||||
conditions.push(`real_time >= $${i++}`);
|
||||
conditions.push(`real_time >= $${param.current++}`);
|
||||
values.push(new Date(from));
|
||||
}
|
||||
if (to) {
|
||||
conditions.push(`real_time <= $${i++}`);
|
||||
conditions.push(`real_time <= $${param.current++}`);
|
||||
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) {
|
||||
const baseConditions: string[] = [];
|
||||
const baseValues: unknown[] = [];
|
||||
let j = 1;
|
||||
const baseParam = { current: 1 };
|
||||
|
||||
if (combinators.length > 0) {
|
||||
baseConditions.push(`combinator = ANY($${j++})`);
|
||||
baseConditions.push(`combinator = ANY($${baseParam.current++})`);
|
||||
baseValues.push(combinators);
|
||||
}
|
||||
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 orConds = [`item_key ~* $${j++}`];
|
||||
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 wlBase = buildItemFilter(itemsWhitelist, useRegex, true, baseValues, baseParam);
|
||||
if (wlBase) baseConditions.push(wlBase);
|
||||
|
||||
const blBase = buildItemFilter(itemsBlacklist, useRegex, false, baseValues, baseParam);
|
||||
if (blBase) baseConditions.push(blBase);
|
||||
|
||||
const baseWhere = baseConditions.length > 0 ? `WHERE ${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
|
||||
FROM deltas
|
||||
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 }>(
|
||||
deltaQuery,
|
||||
[...baseValues, limit],
|
||||
);
|
||||
const deltaResult = await pool.query<{
|
||||
combinator: string;
|
||||
item_key: string;
|
||||
delta: number;
|
||||
}>(deltaQuery, [...baseValues, limit]);
|
||||
|
||||
const top = deltaResult.rows;
|
||||
if (top.length === 0) return NextResponse.json([]);
|
||||
|
||||
const seriesConditions = top.map(
|
||||
(_, idx) => `(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
|
||||
(_, idx) =>
|
||||
`(combinator = $${param.current + idx * 2} AND item_key = $${param.current + 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}`,
|
||||
`WHEN combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1} THEN ${idx}`,
|
||||
)
|
||||
.join(' ');
|
||||
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') &&
|
||||
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)
|
||||
combinator, item_key, ${valueCol} AS val
|
||||
FROM signals ${where}
|
||||
@@ -205,13 +153,14 @@ export const GET = withAuth(async (req: NextRequest) => {
|
||||
if (top.length === 0) return NextResponse.json([]);
|
||||
|
||||
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 orderCase = top
|
||||
.map(
|
||||
(_, 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(' ');
|
||||
const result = await pool.query(
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import pool from '@/lib/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/apiHelpers';
|
||||
import pool from '@/lib/db';
|
||||
|
||||
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 fromVal = p.get('from');
|
||||
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 values: unknown[] = [from, to];
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppProvider, useApp } from '@/lib/context';
|
||||
import { setToken, fetchAlerts } from '@/lib/api';
|
||||
import { getLocaleMap } from '@/lib/localization';
|
||||
import type { AlertConfig } from '@/lib/types';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
|
||||
import type { LocaleMap } from '@/lib/localization';
|
||||
import TimeRangeSelector from '@/components/TimeRangeSelector';
|
||||
import type { AlertConfig } from '@/lib/types';
|
||||
|
||||
import AlertPanel from '@/components/AlertPanel';
|
||||
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[] }) {
|
||||
const { triggeredAlerts } = useApp();
|
||||
|
||||
@@ -2,7 +2,7 @@ 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,
|
||||
const 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),
|
||||
@@ -11,7 +11,7 @@ function hexToHsl(h: string): [number, number, number] {
|
||||
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);
|
||||
let hue = 0;
|
||||
let hue: number;
|
||||
if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
else if (mx === g) hue = ((b - r) / d + 2) * 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);
|
||||
}
|
||||
|
||||
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 raw = lines
|
||||
.slice(1)
|
||||
@@ -108,8 +115,9 @@ for (let i = 0; i < n; i++) {
|
||||
const groups = new Map<number, typeof hsl>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const root = find(i);
|
||||
if (!groups.has(root)) groups.set(root, []);
|
||||
groups.get(root)!.push(hsl[i]);
|
||||
const group = groups.get(root) ?? [];
|
||||
if (!groups.has(root)) groups.set(root, group);
|
||||
group.push(hsl[i]);
|
||||
}
|
||||
|
||||
let fixed = 0;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
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 { fetchAlerts, createAlert, updateAlert, deleteAlert } from '@/lib/api';
|
||||
import { useApp } from '@/lib/context';
|
||||
import { resolveName, resolveKey } from '@/lib/localization';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -121,6 +123,7 @@ function AlertForm({
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
aria-label={submitLabel}
|
||||
onClick={onSubmit}
|
||||
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>
|
||||
{onCancel && (
|
||||
<button
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
@@ -288,12 +296,14 @@ export default function AlertPanel({ open, onClose }: Props) {
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2 shrink-0">
|
||||
<button
|
||||
aria-label="Edit alert"
|
||||
onClick={() => startEdit(a)}
|
||||
className="text-gray-500 hover:text-indigo-400"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete alert"
|
||||
onClick={() => handleDelete(a.id)}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
|
||||
@@ -12,12 +12,14 @@ export function Header({ title, onEdit, onDelete }: HeaderProps) {
|
||||
<span className="text-sm font-medium text-gray-200 truncate">{title}</span>
|
||||
<div className="flex gap-1 ml-2 shrink-0">
|
||||
<button
|
||||
aria-label="Edit chart"
|
||||
onClick={onEdit}
|
||||
className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete chart"
|
||||
onClick={onDelete}
|
||||
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;
|
||||
children: React.ReactNode;
|
||||
/** Ref to the div where the uPlot legend will be mounted */
|
||||
legendContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
legendContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function CardShell({
|
||||
|
||||
@@ -13,12 +13,14 @@ export default function DividerCard({ title, onEdit, onDelete }: Props) {
|
||||
<div className="flex-1 h-px bg-gray-600" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button
|
||||
aria-label="Edit divider"
|
||||
onClick={onEdit}
|
||||
className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete divider"
|
||||
onClick={onDelete}
|
||||
className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import uPlot from 'uplot';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useApp } from '@/lib/context';
|
||||
import { resolveName } from '@/lib/localization';
|
||||
import { getColorMap } from '@/lib/colors';
|
||||
import type { ColorMap } from '@/lib/colors';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { CardShell } from './CardShell';
|
||||
import {
|
||||
makeYScale,
|
||||
@@ -17,8 +14,13 @@ import {
|
||||
} 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';
|
||||
|
||||
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 {
|
||||
config: ChartConfig;
|
||||
@@ -81,7 +83,17 @@ export default function SignalsChart({
|
||||
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 (
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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 type { ColorMap } from '@/lib/colors';
|
||||
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 {
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { CardShell } from './CardShell';
|
||||
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
|
||||
import { formatSI } from '@/lib/formatNumber';
|
||||
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 {
|
||||
config: ChartConfig;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow, UpsRow } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
import UpsChart from './UpsChart';
|
||||
import DividerCard from './DividerCard';
|
||||
import SignalsChart from './SignalsChart';
|
||||
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 {
|
||||
config: ChartConfig;
|
||||
|
||||
@@ -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 { ChartConfig } from '@/lib/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { getItemColor } from '@/lib/colors';
|
||||
import { formatSI } from '@/lib/formatNumber';
|
||||
|
||||
const SEMANTIC_GREEN = '#4ade80';
|
||||
const SEMANTIC_RED = '#f87171';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SignalRow, ChartConfig } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
import type { SignalRow, ChartConfig, TimeMode } from '@/lib/types';
|
||||
|
||||
const MAX_SERIES = 80;
|
||||
|
||||
@@ -29,20 +28,25 @@ export function buildSeriesData(
|
||||
? 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);
|
||||
seriesMap.get(key)?.set(x, val);
|
||||
}
|
||||
}
|
||||
|
||||
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 allXs = [
|
||||
...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 m = seriesMap.get(k)!;
|
||||
return allXs.map((x) => m.get(x)); // undefined = gap
|
||||
const m = seriesMap.get(k);
|
||||
return m ? allXs.map((x) => m.get(x)) : []; // undefined = gap
|
||||
});
|
||||
|
||||
return { keys, allXs, data };
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import { useCallback, useEffect, useRef, type DependencyList } from 'react';
|
||||
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
export type BuildFn = (
|
||||
el: HTMLDivElement,
|
||||
w: number,
|
||||
h: number,
|
||||
legendRef: React.RefObject<HTMLDivElement>,
|
||||
legendRef: React.RefObject<HTMLDivElement | null>,
|
||||
) => uPlot | null;
|
||||
|
||||
/** 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(
|
||||
build: BuildFn,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deps: any[],
|
||||
deps: DependencyList,
|
||||
): {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
legendRef: React.RefObject<HTMLDivElement>;
|
||||
legendRef: React.RefObject<HTMLDivElement | null>;
|
||||
} {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const legendRef = useRef<HTMLDivElement>(null!);
|
||||
const legendRef = useRef<HTMLDivElement>(null);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ChartConfig } from '@/lib/types';
|
||||
|
||||
import { useApp } from '@/lib/context';
|
||||
import { resolveKey } from '@/lib/localization';
|
||||
import type { ChartConfig } from '@/lib/types';
|
||||
|
||||
type DraftChart = Omit<ChartConfig, 'id'>;
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
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 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useApp } from '@/lib/context';
|
||||
import {
|
||||
fetchCharts,
|
||||
createChart,
|
||||
@@ -15,9 +20,7 @@ import {
|
||||
fetchSessions,
|
||||
fetchUps,
|
||||
} from '@/lib/api';
|
||||
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
|
||||
import ChartCard from './ChartCard';
|
||||
import ChartEditor from './ChartEditor';
|
||||
import { useApp } from '@/lib/context';
|
||||
|
||||
const COLS = 6;
|
||||
const ROW_HEIGHT = 80;
|
||||
@@ -211,6 +214,7 @@ export default function Dashboard({ alerts }: Props) {
|
||||
</GridLayout>
|
||||
|
||||
<button
|
||||
aria-label="Create chart"
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useApp } from '@/lib/context';
|
||||
import type { TimeRange, TimeMode } from '@/lib/types';
|
||||
|
||||
import { useApp } from '@/lib/context';
|
||||
|
||||
const RANGES: TimeRange[] = ['30m', '1h', '6h', '24h', '7d', '30d'];
|
||||
|
||||
export default function TimeRangeSelector() {
|
||||
|
||||
62
web/eslint.config.mjs
Normal file
62
web/eslint.config.mjs
Normal 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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { isAuthorized, unauthorized } from './auth';
|
||||
import { getServerLocaleMap } from './localeServer';
|
||||
import { matchKeys } from './localization';
|
||||
|
||||
type RouteContext = { params: Promise<Record<string, string>> };
|
||||
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++})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
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 { buildReverseMap } from './localization';
|
||||
import { TIME_RANGE_MS } from './types';
|
||||
|
||||
import type { LocaleMap, ReverseMap } from './localization';
|
||||
import type { TimeRange, TimeMode, TriggeredAlert } from './types';
|
||||
|
||||
interface AppContextValue {
|
||||
timeRange: TimeRange;
|
||||
@@ -22,14 +24,12 @@ interface AppContextValue {
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
export function AppProvider({
|
||||
token: _token,
|
||||
localeMap,
|
||||
children,
|
||||
}: {
|
||||
token: string;
|
||||
localeMap: LocaleMap;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
} & { token?: string }) {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('6h');
|
||||
const [timeMode, setTimeMode] = useState<TimeMode>('real');
|
||||
const [triggeredAlerts, setTriggeredAlerts] = useState<TriggeredAlert[]>([]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __pgPool: Pool | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { parseCsv } from './localization';
|
||||
|
||||
import type { LocaleMap } from './localization';
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pool from '@/lib/db';
|
||||
import type { SessionBoundary } from '@/lib/types';
|
||||
|
||||
import pool from '@/lib/db';
|
||||
|
||||
/**
|
||||
* Returns session-start timestamps where any gap > 30 min exists
|
||||
* in the global tick_timing timeline.
|
||||
|
||||
@@ -74,7 +74,7 @@ exports.up = (pgm) => {
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
|
||||
|
||||
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 $$`);
|
||||
|
||||
pgm.sql(`DO $$ BEGIN
|
||||
|
||||
1823
web/package-lock.json
generated
1823
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "echo 'no lint'",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"migrate": "node-pg-migrate up -m migrations",
|
||||
@@ -24,14 +24,20 @@
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@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",
|
||||
"postcss": "^8.5.14",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user