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 { 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;
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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'>;
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
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 { 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++})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
1823
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user