Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5ded77d5 | ||
|
|
4b05f2968e | ||
|
|
cf9bb33ecb |
6
web/.prettierignore
Normal file
6
web/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
charts
|
||||||
|
public/*.csv
|
||||||
7
web/.prettierrc
Normal file
7
web/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
# This is NOT the Next.js you know
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ DATABASE_URL=postgresql://factorio:factorio@localhost:5432/factorio npm run migr
|
|||||||
|
|
||||||
# 4. Start dev server
|
# 4. Start dev server
|
||||||
cp .env.local.example .env.local # fill in API_TOKEN
|
cp .env.local.example .env.local # fill in API_TOKEN
|
||||||
npm run dev
|
npm run dev
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
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;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active } = body;
|
const { item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active } =
|
||||||
|
body;
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE alerts SET
|
`UPDATE alerts SET
|
||||||
@@ -29,4 +31,4 @@ export const DELETE = withAuth(async (_req: NextRequest, { params }) => {
|
|||||||
const result = await pool.query('DELETE FROM alerts WHERE id = $1', [id]);
|
const result = await pool.query('DELETE FROM alerts WHERE id = $1', [id]);
|
||||||
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>(
|
||||||
@@ -13,7 +15,10 @@ export const GET = withAuth(async () => {
|
|||||||
if (alertsResult.rows.length === 0) return NextResponse.json([]);
|
if (alertsResult.rows.length === 0) return NextResponse.json([]);
|
||||||
|
|
||||||
const latestResult = await pool.query<{
|
const latestResult = await pool.query<{
|
||||||
combinator: string; item_key: string; green: number; red: number;
|
combinator: string;
|
||||||
|
item_key: string;
|
||||||
|
green: number;
|
||||||
|
red: number;
|
||||||
}>(
|
}>(
|
||||||
`SELECT DISTINCT ON (combinator, item_key) combinator, item_key, green, red
|
`SELECT DISTINCT ON (combinator, item_key) combinator, item_key, green, red
|
||||||
FROM signals
|
FROM signals
|
||||||
@@ -22,9 +27,7 @@ export const GET = withAuth(async () => {
|
|||||||
|
|
||||||
const localeMap = getServerLocaleMap();
|
const localeMap = getServerLocaleMap();
|
||||||
|
|
||||||
const latestMap = new Map(
|
const latestMap = new Map(latestResult.rows.map((r) => [`${r.combinator}::${r.item_key}`, r]));
|
||||||
latestResult.rows.map(r => [`${r.combinator}::${r.item_key}`, r]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const triggered: TriggeredAlert[] = [];
|
const triggered: TriggeredAlert[] = [];
|
||||||
|
|
||||||
@@ -49,14 +52,15 @@ export const GET = withAuth(async () => {
|
|||||||
|
|
||||||
const value = alert.signal_type === 'green' ? vals.green : vals.red;
|
const value = alert.signal_type === 'green' ? vals.green : vals.red;
|
||||||
const fired = alert.condition === 'above' ? value > alert.threshold : value < alert.threshold;
|
const fired = alert.condition === 'above' ? value > alert.threshold : value < alert.threshold;
|
||||||
if (fired) triggered.push({
|
if (fired)
|
||||||
...alert,
|
triggered.push({
|
||||||
current_value: value,
|
...alert,
|
||||||
combinator_match: combinator,
|
current_value: value,
|
||||||
matched_item_key: item_key,
|
combinator_match: combinator,
|
||||||
});
|
matched_item_key: item_key,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(triggered);
|
return NextResponse.json(triggered);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -10,15 +11,16 @@ export const GET = withAuth(async () => {
|
|||||||
export const POST = withAuth(async (req: NextRequest) => {
|
export const POST = withAuth(async (req: NextRequest) => {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const {
|
const {
|
||||||
item_key, item_key_is_regex = false,
|
item_key,
|
||||||
combinator = null, signal_type = 'green', condition, threshold,
|
item_key_is_regex = false,
|
||||||
|
combinator = null,
|
||||||
|
signal_type = 'green',
|
||||||
|
condition,
|
||||||
|
threshold,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!item_key || !condition || threshold === undefined) {
|
if (!item_key || !condition || threshold === undefined) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'item_key, condition, threshold required' }, { status: 400 });
|
||||||
{ error: 'item_key, condition, threshold required' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -27,4 +29,4 @@ export const POST = withAuth(async (req: NextRequest) => {
|
|||||||
[item_key, item_key_is_regex, combinator, signal_type, condition, threshold],
|
[item_key, item_key_is_regex, combinator, signal_type, condition, threshold],
|
||||||
);
|
);
|
||||||
return NextResponse.json(result.rows[0], { status: 201 });
|
return NextResponse.json(result.rows[0], { status: 201 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
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;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const {
|
const {
|
||||||
title, pos_x, pos_y, width, height,
|
title,
|
||||||
signal_type, chart_type, viz_type,
|
pos_x,
|
||||||
filter_items_regex, y_scale,
|
pos_y,
|
||||||
series_limit, order_by,
|
width,
|
||||||
|
height,
|
||||||
|
signal_type,
|
||||||
|
chart_type,
|
||||||
|
viz_type,
|
||||||
|
filter_items_regex,
|
||||||
|
y_scale,
|
||||||
|
series_limit,
|
||||||
|
order_by,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const hasFilterCombinators = 'filter_combinators' in body;
|
const hasFilterCombinators = 'filter_combinators' in body;
|
||||||
const hasFilterItems = 'filter_items' in body;
|
const hasFilterItems = 'filter_items' in body;
|
||||||
const hasFilterItemsExclude = 'filter_items_exclude' in body;
|
const hasFilterItemsExclude = 'filter_items_exclude' in body;
|
||||||
const hasYMin = 'y_min' in body;
|
const hasYMin = 'y_min' in body;
|
||||||
const hasYMax = 'y_max' in body;
|
const hasYMax = 'y_max' in body;
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE charts SET
|
`UPDATE charts SET
|
||||||
@@ -40,15 +49,28 @@ export const PUT = withAuth(async (req: NextRequest, { params }) => {
|
|||||||
WHERE id = $23
|
WHERE id = $23
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
title, pos_x, pos_y, width, height, signal_type, chart_type, viz_type,
|
title,
|
||||||
hasFilterCombinators, body.filter_combinators ?? null,
|
pos_x,
|
||||||
hasFilterItems, body.filter_items ?? null,
|
pos_y,
|
||||||
hasFilterItemsExclude, body.filter_items_exclude ?? null,
|
width,
|
||||||
|
height,
|
||||||
|
signal_type,
|
||||||
|
chart_type,
|
||||||
|
viz_type,
|
||||||
|
hasFilterCombinators,
|
||||||
|
body.filter_combinators ?? null,
|
||||||
|
hasFilterItems,
|
||||||
|
body.filter_items ?? null,
|
||||||
|
hasFilterItemsExclude,
|
||||||
|
body.filter_items_exclude ?? null,
|
||||||
filter_items_regex,
|
filter_items_regex,
|
||||||
hasYMin, body.y_min ?? null,
|
hasYMin,
|
||||||
hasYMax, body.y_max ?? null,
|
body.y_min ?? null,
|
||||||
|
hasYMax,
|
||||||
|
body.y_max ?? null,
|
||||||
y_scale,
|
y_scale,
|
||||||
series_limit, order_by,
|
series_limit,
|
||||||
|
order_by,
|
||||||
id,
|
id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -61,4 +83,4 @@ export const DELETE = withAuth(async (_req: NextRequest, { params }) => {
|
|||||||
const result = await pool.query('DELETE FROM charts WHERE id = $1', [id]);
|
const result = await pool.query('DELETE FROM charts WHERE id = $1', [id]);
|
||||||
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
if (result.rowCount === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -11,12 +12,22 @@ export const POST = withAuth(async (req: NextRequest) => {
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
pos_x = 0, pos_y = 0, width = 2, height = 4,
|
pos_x = 0,
|
||||||
signal_type = 'both', chart_type = 'signals', viz_type = 'line',
|
pos_y = 0,
|
||||||
filter_combinators = null, filter_items = null,
|
width = 2,
|
||||||
filter_items_exclude = null, filter_items_regex = false,
|
height = 4,
|
||||||
y_min = null, y_max = null, y_scale = 'linear',
|
signal_type = 'both',
|
||||||
series_limit = 20, order_by = 'value_asc',
|
chart_type = 'signals',
|
||||||
|
viz_type = 'line',
|
||||||
|
filter_combinators = null,
|
||||||
|
filter_items = null,
|
||||||
|
filter_items_exclude = null,
|
||||||
|
filter_items_regex = false,
|
||||||
|
y_min = null,
|
||||||
|
y_max = null,
|
||||||
|
y_scale = 'linear',
|
||||||
|
series_limit = 20,
|
||||||
|
order_by = 'value_asc',
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
|
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
|
||||||
@@ -28,9 +39,25 @@ export const POST = withAuth(async (req: NextRequest) => {
|
|||||||
y_min,y_max,y_scale,series_limit,order_by)
|
y_min,y_max,y_scale,series_limit,order_by)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[title,pos_x,pos_y,width,height,signal_type,chart_type,viz_type,
|
[
|
||||||
filter_combinators,filter_items,filter_items_exclude,filter_items_regex,
|
title,
|
||||||
y_min,y_max,y_scale,series_limit,order_by],
|
pos_x,
|
||||||
|
pos_y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
signal_type,
|
||||||
|
chart_type,
|
||||||
|
viz_type,
|
||||||
|
filter_combinators,
|
||||||
|
filter_items,
|
||||||
|
filter_items_exclude,
|
||||||
|
filter_items_regex,
|
||||||
|
y_min,
|
||||||
|
y_max,
|
||||||
|
y_scale,
|
||||||
|
series_limit,
|
||||||
|
order_by,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
return NextResponse.json(result.rows[0], { status: 201 });
|
return NextResponse.json(result.rows[0], { status: 201 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +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';
|
||||||
|
|
||||||
interface CircuitNetwork {
|
interface CircuitNetwork {
|
||||||
green: Record<string, number>;
|
green: Record<string, number>;
|
||||||
red: Record<string, number>;
|
red: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IngestBody {
|
interface IngestBody {
|
||||||
game_tick: number;
|
game_tick: number;
|
||||||
circuit_network: CircuitNetwork;
|
circuit_network: CircuitNetwork;
|
||||||
logistic_network: Record<string, number>;
|
logistic_network: Record<string, number>;
|
||||||
}
|
}
|
||||||
@@ -31,10 +32,10 @@ export const POST = withAuth(async (req: NextRequest, { params }) => {
|
|||||||
return NextResponse.json({ error: 'Missing game_tick' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing game_tick' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const green = circuit_network?.green ?? {};
|
const green = circuit_network?.green ?? {};
|
||||||
const red = circuit_network?.red ?? {};
|
const red = circuit_network?.red ?? {};
|
||||||
const logistic = logistic_network ?? {};
|
const logistic = logistic_network ?? {};
|
||||||
const allKeys = new Set([...Object.keys(green), ...Object.keys(red), ...Object.keys(logistic)]);
|
const allKeys = new Set([...Object.keys(green), ...Object.keys(red), ...Object.keys(logistic)]);
|
||||||
|
|
||||||
if (allKeys.size === 0) return NextResponse.json({ ok: true, rows: 0 });
|
if (allKeys.size === 0) return NextResponse.json({ ok: true, rows: 0 });
|
||||||
|
|
||||||
@@ -47,7 +48,15 @@ export const POST = withAuth(async (req: NextRequest, { params }) => {
|
|||||||
let idx = 1;
|
let idx = 1;
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
placeholders.push(`($${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++})`);
|
placeholders.push(`($${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++})`);
|
||||||
values.push(realTime, game_tick, combinator, key, green[key] ?? 0, red[key] ?? 0, logistic[key] ?? null);
|
values.push(
|
||||||
|
realTime,
|
||||||
|
game_tick,
|
||||||
|
combinator,
|
||||||
|
key,
|
||||||
|
green[key] ?? 0,
|
||||||
|
red[key] ?? 0,
|
||||||
|
logistic[key] ?? null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -68,4 +77,4 @@ export const POST = withAuth(async (req: NextRequest, { params }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,122 +1,72 @@
|
|||||||
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;
|
||||||
const combinators = p.getAll('combinator');
|
const combinators = p.getAll('combinator');
|
||||||
const itemsWhitelist = p.getAll('item');
|
const itemsWhitelist = p.getAll('item');
|
||||||
const itemsBlacklist = p.getAll('exclude');
|
const itemsBlacklist = p.getAll('exclude');
|
||||||
const signalType = p.get('signal') ?? 'both';
|
const signalType = p.get('signal') ?? 'both';
|
||||||
const from = p.get('from');
|
const from = p.get('from');
|
||||||
const to = p.get('to');
|
const to = p.get('to');
|
||||||
const useRegex = p.get('regex') === 'true';
|
const useRegex = p.get('regex') === 'true';
|
||||||
const orderBy = p.get('order_by') ?? 'value_asc';
|
const orderBy = p.get('order_by') ?? 'value_asc';
|
||||||
const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null;
|
const 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) { conditions.push(`real_time >= $${i++}`); values.push(new Date(from)); }
|
if (from) {
|
||||||
if (to) { conditions.push(`real_time <= $${i++}`); values.push(new Date(to)); }
|
conditions.push(`real_time >= $${param.current++}`);
|
||||||
|
values.push(new Date(from));
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
conditions.push(`real_time <= $${param.current++}`);
|
||||||
|
values.push(new Date(to));
|
||||||
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
const valueCol = signalType === 'red' ? 'red' : 'green';
|
const valueCol = signalType === 'red' ? 'red' : 'green';
|
||||||
const selectCols =
|
const selectCols =
|
||||||
signalType === 'green' ? 'real_time, game_tick, combinator, item_key, green'
|
signalType === 'green'
|
||||||
: signalType === 'red' ? 'real_time, game_tick, combinator, item_key, red'
|
? 'real_time, game_tick, combinator, item_key, green'
|
||||||
: 'real_time, game_tick, combinator, item_key, green, red';
|
: signalType === 'red'
|
||||||
|
? 'real_time, game_tick, combinator, item_key, red'
|
||||||
|
: 'real_time, game_tick, combinator, item_key, green, red';
|
||||||
|
|
||||||
if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) {
|
if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) {
|
||||||
const baseConditions: string[] = [];
|
const baseConditions: string[] = [];
|
||||||
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 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 baseWhere = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : '';
|
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 ')}` : '';
|
const baseWhereAnd = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
const deltaQuery = `
|
const deltaQuery = `
|
||||||
@@ -147,33 +97,45 @@ 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((_, idx) =>
|
const seriesConditions = top.map(
|
||||||
`(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.map((_, idx) =>
|
const orderCase = top
|
||||||
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`,
|
.map(
|
||||||
).join(' ');
|
(_, idx) =>
|
||||||
|
`WHEN combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1} THEN ${idx}`,
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
|
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
|
||||||
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
|
[...values, ...top.flatMap((r) => [r.combinator, r.item_key])],
|
||||||
);
|
);
|
||||||
return NextResponse.json(result.rows);
|
return NextResponse.json(result.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') && limit !== null) {
|
if (
|
||||||
const latestVals = await pool.query<{ combinator: string; item_key: string; val: number }>(
|
(orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') &&
|
||||||
|
limit !== null
|
||||||
|
) {
|
||||||
|
const latestVals = await pool.query<{
|
||||||
|
combinator: string;
|
||||||
|
item_key: string;
|
||||||
|
val: number;
|
||||||
|
}>(
|
||||||
`SELECT DISTINCT ON (combinator, item_key)
|
`SELECT DISTINCT ON (combinator, item_key)
|
||||||
combinator, item_key, ${valueCol} AS val
|
combinator, item_key, ${valueCol} AS val
|
||||||
FROM signals ${where}
|
FROM signals ${where}
|
||||||
@@ -182,23 +144,28 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let sorted = latestVals.rows;
|
let sorted = latestVals.rows;
|
||||||
if (orderBy === 'value_asc') sorted = [...sorted].sort((a, b) => a.val - b.val);
|
if (orderBy === 'value_asc') sorted = [...sorted].sort((a, b) => a.val - b.val);
|
||||||
if (orderBy === 'value_desc') sorted = [...sorted].sort((a, b) => b.val - a.val);
|
if (orderBy === 'value_desc') sorted = [...sorted].sort((a, b) => b.val - a.val);
|
||||||
if (orderBy === 'abs_desc') sorted = [...sorted].sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
|
if (orderBy === 'abs_desc')
|
||||||
|
sorted = [...sorted].sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
|
||||||
|
|
||||||
const top = sorted.slice(0, limit);
|
const top = sorted.slice(0, limit);
|
||||||
if (top.length === 0) return NextResponse.json([]);
|
if (top.length === 0) return NextResponse.json([]);
|
||||||
|
|
||||||
const seriesConditions = top.map((_, idx) =>
|
const seriesConditions = top.map(
|
||||||
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
|
(_, idx) =>
|
||||||
|
`(combinator = $${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.map((_, idx) =>
|
const orderCase = top
|
||||||
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`,
|
.map(
|
||||||
).join(' ');
|
(_, idx) =>
|
||||||
|
`WHEN combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1} THEN ${idx}`,
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
|
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
|
||||||
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
|
[...values, ...top.flatMap((r) => [r.combinator, r.item_key])],
|
||||||
);
|
);
|
||||||
return NextResponse.json(result.rows);
|
return NextResponse.json(result.rows);
|
||||||
}
|
}
|
||||||
@@ -209,4 +176,4 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
return NextResponse.json(result.rows);
|
return NextResponse.json(result.rows);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -17,7 +20,9 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query<{
|
const result = await pool.query<{
|
||||||
real_time: Date; game_tick: string; combinator: string;
|
real_time: Date;
|
||||||
|
game_tick: string;
|
||||||
|
combinator: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT real_time, game_tick, combinator
|
`SELECT real_time, game_tick, combinator
|
||||||
FROM tick_timing
|
FROM tick_timing
|
||||||
@@ -43,17 +48,17 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
const prev = combiRows[i - 1];
|
const prev = combiRows[i - 1];
|
||||||
const curr = combiRows[i];
|
const curr = combiRows[i];
|
||||||
const deltaRealMs = curr.real_time.getTime() - prev.real_time.getTime();
|
const deltaRealMs = curr.real_time.getTime() - prev.real_time.getTime();
|
||||||
const deltaTicks = parseInt(curr.game_tick, 10) - parseInt(prev.game_tick, 10);
|
const deltaTicks = parseInt(curr.game_tick, 10) - parseInt(prev.game_tick, 10);
|
||||||
// Skip session gaps and bad data
|
// Skip session gaps and bad data
|
||||||
if (deltaRealMs > 30 * 60 * 1000 || deltaRealMs <= 0 || deltaTicks <= 0) continue;
|
if (deltaRealMs > 30 * 60 * 1000 || deltaRealMs <= 0 || deltaTicks <= 0) continue;
|
||||||
points.push({
|
points.push({
|
||||||
real_time: curr.real_time.toISOString(),
|
real_time: curr.real_time.toISOString(),
|
||||||
game_tick: parseInt(curr.game_tick, 10),
|
game_tick: parseInt(curr.game_tick, 10),
|
||||||
combinator: combi,
|
combinator: combi,
|
||||||
ups: Math.round((deltaTicks / deltaRealMs) * 1000 * 10) / 10,
|
ups: Math.round((deltaTicks / deltaRealMs) * 1000 * 10) / 10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(points);
|
return NextResponse.json(points);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #111827;
|
--background: #111827;
|
||||||
@@ -15,4 +15,3 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import "./globals.css";
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Factorio Dashboard",
|
title: 'Factorio Dashboard',
|
||||||
description: "Factorio signal monitor",
|
description: 'Factorio signal monitor',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
children,
|
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full antialiased">
|
<html lang="en" className="h-full antialiased">
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -48,8 +50,8 @@ function DashboardApp() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const token = searchParams.get('token') ?? '';
|
const token = searchParams.get('token') ?? '';
|
||||||
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
|
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
|
||||||
const [localeMap, setLocaleMap] = useState<LocaleMap>(new Map());
|
const [localeMap, setLocaleMap] = useState<LocaleMap>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,9 +74,7 @@ function DashboardApp() {
|
|||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center text-gray-400">
|
<div className="flex min-h-screen items-center justify-center text-gray-400">Loading…</div>
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,4 +91,4 @@ export default function Page() {
|
|||||||
<DashboardApp />
|
<DashboardApp />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,53 @@ import { readFileSync, writeFileSync } from 'fs';
|
|||||||
const CSV = 'public/factorio_item_colors.csv';
|
const CSV = 'public/factorio_item_colors.csv';
|
||||||
|
|
||||||
function hexToHsl(h: string): [number, number, number] {
|
function hexToHsl(h: string): [number, number, number] {
|
||||||
let r = parseInt(h.slice(1, 3), 16) / 255, g = parseInt(h.slice(3, 5), 16) / 255, b = parseInt(h.slice(5, 7), 16) / 255;
|
const r = parseInt(h.slice(1, 3), 16) / 255,
|
||||||
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
|
g = parseInt(h.slice(3, 5), 16) / 255,
|
||||||
|
b = parseInt(h.slice(5, 7), 16) / 255;
|
||||||
|
const mx = Math.max(r, g, b),
|
||||||
|
mn = Math.min(r, g, b),
|
||||||
|
l = (mx + mn) / 2;
|
||||||
if (mx === mn) return [0, 0, Math.round(l * 100)];
|
if (mx === mn) return [0, 0, Math.round(l * 100)];
|
||||||
const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
|
const d = mx - mn,
|
||||||
let hue = 0;
|
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
|
||||||
|
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;
|
||||||
return [Math.round(hue), Math.round(s * 100), Math.round(l * 100)];
|
return [Math.round(hue), Math.round(s * 100), Math.round(l * 100)];
|
||||||
}
|
}
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
function hslToHex(h: number, s: number, l: number): string {
|
||||||
s /= 100; l /= 100;
|
s /= 100;
|
||||||
const c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = l - c / 2;
|
l /= 100;
|
||||||
let r = 0, g = 0, b = 0;
|
const c = (1 - Math.abs(2 * l - 1)) * s,
|
||||||
if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; }
|
x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
|
||||||
else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; }
|
m = l - c / 2;
|
||||||
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
|
let r = 0,
|
||||||
|
g = 0,
|
||||||
|
b = 0;
|
||||||
|
if (h < 60) {
|
||||||
|
r = c;
|
||||||
|
g = x;
|
||||||
|
} else if (h < 120) {
|
||||||
|
r = x;
|
||||||
|
g = c;
|
||||||
|
} else if (h < 180) {
|
||||||
|
g = c;
|
||||||
|
b = x;
|
||||||
|
} else if (h < 240) {
|
||||||
|
g = x;
|
||||||
|
b = c;
|
||||||
|
} else if (h < 300) {
|
||||||
|
r = x;
|
||||||
|
b = c;
|
||||||
|
} else {
|
||||||
|
r = c;
|
||||||
|
b = x;
|
||||||
|
}
|
||||||
|
const to = (v: number) =>
|
||||||
|
Math.round((v + m) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0');
|
||||||
return '#' + to(r) + to(g) + to(b);
|
return '#' + to(r) + to(g) + to(b);
|
||||||
}
|
}
|
||||||
function djb2(s: string): number {
|
function djb2(s: string): number {
|
||||||
@@ -31,32 +61,53 @@ 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.slice(1).filter(l => l.trim()).map(l => {
|
const raw = lines
|
||||||
const [k, c] = l.split(',');
|
.slice(1)
|
||||||
return { key: k.trim(), color: c.trim() };
|
.filter((l) => l.trim())
|
||||||
});
|
.map((l) => {
|
||||||
|
const [k, c] = l.split(',');
|
||||||
|
return { key: k.trim(), color: c.trim() };
|
||||||
|
});
|
||||||
|
|
||||||
// Dedup by key
|
// Dedup by key
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const rows: typeof raw = [];
|
const rows: typeof raw = [];
|
||||||
for (const r of raw) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); }
|
for (const r of raw) {
|
||||||
|
if (seen.has(r.key)) continue;
|
||||||
|
seen.add(r.key);
|
||||||
|
rows.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
const n = rows.length;
|
const n = rows.length;
|
||||||
const parent = Array.from({ length: n }, (_, i) => i);
|
const parent = Array.from({ length: n }, (_, i) => i);
|
||||||
function find(x: number): number {
|
function find(x: number): number {
|
||||||
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
|
while (parent[x] !== x) {
|
||||||
|
parent[x] = parent[parent[x]];
|
||||||
|
x = parent[x];
|
||||||
|
}
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
function union(a: number, b: number) { parent[find(a)] = find(b); }
|
function union(a: number, b: number) {
|
||||||
|
parent[find(a)] = find(b);
|
||||||
|
}
|
||||||
|
|
||||||
const hsl = rows.map((r, i) => ({ ...r, hsl: hexToHsl(r.color), idx: i }));
|
const hsl = rows.map((r, i) => ({ ...r, hsl: hexToHsl(r.color), idx: i }));
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
for (let j = i + 1; j < n; j++) {
|
for (let j = i + 1; j < n; j++) {
|
||||||
if (hueDist(hsl[i].hsl[0], hsl[j].hsl[0]) <= 0.5 &&
|
if (
|
||||||
Math.abs(hsl[i].hsl[2] - hsl[j].hsl[2]) <= 0.5)
|
hueDist(hsl[i].hsl[0], hsl[j].hsl[0]) <= 0.5 &&
|
||||||
|
Math.abs(hsl[i].hsl[2] - hsl[j].hsl[2]) <= 0.5
|
||||||
|
)
|
||||||
union(i, j);
|
union(i, j);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,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;
|
||||||
@@ -75,13 +127,13 @@ for (const [, items] of groups) {
|
|||||||
const [oh, os, ol] = items[0].hsl;
|
const [oh, os, ol] = items[0].hsl;
|
||||||
for (let i = 1; i < items.length; i++) {
|
for (let i = 1; i < items.length; i++) {
|
||||||
const hash = djb2(items[i].key);
|
const hash = djb2(items[i].key);
|
||||||
const h = (oh + (hash % 5 - 2) + i * 7 + 360) % 360;
|
const h = (oh + ((hash % 5) - 2) + i * 7 + 360) % 360;
|
||||||
const l = Math.max(0, Math.min(100, ol + ((hash >> 4) % 5 - 2)));
|
const l = Math.max(0, Math.min(100, ol + (((hash >> 4) % 5) - 2)));
|
||||||
items[i].color = hslToHex(h, os, l);
|
items[i].color = hslToHex(h, os, l);
|
||||||
fixed++;
|
fixed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hsl.sort((a, b) => a.idx - b.idx);
|
hsl.sort((a, b) => a.idx - b.idx);
|
||||||
writeFileSync(CSV, header + '\n' + hsl.map(r => `${r.key},${r.color}`).join('\n') + '\n');
|
writeFileSync(CSV, header + '\n' + hsl.map((r) => `${r.key},${r.color}`).join('\n') + '\n');
|
||||||
if (fixed) console.log(`Fixed ${fixed} close colors`);
|
if (fixed) console.log(`Fixed ${fixed} close colors`);
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ name: factorio-signal-exporter
|
|||||||
description: Factorio Signal Exporter — Next.js dashboard with TimescaleDB
|
description: Factorio Signal Exporter — Next.js dashboard with TimescaleDB
|
||||||
type: application
|
type: application
|
||||||
version: 0.0.0-dev
|
version: 0.0.0-dev
|
||||||
appVersion: "latest"
|
appVersion: 'latest'
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ imagePullSecrets: []
|
|||||||
app:
|
app:
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
## API token for ingest POST and dashboard GET ?token=
|
## API token for ingest POST and dashboard GET ?token=
|
||||||
apiToken: ""
|
apiToken: ''
|
||||||
## If set, use this existing K8s secret instead of creating one.
|
## If set, use this existing K8s secret instead of creating one.
|
||||||
## Secret must contain keys: API_TOKEN, DATABASE_URL
|
## Secret must contain keys: API_TOKEN, DATABASE_URL
|
||||||
existingSecret: ""
|
existingSecret: ''
|
||||||
|
|
||||||
db:
|
db:
|
||||||
## TimescaleDB credentials — used to build DATABASE_URL and configure the StatefulSet
|
## TimescaleDB credentials — used to build DATABASE_URL and configure the StatefulSet
|
||||||
@@ -25,7 +25,7 @@ db:
|
|||||||
name: factorio
|
name: factorio
|
||||||
port: 5432
|
port: 5432
|
||||||
storage: 10Gi
|
storage: 10Gi
|
||||||
storageClassName: ""
|
storageClassName: ''
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
@@ -33,14 +33,14 @@ service:
|
|||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
className: ""
|
className: ''
|
||||||
host: factorio.example.com
|
host: factorio.example.com
|
||||||
tls: false
|
tls: false
|
||||||
tlsSecretName: ""
|
tlsSecretName: ''
|
||||||
annotations: {}
|
annotations: {}
|
||||||
|
|
||||||
resources: {}
|
resources: {}
|
||||||
|
|
||||||
nodeSelector: {}
|
nodeSelector: {}
|
||||||
tolerations: []
|
tolerations: []
|
||||||
affinity: {}
|
affinity: {}
|
||||||
|
|||||||
@@ -1,40 +1,51 @@
|
|||||||
'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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
|
const inputCls =
|
||||||
const selectCls = 'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
|
'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
|
||||||
|
const selectCls =
|
||||||
|
'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
|
||||||
|
|
||||||
interface AlertFormState {
|
interface AlertFormState {
|
||||||
itemKey: string;
|
itemKey: string;
|
||||||
itemKeyIsRegex: boolean;
|
itemKeyIsRegex: boolean;
|
||||||
combinator: string;
|
combinator: string;
|
||||||
signalType: 'green' | 'red';
|
signalType: 'green' | 'red';
|
||||||
condition: 'above' | 'below';
|
condition: 'above' | 'below';
|
||||||
threshold: string;
|
threshold: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyForm(): AlertFormState {
|
function emptyForm(): AlertFormState {
|
||||||
return { itemKey: '', itemKeyIsRegex: false, combinator: '', signalType: 'green', condition: 'below', threshold: '0' };
|
return {
|
||||||
|
itemKey: '',
|
||||||
|
itemKeyIsRegex: false,
|
||||||
|
combinator: '',
|
||||||
|
signalType: 'green',
|
||||||
|
condition: 'below',
|
||||||
|
threshold: '0',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function alertToForm(a: AlertConfig): AlertFormState {
|
function alertToForm(a: AlertConfig): AlertFormState {
|
||||||
return {
|
return {
|
||||||
itemKey: a.item_key,
|
itemKey: a.item_key,
|
||||||
itemKeyIsRegex: a.item_key_is_regex,
|
itemKeyIsRegex: a.item_key_is_regex,
|
||||||
combinator: a.combinator ?? '',
|
combinator: a.combinator ?? '',
|
||||||
signalType: a.signal_type,
|
signalType: a.signal_type,
|
||||||
condition: a.condition,
|
condition: a.condition,
|
||||||
threshold: String(a.threshold),
|
threshold: String(a.threshold),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,48 +61,80 @@ function Tooltip({ text }: { text: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AlertForm({
|
function AlertForm({
|
||||||
value, onChange, onSubmit, onCancel, submitLabel,
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitLabel,
|
||||||
}: {
|
}: {
|
||||||
value: AlertFormState;
|
value: AlertFormState;
|
||||||
onChange: (s: AlertFormState) => void;
|
onChange: (s: AlertFormState) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
value={value.itemKey}
|
value={value.itemKey}
|
||||||
onChange={e => onChange({ ...value, itemKey: e.target.value })}
|
onChange={(e) => onChange({ ...value, itemKey: e.target.value })}
|
||||||
placeholder={value.itemKeyIsRegex ? 'iron-.*|Iron Plate' : 'Iron Plate or item-key'}
|
placeholder={value.itemKeyIsRegex ? 'iron-.*|Iron Plate' : 'Iron Plate or item-key'}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||||
<input type="checkbox" checked={value.itemKeyIsRegex}
|
<input
|
||||||
onChange={e => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
|
type="checkbox"
|
||||||
className="accent-indigo-500" />
|
checked={value.itemKeyIsRegex}
|
||||||
|
onChange={(e) => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
|
||||||
|
className="accent-indigo-500"
|
||||||
|
/>
|
||||||
Item key is regex
|
Item key is regex
|
||||||
</label>
|
</label>
|
||||||
<input value={value.combinator} onChange={e => onChange({ ...value, combinator: e.target.value })}
|
<input
|
||||||
placeholder="combinator (empty = all)" className={inputCls} />
|
value={value.combinator}
|
||||||
|
onChange={(e) => onChange({ ...value, combinator: e.target.value })}
|
||||||
|
placeholder="combinator (empty = all)"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={value.signalType} onChange={e => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })} className={selectCls}>
|
<select
|
||||||
|
value={value.signalType}
|
||||||
|
onChange={(e) => onChange({ ...value, signalType: e.target.value as 'green' | 'red' })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
<option value="green">Green</option>
|
<option value="green">Green</option>
|
||||||
<option value="red">Red</option>
|
<option value="red">Red</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={value.condition} onChange={e => onChange({ ...value, condition: e.target.value as 'above' | 'below' })} className={selectCls}>
|
<select
|
||||||
|
value={value.condition}
|
||||||
|
onChange={(e) => onChange({ ...value, condition: e.target.value as 'above' | 'below' })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
<option value="below">Below</option>
|
<option value="below">Below</option>
|
||||||
<option value="above">Above</option>
|
<option value="above">Above</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" value={value.threshold} onChange={e => onChange({ ...value, threshold: e.target.value })}
|
<input
|
||||||
placeholder="threshold" className={inputCls} />
|
type="number"
|
||||||
|
value={value.threshold}
|
||||||
|
onChange={(e) => onChange({ ...value, threshold: e.target.value })}
|
||||||
|
placeholder="threshold"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={onSubmit} className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5">
|
<button
|
||||||
|
aria-label={submitLabel}
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded py-1.5"
|
||||||
|
>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700">
|
<button
|
||||||
|
aria-label="Cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -102,10 +145,10 @@ function AlertForm({
|
|||||||
|
|
||||||
export default function AlertPanel({ open, onClose }: Props) {
|
export default function AlertPanel({ open, onClose }: Props) {
|
||||||
const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp();
|
const { triggeredAlerts, refreshAlerts, localeMap, reverseMap } = useApp();
|
||||||
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
|
const [alerts, setAlerts] = useState<AlertConfig[]>([]);
|
||||||
const [newForm, setNewForm] = useState<AlertFormState>(emptyForm());
|
const [newForm, setNewForm] = useState<AlertFormState>(emptyForm());
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editForm, setEditForm] = useState<AlertFormState>(emptyForm());
|
const [editForm, setEditForm] = useState<AlertFormState>(emptyForm());
|
||||||
const prevTriggeredCount = useRef(0);
|
const prevTriggeredCount = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,8 +158,8 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (triggeredAlerts.length > prevTriggeredCount.current) {
|
if (triggeredAlerts.length > prevTriggeredCount.current) {
|
||||||
try {
|
try {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
const osc = ctx.createOscillator();
|
const osc = ctx.createOscillator();
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
osc.connect(gain);
|
osc.connect(gain);
|
||||||
gain.connect(ctx.destination);
|
gain.connect(ctx.destination);
|
||||||
@@ -125,7 +168,9 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
||||||
osc.start();
|
osc.start();
|
||||||
osc.stop(ctx.currentTime + 0.4);
|
osc.stop(ctx.currentTime + 0.4);
|
||||||
} catch { /* AudioContext blocked */ }
|
} catch {
|
||||||
|
/* AudioContext blocked */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevTriggeredCount.current = triggeredAlerts.length;
|
prevTriggeredCount.current = triggeredAlerts.length;
|
||||||
}, [triggeredAlerts.length]);
|
}, [triggeredAlerts.length]);
|
||||||
@@ -139,14 +184,14 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!newForm.itemKey.trim()) return;
|
if (!newForm.itemKey.trim()) return;
|
||||||
const created = await createAlert({
|
const created = await createAlert({
|
||||||
item_key: normalizeItemKey(newForm),
|
item_key: normalizeItemKey(newForm),
|
||||||
item_key_is_regex: newForm.itemKeyIsRegex,
|
item_key_is_regex: newForm.itemKeyIsRegex,
|
||||||
combinator: newForm.combinator.trim() || null,
|
combinator: newForm.combinator.trim() || null,
|
||||||
signal_type: newForm.signalType,
|
signal_type: newForm.signalType,
|
||||||
condition: newForm.condition,
|
condition: newForm.condition,
|
||||||
threshold: parseInt(newForm.threshold, 10),
|
threshold: parseInt(newForm.threshold, 10),
|
||||||
});
|
});
|
||||||
setAlerts(a => [created, ...a]);
|
setAlerts((a) => [created, ...a]);
|
||||||
setNewForm(emptyForm());
|
setNewForm(emptyForm());
|
||||||
await refreshAlerts();
|
await refreshAlerts();
|
||||||
}
|
}
|
||||||
@@ -154,21 +199,21 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
async function handleEdit(id: string) {
|
async function handleEdit(id: string) {
|
||||||
if (!editForm.itemKey.trim()) return;
|
if (!editForm.itemKey.trim()) return;
|
||||||
const updated = await updateAlert(id, {
|
const updated = await updateAlert(id, {
|
||||||
item_key: normalizeItemKey(editForm),
|
item_key: normalizeItemKey(editForm),
|
||||||
item_key_is_regex: editForm.itemKeyIsRegex,
|
item_key_is_regex: editForm.itemKeyIsRegex,
|
||||||
combinator: editForm.combinator.trim() || null,
|
combinator: editForm.combinator.trim() || null,
|
||||||
signal_type: editForm.signalType,
|
signal_type: editForm.signalType,
|
||||||
condition: editForm.condition,
|
condition: editForm.condition,
|
||||||
threshold: parseInt(editForm.threshold, 10),
|
threshold: parseInt(editForm.threshold, 10),
|
||||||
});
|
});
|
||||||
setAlerts(a => a.map(x => x.id === id ? updated : x));
|
setAlerts((a) => a.map((x) => (x.id === id ? updated : x)));
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
await refreshAlerts();
|
await refreshAlerts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
await deleteAlert(id);
|
await deleteAlert(id);
|
||||||
setAlerts(a => a.filter(x => x.id !== id));
|
setAlerts((a) => a.filter((x) => x.id !== id));
|
||||||
await refreshAlerts();
|
await refreshAlerts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,21 +223,33 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed top-0 right-0 h-full w-80 bg-gray-900 border-l border-gray-700 shadow-xl z-40 transform transition-transform duration-200 ${open ? 'translate-x-0' : 'translate-x-full'}`}>
|
<div
|
||||||
|
className={`fixed top-0 right-0 h-full w-80 bg-gray-900 border-l border-gray-700 shadow-xl z-40 transform transition-transform duration-200 ${open ? 'translate-x-0' : 'translate-x-full'}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||||
<span className="font-semibold text-white">Alerts</span>
|
<span className="font-semibold text-white">Alerts</span>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-white">✕</button>
|
<button
|
||||||
|
aria-label="Close alerts panel"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{triggeredAlerts.length > 0 && (
|
{triggeredAlerts.length > 0 && (
|
||||||
<div className="px-4 py-2 bg-red-900/40 border-b border-red-800">
|
<div className="px-4 py-2 bg-red-900/40 border-b border-red-800">
|
||||||
<p className="text-red-300 text-xs font-semibold mb-1">🔴 TRIGGERED ({triggeredAlerts.length})</p>
|
<p className="text-red-300 text-xs font-semibold mb-1">
|
||||||
|
🔴 TRIGGERED ({triggeredAlerts.length})
|
||||||
|
</p>
|
||||||
{triggeredAlerts.map((a, i) => (
|
{triggeredAlerts.map((a, i) => (
|
||||||
<div key={i} className="text-xs text-red-200 flex items-center gap-1 flex-wrap">
|
<div key={i} className="text-xs text-red-200 flex items-center gap-1 flex-wrap">
|
||||||
<span>{resolveName(a.matched_item_key, localeMap)}</span>
|
<span>{resolveName(a.matched_item_key, localeMap)}</span>
|
||||||
<span className="text-red-400">({a.combinator_match})</span>
|
<span className="text-red-400">({a.combinator_match})</span>
|
||||||
<span>[{a.signal_type}]</span>
|
<span>[{a.signal_type}]</span>
|
||||||
<span>= {a.current_value} {a.condition} {a.threshold}</span>
|
<span>
|
||||||
|
= {a.current_value} {a.condition} {a.threshold}
|
||||||
|
</span>
|
||||||
{a.item_key_is_regex && a.matched_item_key !== a.item_key && (
|
{a.item_key_is_regex && a.matched_item_key !== a.item_key && (
|
||||||
<Tooltip text={`Matched by regex: /${a.item_key}/`} />
|
<Tooltip text={`Matched by regex: /${a.item_key}/`} />
|
||||||
)}
|
)}
|
||||||
@@ -203,12 +260,17 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
|
|
||||||
<div className="p-4 border-b border-gray-700 space-y-2">
|
<div className="p-4 border-b border-gray-700 space-y-2">
|
||||||
<p className="text-xs text-gray-400 font-semibold uppercase">New Alert</p>
|
<p className="text-xs text-gray-400 font-semibold uppercase">New Alert</p>
|
||||||
<AlertForm value={newForm} onChange={setNewForm} onSubmit={handleCreate} submitLabel="Add Alert" />
|
<AlertForm
|
||||||
|
value={newForm}
|
||||||
|
onChange={setNewForm}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
submitLabel="Add Alert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 p-4 space-y-2">
|
<div className="overflow-y-auto flex-1 p-4 space-y-2">
|
||||||
{alerts.length === 0 && <p className="text-gray-500 text-sm">No alerts configured.</p>}
|
{alerts.length === 0 && <p className="text-gray-500 text-sm">No alerts configured.</p>}
|
||||||
{alerts.map(a => (
|
{alerts.map((a) => (
|
||||||
<div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300">
|
<div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300">
|
||||||
{editingId === a.id ? (
|
{editingId === a.id ? (
|
||||||
<AlertForm
|
<AlertForm
|
||||||
@@ -221,18 +283,32 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-white">{resolveName(a.item_key, localeMap)}</span>
|
<span className="font-medium text-white">
|
||||||
{a.item_key_is_regex && (
|
{resolveName(a.item_key, localeMap)}
|
||||||
<Tooltip text={`Regex pattern: /${a.item_key}/`} />
|
</span>
|
||||||
)}
|
{a.item_key_is_regex && <Tooltip text={`Regex pattern: /${a.item_key}/`} />}
|
||||||
{a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>}
|
{a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>}
|
||||||
<br />
|
<br />
|
||||||
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>[{a.signal_type}]</span>
|
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>
|
||||||
{' '}{a.condition} {a.threshold}
|
[{a.signal_type}]
|
||||||
|
</span>{' '}
|
||||||
|
{a.condition} {a.threshold}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 ml-2 shrink-0">
|
<div className="flex gap-1 ml-2 shrink-0">
|
||||||
<button onClick={() => startEdit(a)} className="text-gray-500 hover:text-indigo-400">✏️</button>
|
<button
|
||||||
<button onClick={() => handleDelete(a.id)} className="text-gray-500 hover:text-red-400">✕</button>
|
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"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -241,4 +317,4 @@ export default function AlertPanel({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useRef, useState, useCallback } from 'react';
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,8 +11,20 @@ export function Header({ title, onEdit, onDelete }: HeaderProps) {
|
|||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 shrink-0">
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 shrink-0">
|
||||||
<span className="text-sm font-medium text-gray-200 truncate">{title}</span>
|
<span className="text-sm font-medium text-gray-200 truncate">{title}</span>
|
||||||
<div className="flex gap-1 ml-2 shrink-0">
|
<div className="flex gap-1 ml-2 shrink-0">
|
||||||
<button onClick={onEdit} className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700">✏️</button>
|
<button
|
||||||
<button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
|
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"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -25,64 +37,81 @@ export function EmptyState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CardShellProps extends HeaderProps {
|
interface CardShellProps extends HeaderProps {
|
||||||
empty: boolean;
|
empty: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Ref to the div where the uPlot legend will be mounted */
|
/** Ref to the div where the uPlot legend will be mounted */
|
||||||
legendContainerRef?: React.RefObject<HTMLDivElement>;
|
legendContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) {
|
export function CardShell({
|
||||||
|
title,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
empty,
|
||||||
|
children,
|
||||||
|
legendContainerRef,
|
||||||
|
}: CardShellProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [legendHeight, setLegendHeight] = useState<number | null>(null);
|
const [legendHeight, setLegendHeight] = useState<number | null>(null);
|
||||||
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback(
|
||||||
e.preventDefault();
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
const el = legendContainerRef?.current;
|
e.stopPropagation();
|
||||||
if (!el) return;
|
const el = legendContainerRef?.current;
|
||||||
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
|
if (!el) return;
|
||||||
|
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
|
||||||
|
|
||||||
function onMove(ev: MouseEvent) {
|
function onMove(ev: MouseEvent) {
|
||||||
if (!dragRef.current) return;
|
if (!dragRef.current) return;
|
||||||
const delta = dragRef.current.startY - ev.clientY;
|
const delta = dragRef.current.startY - ev.clientY;
|
||||||
const containerH = containerRef.current?.offsetHeight ?? 400;
|
const containerH = containerRef.current?.offsetHeight ?? 400;
|
||||||
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
|
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
|
||||||
setLegendHeight(newH);
|
setLegendHeight(newH);
|
||||||
}
|
}
|
||||||
function onUp() {
|
function onUp() {
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
}
|
}
|
||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
}, [legendContainerRef]);
|
},
|
||||||
|
[legendContainerRef],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden"
|
||||||
|
>
|
||||||
<Header title={title} onEdit={onEdit} onDelete={onDelete} />
|
<Header title={title} onEdit={onEdit} onDelete={onDelete} />
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">{children}</div>
|
||||||
{children}
|
{legendContainerRef && (
|
||||||
</div>
|
<>
|
||||||
{legendContainerRef && <>
|
<div
|
||||||
<div
|
onMouseDown={handleMouseDown}
|
||||||
onMouseDown={handleMouseDown}
|
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
|
||||||
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
ref={legendContainerRef}
|
||||||
ref={legendContainerRef}
|
className="shrink-0 overflow-y-auto px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
|
||||||
className="shrink-0 overflow-y-auto px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
|
style={
|
||||||
style={legendHeight != null ? { height: legendHeight, maxHeight: legendHeight } : { maxHeight: '25%' }}
|
legendHeight != null
|
||||||
/>
|
? { height: legendHeight, maxHeight: legendHeight }
|
||||||
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
|
: { maxHeight: '25%' }
|
||||||
</>}
|
}
|
||||||
|
/>
|
||||||
|
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DividerCard({ title, onEdit, onDelete }: Props) {
|
export default function DividerCard({ title, onEdit, onDelete }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center bg-gray-900/30 rounded border border-gray-600/40 px-4 gap-4 overflow-hidden">
|
<div className="h-full flex items-center bg-gray-900/30 rounded border border-gray-600/40 px-4 gap-4 overflow-hidden">
|
||||||
<span className="text-sm font-bold text-gray-200 uppercase tracking-widest shrink-0">{title}</span>
|
<span className="text-sm font-bold text-gray-200 uppercase tracking-widest shrink-0">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
<div className="flex-1 h-px bg-gray-600" />
|
<div className="flex-1 h-px bg-gray-600" />
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
<button onClick={onEdit} className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700">✏️</button>
|
<button
|
||||||
<button onClick={onDelete} className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
|
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"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,52 @@
|
|||||||
'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 { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
|
import {
|
||||||
|
makeYScale,
|
||||||
|
makeAnnotationHooks,
|
||||||
|
makeSignalsSeries,
|
||||||
|
makeSignalsAxes,
|
||||||
|
CURSOR_NO_DRAG,
|
||||||
|
} from './plotHelpers';
|
||||||
import { buildSeriesData } from './seriesData';
|
import { buildSeriesData } from './seriesData';
|
||||||
import { usePlot } from './usePlot';
|
import { usePlot } from './usePlot';
|
||||||
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types';
|
|
||||||
import type { 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;
|
||||||
rows: SignalRow[];
|
rows: SignalRow[];
|
||||||
sessions: SessionBoundary[];
|
sessions: SessionBoundary[];
|
||||||
alerts: AlertConfig[];
|
alerts: AlertConfig[];
|
||||||
timeMode: TimeMode;
|
timeMode: TimeMode;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
|
export default function SignalsChart({
|
||||||
|
config,
|
||||||
|
rows,
|
||||||
|
sessions,
|
||||||
|
alerts,
|
||||||
|
timeMode,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: Props) {
|
||||||
const { localeMap } = useApp();
|
const { localeMap } = useApp();
|
||||||
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
|
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
|
||||||
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
||||||
useEffect(() => { getColorMap().then(setColorMap); }, []);
|
useEffect(() => {
|
||||||
|
getColorMap().then(setColorMap);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { containerRef, legendRef } = usePlot(
|
const { containerRef, legendRef } = usePlot(
|
||||||
(el, w, h, lRef) => {
|
(el, w, h, lRef) => {
|
||||||
@@ -36,35 +54,57 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode,
|
|||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const { keys, allXs, data: seriesData } = data;
|
const { keys, allXs, data: seriesData } = data;
|
||||||
const sessionXs = sessions.map(s => timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000);
|
const sessionXs = sessions.map((s) =>
|
||||||
|
timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000,
|
||||||
|
);
|
||||||
const alertThresholds = alerts
|
const alertThresholds = alerts
|
||||||
.filter(a => config.signal_type === 'both' || config.signal_type === a.signal_type)
|
.filter((a) => config.signal_type === 'both' || config.signal_type === a.signal_type)
|
||||||
.map(a => a.threshold);
|
.map((a) => a.threshold);
|
||||||
|
|
||||||
return new uPlot({
|
return new uPlot(
|
||||||
width: w,
|
{
|
||||||
height: h,
|
width: w,
|
||||||
cursor: CURSOR_NO_DRAG,
|
height: h,
|
||||||
legend: {
|
cursor: CURSOR_NO_DRAG,
|
||||||
mount: (_u, legendEl) => {
|
legend: {
|
||||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
mount: (_u, legendEl) => {
|
||||||
|
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
series: makeSignalsSeries(keys, timeMode, (key) => resolveName(key, localeMap), colorMap),
|
||||||
|
axes: makeSignalsAxes(timeMode, locale),
|
||||||
|
scales: {
|
||||||
|
x: { time: false },
|
||||||
|
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||||
|
},
|
||||||
|
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
|
||||||
},
|
},
|
||||||
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap), colorMap),
|
[allXs, ...seriesData],
|
||||||
axes: makeSignalsAxes(timeMode, locale),
|
el,
|
||||||
scales: {
|
);
|
||||||
x: { time: false },
|
|
||||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
|
||||||
},
|
|
||||||
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
|
|
||||||
}, [allXs, ...seriesData], el);
|
|
||||||
},
|
},
|
||||||
[rows, sessions, alerts, config, timeMode, localeMap],
|
[
|
||||||
|
rows,
|
||||||
|
sessions,
|
||||||
|
alerts,
|
||||||
|
config.signal_type,
|
||||||
|
config.y_min,
|
||||||
|
config.y_max,
|
||||||
|
config.y_scale,
|
||||||
|
timeMode,
|
||||||
|
localeMap,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0} legendContainerRef={legendRef}>
|
<CardShell
|
||||||
|
title={config.title}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
empty={rows.length === 0}
|
||||||
|
legendContainerRef={legendRef}
|
||||||
|
>
|
||||||
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
||||||
</CardShell>
|
</CardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
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[];
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
||||||
const { localeMap } = useApp();
|
const { localeMap } = useApp();
|
||||||
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
||||||
useEffect(() => { getColorMap().then(setColorMap); }, []);
|
useEffect(() => {
|
||||||
|
getColorMap().then(setColorMap);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const latest = new Map<string, { green?: number; red?: number }>();
|
const latest = new Map<string, { green?: number; red?: number }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -33,8 +38,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-2 py-1">Item</th>
|
<th className="text-left px-2 py-1">Item</th>
|
||||||
<th className="text-left px-2 py-1">Combinator</th>
|
<th className="text-left px-2 py-1">Combinator</th>
|
||||||
{config.signal_type !== 'red' && <th className="text-right px-2 py-1 text-green-400">Green</th>}
|
{config.signal_type !== 'red' && (
|
||||||
{config.signal_type !== 'green' && <th className="text-right px-2 py-1 text-red-400">Red (NP)</th>}
|
<th className="text-right px-2 py-1 text-green-400">Green</th>
|
||||||
|
)}
|
||||||
|
{config.signal_type !== 'green' && (
|
||||||
|
<th className="text-right px-2 py-1 text-red-400">Red (NP)</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -43,13 +52,17 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
|||||||
return (
|
return (
|
||||||
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
|
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
|
||||||
<td className="px-2 py-0.5 flex items-center gap-1.5">
|
<td className="px-2 py-0.5 flex items-center gap-1.5">
|
||||||
<span className="inline-block w-2 h-2 rounded-full shrink-0"
|
<span
|
||||||
style={{ backgroundColor: getItemColor(item_key, colorMap) }} />
|
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: getItemColor(item_key, colorMap) }}
|
||||||
|
/>
|
||||||
{resolveName(item_key, localeMap)}
|
{resolveName(item_key, localeMap)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
|
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
|
||||||
{config.signal_type !== 'red' && (
|
{config.signal_type !== 'red' && (
|
||||||
<td className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}>
|
<td
|
||||||
|
className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}
|
||||||
|
>
|
||||||
{vals.green != null ? formatSI(vals.green, undefined, 0) : '--'}
|
{vals.green != null ? formatSI(vals.green, undefined, 0) : '--'}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
@@ -66,4 +79,4 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</CardShell>
|
</CardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,20 @@
|
|||||||
|
|
||||||
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;
|
||||||
upsRows: UpsRow[];
|
upsRows: UpsRow[];
|
||||||
timeMode: TimeMode;
|
timeMode: TimeMode;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,42 +29,59 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
|
|||||||
? a.game_tick - b.game_tick
|
? a.game_tick - b.game_tick
|
||||||
: new Date(a.real_time).getTime() - new Date(b.real_time).getTime(),
|
: new Date(a.real_time).getTime() - new Date(b.real_time).getTime(),
|
||||||
);
|
);
|
||||||
const xs = sorted.map(r => timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000);
|
const xs = sorted.map((r) =>
|
||||||
const ys = sorted.map(r => r.ups);
|
timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000,
|
||||||
|
);
|
||||||
|
const ys = sorted.map((r) => r.ups);
|
||||||
|
|
||||||
const xAxis: uPlot.Axis = {
|
const xAxis: uPlot.Axis = {
|
||||||
...AXIS_BASE,
|
...AXIS_BASE,
|
||||||
values: timeMode === 'real'
|
values:
|
||||||
? (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
|
timeMode === 'real'
|
||||||
: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)),
|
? (_u, vals) =>
|
||||||
|
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
|
||||||
|
: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new uPlot({
|
return new uPlot(
|
||||||
width: w,
|
{
|
||||||
height: h,
|
width: w,
|
||||||
cursor: CURSOR_NO_DRAG,
|
height: h,
|
||||||
legend: {
|
cursor: CURSOR_NO_DRAG,
|
||||||
mount: (_u, legendEl) => {
|
legend: {
|
||||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
mount: (_u, legendEl) => {
|
||||||
|
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
|
||||||
|
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
|
||||||
|
],
|
||||||
|
axes: [
|
||||||
|
xAxis,
|
||||||
|
{ ...AXIS_BASE, values: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))) },
|
||||||
|
],
|
||||||
|
scales: {
|
||||||
|
x: { time: false },
|
||||||
|
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
series: [
|
[xs, ys],
|
||||||
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
|
el,
|
||||||
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
|
);
|
||||||
],
|
|
||||||
axes: [xAxis, { ...AXIS_BASE, values: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)) }],
|
|
||||||
scales: {
|
|
||||||
x: { time: false },
|
|
||||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
|
||||||
},
|
|
||||||
}, [xs, ys], el);
|
|
||||||
},
|
},
|
||||||
[upsRows, config.y_min, config.y_max, config.y_scale, timeMode],
|
[upsRows, config.y_min, config.y_max, config.y_scale, timeMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={upsRows.length === 0} legendContainerRef={legendRef}>
|
<CardShell
|
||||||
|
title={config.title}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
empty={upsRows.length === 0}
|
||||||
|
legendContainerRef={legendRef}
|
||||||
|
>
|
||||||
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
||||||
</CardShell>
|
</CardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
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;
|
||||||
rows: SignalRow[];
|
rows: SignalRow[];
|
||||||
upsRows: UpsRow[];
|
upsRows: UpsRow[];
|
||||||
sessions: SessionBoundary[];
|
sessions: SessionBoundary[];
|
||||||
alerts: AlertConfig[];
|
alerts: AlertConfig[];
|
||||||
timeMode: TimeMode;
|
timeMode: TimeMode;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChartCard(props: ChartCardProps) {
|
export default function ChartCard(props: ChartCardProps) {
|
||||||
const { config } = props;
|
const { config } = props;
|
||||||
if (config.chart_type === 'divider') return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
if (config.chart_type === 'divider')
|
||||||
if (config.chart_type === 'ups') return <UpsChart {...props} />;
|
return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
||||||
if (config.viz_type === 'table') return <TableViz config={props.config} rows={props.rows} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
if (config.chart_type === 'ups') return <UpsChart {...props} />;
|
||||||
return <SignalsChart {...props} />;
|
if (config.viz_type === 'table')
|
||||||
}
|
return (
|
||||||
|
<TableViz
|
||||||
|
config={props.config}
|
||||||
|
rows={props.rows}
|
||||||
|
onEdit={props.onEdit}
|
||||||
|
onDelete={props.onDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return <SignalsChart {...props} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
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';
|
||||||
|
|
||||||
export interface SeriesStyle {
|
export interface SeriesStyle {
|
||||||
color: string;
|
color: string;
|
||||||
dash: number[] | undefined;
|
dash: number[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSeriesStyle(
|
export function getSeriesStyle(
|
||||||
key: string,
|
key: string,
|
||||||
uCombs: number,
|
uCombs: number,
|
||||||
uItems: number,
|
uItems: number,
|
||||||
uSigs: number,
|
uSigs: number,
|
||||||
colorMap: ColorMap = new Map(),
|
colorMap: ColorMap = new Map(),
|
||||||
): SeriesStyle {
|
): SeriesStyle {
|
||||||
const [combinator, item_key, sig] = key.split('::');
|
const [combinator, item_key, sig] = key.split('::');
|
||||||
@@ -25,9 +26,15 @@ export function getSeriesStyle(
|
|||||||
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
|
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
|
||||||
}
|
}
|
||||||
if (uItems > 1) {
|
if (uItems > 1) {
|
||||||
return { color: getItemColor(item_key, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
return {
|
||||||
|
color: getItemColor(item_key, colorMap),
|
||||||
|
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { color: getItemColor(combinator, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
return {
|
||||||
|
color: getItemColor(combinator, colorMap),
|
||||||
|
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,10 +42,10 @@ export function getSeriesStyle(
|
|||||||
* @param displayName Pre-resolved localized name for the item.
|
* @param displayName Pre-resolved localized name for the item.
|
||||||
*/
|
*/
|
||||||
export function getSeriesLabel(
|
export function getSeriesLabel(
|
||||||
key: string,
|
key: string,
|
||||||
uCombs: number,
|
uCombs: number,
|
||||||
uItems: number,
|
uItems: number,
|
||||||
uSigs: number,
|
uSigs: number,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
): string {
|
): string {
|
||||||
const [combinator, , sig] = key.split('::');
|
const [combinator, , sig] = key.split('::');
|
||||||
@@ -46,7 +53,7 @@ export function getSeriesLabel(
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (uItems > 1) parts.push(displayName);
|
if (uItems > 1) parts.push(displayName);
|
||||||
if (uCombs > 1) parts.push(`(${combinator})`);
|
if (uCombs > 1) parts.push(`(${combinator})`);
|
||||||
if (uSigs > 1) parts.push(`[${sig}]`);
|
if (uSigs > 1) parts.push(`[${sig}]`);
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +61,8 @@ export function getSeriesLabel(
|
|||||||
|
|
||||||
export const AXIS_BASE: uPlot.Axis = {
|
export const AXIS_BASE: uPlot.Axis = {
|
||||||
stroke: '#9ca3af',
|
stroke: '#9ca3af',
|
||||||
ticks: { stroke: '#374151' },
|
ticks: { stroke: '#374151' },
|
||||||
grid: { stroke: '#1f2937' },
|
grid: { stroke: '#1f2937' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CURSOR_NO_DRAG: uPlot.Cursor = {
|
export const CURSOR_NO_DRAG: uPlot.Cursor = {
|
||||||
@@ -67,17 +74,19 @@ export const CURSOR_NO_DRAG: uPlot.Cursor = {
|
|||||||
* 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives.
|
* 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives.
|
||||||
*/
|
*/
|
||||||
export function makeYScale(
|
export function makeYScale(
|
||||||
yMin: number | null,
|
yMin: number | null,
|
||||||
yMax: number | null,
|
yMax: number | null,
|
||||||
yScale: ChartConfig['y_scale'] = 'linear',
|
yScale: ChartConfig['y_scale'] = 'linear',
|
||||||
): uPlot.Scale {
|
): uPlot.Scale {
|
||||||
if (yScale === 'log') {
|
if (yScale === 'log') {
|
||||||
return {
|
return {
|
||||||
distr: 4,
|
distr: 4,
|
||||||
asinh: 1,
|
asinh: 1,
|
||||||
...(yMin !== null || yMax !== null ? {
|
...(yMin !== null || yMax !== null
|
||||||
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
|
? {
|
||||||
} : {}),
|
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,53 +94,61 @@ export function makeYScale(
|
|||||||
return {
|
return {
|
||||||
dir: 1,
|
dir: 1,
|
||||||
range: (_u, dataMin, dataMax) => {
|
range: (_u, dataMin, dataMax) => {
|
||||||
const lo = yMin ?? (dataMin ?? 0);
|
const lo = yMin ?? dataMin ?? 0;
|
||||||
const hi = yMax ?? (dataMax ?? 1);
|
const hi = yMax ?? dataMax ?? 1;
|
||||||
if (lo === hi) return [lo - 1, hi + 1];
|
if (lo === hi) return [lo - 1, hi + 1];
|
||||||
const pad = (yMin == null || yMax == null) ? Math.abs(hi - lo) * 0.05 : 0;
|
const pad = yMin == null || yMax == null ? Math.abs(hi - lo) * 0.05 : 0;
|
||||||
return [lo - pad, hi + pad];
|
return [lo - pad, hi + pad];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeAnnotationHooks(
|
export function makeAnnotationHooks(
|
||||||
sessionXs: number[],
|
sessionXs: number[],
|
||||||
alertThresholds: number[],
|
alertThresholds: number[],
|
||||||
): uPlot.Options['hooks'] {
|
): uPlot.Options['hooks'] {
|
||||||
return {
|
return {
|
||||||
draw: [(u) => {
|
draw: [
|
||||||
const { ctx, bbox } = u;
|
(u) => {
|
||||||
ctx.save();
|
const { ctx, bbox } = u;
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(251,191,36,0.6)';
|
ctx.strokeStyle = 'rgba(251,191,36,0.6)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.setLineDash([4, 4]);
|
ctx.setLineDash([4, 4]);
|
||||||
for (const sx of sessionXs) {
|
for (const sx of sessionXs) {
|
||||||
const cx = Math.round(u.valToPos(sx, 'x', true));
|
const cx = Math.round(u.valToPos(sx, 'x', true));
|
||||||
ctx.beginPath(); ctx.moveTo(cx, bbox.top); ctx.lineTo(cx, bbox.top + bbox.height); ctx.stroke();
|
ctx.beginPath();
|
||||||
}
|
ctx.moveTo(cx, bbox.top);
|
||||||
|
ctx.lineTo(cx, bbox.top + bbox.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(248,113,113,0.7)';
|
ctx.strokeStyle = 'rgba(248,113,113,0.7)';
|
||||||
ctx.setLineDash([6, 3]);
|
ctx.setLineDash([6, 3]);
|
||||||
for (const t of alertThresholds) {
|
for (const t of alertThresholds) {
|
||||||
const cy = Math.round(u.valToPos(t, 'y', true));
|
const cy = Math.round(u.valToPos(t, 'y', true));
|
||||||
ctx.beginPath(); ctx.moveTo(bbox.left, cy); ctx.lineTo(bbox.left + bbox.width, cy); ctx.stroke();
|
ctx.beginPath();
|
||||||
}
|
ctx.moveTo(bbox.left, cy);
|
||||||
|
ctx.lineTo(bbox.left + bbox.width, cy);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeSignalsSeries(
|
export function makeSignalsSeries(
|
||||||
keys: string[],
|
keys: string[],
|
||||||
timeMode: 'real' | 'tick',
|
timeMode: 'real' | 'tick',
|
||||||
resolveName: (key: string) => string,
|
resolveName: (key: string) => string,
|
||||||
colorMap: ColorMap = new Map(),
|
colorMap: ColorMap = new Map(),
|
||||||
): uPlot.Series[] {
|
): uPlot.Series[] {
|
||||||
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
|
const uCombs = new Set(keys.map((k) => k.split('::')[0])).size;
|
||||||
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
|
const uItems = new Set(keys.map((k) => k.split('::')[1])).size;
|
||||||
const uSigs = new Set(keys.map(k => k.split('::')[2])).size;
|
const uSigs = new Set(keys.map((k) => k.split('::')[2])).size;
|
||||||
|
|
||||||
const xSeries: uPlot.Series = {
|
const xSeries: uPlot.Series = {
|
||||||
label: timeMode === 'tick' ? 'Tick' : 'Time',
|
label: timeMode === 'tick' ? 'Tick' : 'Time',
|
||||||
@@ -143,13 +160,13 @@ export function makeSignalsSeries(
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
xSeries,
|
xSeries,
|
||||||
...keys.map(k => {
|
...keys.map((k) => {
|
||||||
const [, item_key] = k.split('::');
|
const [, item_key] = k.split('::');
|
||||||
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
|
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
|
||||||
return {
|
return {
|
||||||
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
|
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
|
||||||
stroke: color,
|
stroke: color,
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
dash,
|
dash,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -160,16 +177,17 @@ export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPl
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...AXIS_BASE,
|
...AXIS_BASE,
|
||||||
values: timeMode === 'real'
|
values:
|
||||||
? (_u: uPlot, vals: (number | null)[]) =>
|
timeMode === 'real'
|
||||||
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
|
? (_u: uPlot, vals: (number | null)[]) =>
|
||||||
: (_u: uPlot, vals: (number | null)[]) =>
|
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
|
||||||
vals.map(v => v == null ? '' : formatSI(v, locale)),
|
: (_u: uPlot, vals: (number | null)[]) =>
|
||||||
|
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...AXIS_BASE,
|
...AXIS_BASE,
|
||||||
values: (_u: uPlot, vals: (number | null)[]) =>
|
values: (_u: uPlot, vals: (number | null)[]) =>
|
||||||
vals.map(v => v == null ? '' : formatSI(v, locale)),
|
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,53 @@
|
|||||||
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;
|
||||||
|
|
||||||
export interface SeriesData {
|
export interface SeriesData {
|
||||||
keys: string[];
|
keys: string[];
|
||||||
allXs: number[];
|
allXs: number[];
|
||||||
data: (number | undefined)[][];
|
data: (number | undefined)[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSeriesData(
|
export function buildSeriesData(
|
||||||
rows: SignalRow[],
|
rows: SignalRow[],
|
||||||
signalType: ChartConfig['signal_type'],
|
signalType: ChartConfig['signal_type'],
|
||||||
timeMode: TimeMode,
|
timeMode: TimeMode,
|
||||||
): SeriesData | null {
|
): SeriesData | null {
|
||||||
const seriesMap = new Map<string, Map<number, number>>();
|
const seriesMap = new Map<string, Map<number, number>>();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
for (const [sig, val] of [['green', row.green], ['red', row.red]] as ['green' | 'red', number | undefined][]) {
|
for (const [sig, val] of [
|
||||||
|
['green', row.green],
|
||||||
|
['red', row.red],
|
||||||
|
] as ['green' | 'red', number | undefined][]) {
|
||||||
if (signalType !== 'both' && signalType !== sig) continue;
|
if (signalType !== 'both' && signalType !== sig) continue;
|
||||||
if (val === undefined) continue;
|
if (val === undefined) continue;
|
||||||
const key = `${row.combinator}::${row.item_key}::${sig}`;
|
const key = `${row.combinator}::${row.item_key}::${sig}`;
|
||||||
const x = timeMode === 'tick'
|
const x =
|
||||||
? parseInt(row.game_tick, 10)
|
timeMode === 'tick'
|
||||||
: new Date(row.real_time).getTime() / 1000;
|
? parseInt(row.game_tick, 10)
|
||||||
|
: new Date(row.real_time).getTime() / 1000;
|
||||||
if (!seriesMap.has(key)) seriesMap.set(key, new Map());
|
if (!seriesMap.has(key)) seriesMap.set(key, new Map());
|
||||||
seriesMap.get(key)!.set(x, val);
|
seriesMap.get(key)?.set(x, val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seriesMap.size === 0) return null;
|
if (seriesMap.size === 0) return null;
|
||||||
|
|
||||||
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
|
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
|
||||||
const allXs = [...new Set(keys.flatMap(k => [...seriesMap.get(k)!.keys()]))].sort((a, b) => a - b);
|
const allXs = [
|
||||||
|
...new Set(
|
||||||
|
keys.flatMap((k) => {
|
||||||
|
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,18 +25,21 @@ function idxToPixel(plot: uPlot, idx: number): number {
|
|||||||
*/
|
*/
|
||||||
export function usePlot(
|
export function usePlot(
|
||||||
build: BuildFn,
|
build: BuildFn,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
deps: DependencyList,
|
||||||
deps: any[],
|
): {
|
||||||
): { containerRef: React.RefObject<HTMLDivElement | null>; legendRef: React.RefObject<HTMLDivElement> } {
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
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);
|
||||||
|
|
||||||
const rebuild = useCallback(() => {
|
const rebuild = useCallback(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const w = el.clientWidth, h = el.clientHeight;
|
const w = el.clientWidth,
|
||||||
|
h = el.clientHeight;
|
||||||
if (w < 10 || h < 10) return;
|
if (w < 10 || h < 10) return;
|
||||||
|
|
||||||
plotRef.current?.destroy();
|
plotRef.current?.destroy();
|
||||||
@@ -60,12 +64,16 @@ 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(() => {
|
||||||
rebuild();
|
rebuild();
|
||||||
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
return () => {
|
||||||
|
plotRef.current?.destroy();
|
||||||
|
plotRef.current = null;
|
||||||
|
};
|
||||||
}, [rebuild]);
|
}, [rebuild]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,4 +85,4 @@ export function usePlot(
|
|||||||
}, [rebuild]);
|
}, [rebuild]);
|
||||||
|
|
||||||
return { containerRef, legendRef };
|
return { containerRef, legendRef };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'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'>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initial?: ChartConfig;
|
initial?: ChartConfig;
|
||||||
onSave: (draft: DraftChart) => void;
|
onSave: (draft: DraftChart) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,32 +23,42 @@ const inputCls =
|
|||||||
* to raw item_keys. Unknown tokens are kept as-is.
|
* to raw item_keys. Unknown tokens are kept as-is.
|
||||||
*/
|
*/
|
||||||
function normalizeList(raw: string, reverseMap: Map<string, string>): string[] | null {
|
function normalizeList(raw: string, reverseMap: Map<string, string>): string[] | null {
|
||||||
const arr = raw.split(',').map(x => x.trim()).filter(Boolean);
|
const arr = raw
|
||||||
|
.split(',')
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean);
|
||||||
if (arr.length === 0) return null;
|
if (arr.length === 0) return null;
|
||||||
return arr.map(t => resolveKey(t, reverseMap));
|
return arr.map((t) => resolveKey(t, reverseMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
||||||
const { reverseMap } = useApp();
|
const { reverseMap } = useApp();
|
||||||
|
|
||||||
const [title, setTitle] = useState(initial?.title ?? '');
|
const [title, setTitle] = useState(initial?.title ?? '');
|
||||||
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(initial?.chart_type ?? 'signals');
|
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(
|
||||||
const [vizType, setVizType] = useState<ChartConfig['viz_type']>(initial?.viz_type ?? 'line');
|
initial?.chart_type ?? 'signals',
|
||||||
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(initial?.signal_type ?? 'both');
|
);
|
||||||
|
const [vizType, setVizType] = useState<ChartConfig['viz_type']>(initial?.viz_type ?? 'line');
|
||||||
|
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(
|
||||||
|
initial?.signal_type ?? 'both',
|
||||||
|
);
|
||||||
const [combinators, setCombinators] = useState((initial?.filter_combinators ?? []).join(', '));
|
const [combinators, setCombinators] = useState((initial?.filter_combinators ?? []).join(', '));
|
||||||
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
|
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
|
||||||
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
|
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
|
||||||
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
|
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
|
||||||
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'value_asc');
|
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'value_asc');
|
||||||
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
|
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
|
||||||
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
|
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
|
||||||
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
|
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
|
||||||
const [yScale, setYScale] = useState<ChartConfig['y_scale']>(initial?.y_scale ?? 'linear');
|
const [yScale, setYScale] = useState<ChartConfig['y_scale']>(initial?.y_scale ?? 'linear');
|
||||||
const [width, setWidth] = useState(initial?.width ?? 2);
|
const [width, setWidth] = useState(initial?.width ?? 2);
|
||||||
const [height, setHeight] = useState(initial?.height ?? 4);
|
const [height, setHeight] = useState(initial?.height ?? 4);
|
||||||
|
|
||||||
function splitCombinators(): string[] | null {
|
function splitCombinators(): string[] | null {
|
||||||
const arr = combinators.split(',').map(x => x.trim()).filter(Boolean);
|
const arr = combinators
|
||||||
|
.split(',')
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean);
|
||||||
return arr.length > 0 ? arr : null;
|
return arr.length > 0 ? arr : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,30 +67,35 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
|||||||
|
|
||||||
// Regex mode: store pattern exactly as typed — server expands via matchKeys at query time.
|
// Regex mode: store pattern exactly as typed — server expands via matchKeys at query time.
|
||||||
// Non-regex mode: resolve each comma-token (localized name or raw key) to raw key.
|
// Non-regex mode: resolve each comma-token (localized name or raw key) to raw key.
|
||||||
const filter_items = useRegex
|
const filter_items = useRegex
|
||||||
? (whitelist.trim() ? [whitelist.trim()] : null)
|
? whitelist.trim()
|
||||||
|
? [whitelist.trim()]
|
||||||
|
: null
|
||||||
: normalizeList(whitelist, reverseMap);
|
: normalizeList(whitelist, reverseMap);
|
||||||
const filter_items_exclude = useRegex
|
const filter_items_exclude = useRegex
|
||||||
? (blacklist.trim() ? [blacklist.trim()] : null)
|
? blacklist.trim()
|
||||||
|
? [blacklist.trim()]
|
||||||
|
: null
|
||||||
: normalizeList(blacklist, reverseMap);
|
: normalizeList(blacklist, reverseMap);
|
||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
chart_type: chartType,
|
chart_type: chartType,
|
||||||
viz_type: chartType === 'divider' ? 'line' : vizType,
|
viz_type: chartType === 'divider' ? 'line' : vizType,
|
||||||
signal_type: signalType,
|
signal_type: signalType,
|
||||||
pos_x: initial?.pos_x ?? 0,
|
pos_x: initial?.pos_x ?? 0,
|
||||||
pos_y: initial?.pos_y ?? 0,
|
pos_y: initial?.pos_y ?? 0,
|
||||||
width, height,
|
width,
|
||||||
filter_combinators: chartType === 'divider' ? null : splitCombinators(),
|
height,
|
||||||
filter_items: chartType === 'divider' ? null : filter_items,
|
filter_combinators: chartType === 'divider' ? null : splitCombinators(),
|
||||||
|
filter_items: chartType === 'divider' ? null : filter_items,
|
||||||
filter_items_exclude: chartType === 'divider' ? null : filter_items_exclude,
|
filter_items_exclude: chartType === 'divider' ? null : filter_items_exclude,
|
||||||
filter_items_regex: useRegex,
|
filter_items_regex: useRegex,
|
||||||
order_by: orderBy,
|
order_by: orderBy,
|
||||||
series_limit: seriesLimit,
|
series_limit: seriesLimit,
|
||||||
y_min: yMin !== '' ? parseFloat(yMin) : null,
|
y_min: yMin !== '' ? parseFloat(yMin) : null,
|
||||||
y_max: yMax !== '' ? parseFloat(yMax) : null,
|
y_max: yMax !== '' ? parseFloat(yMax) : null,
|
||||||
y_scale: yScale,
|
y_scale: yScale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,115 +103,205 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
|||||||
const isDivider = chartType === 'divider';
|
const isDivider = chartType === 'divider';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md shadow-xl overflow-y-auto max-h-[90vh]"
|
className="bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md shadow-xl overflow-y-auto max-h-[90vh]"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">
|
<h2 className="text-lg font-semibold text-white mb-4">
|
||||||
{initial ? 'Edit Chart' : 'New Chart'}
|
{initial ? 'Edit Chart' : 'New Chart'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Title</label>
|
<label className="block text-sm text-gray-400 mb-1">Title</label>
|
||||||
<input value={title} onChange={e => setTitle(e.target.value)} className={`${inputCls} mb-3`} />
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className={`${inputCls} mb-3`}
|
||||||
|
/>
|
||||||
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Chart Type</label>
|
<label className="block text-sm text-gray-400 mb-1">Chart Type</label>
|
||||||
<select value={chartType} onChange={e => setChartType(e.target.value as ChartConfig['chart_type'])} className={`${inputCls} mb-3`}>
|
<select
|
||||||
|
value={chartType}
|
||||||
|
onChange={(e) => setChartType(e.target.value as ChartConfig['chart_type'])}
|
||||||
|
className={`${inputCls} mb-3`}
|
||||||
|
>
|
||||||
<option value="signals">Signals</option>
|
<option value="signals">Signals</option>
|
||||||
<option value="ups">UPS / Game Tick Rate</option>
|
<option value="ups">UPS / Game Tick Rate</option>
|
||||||
<option value="divider">Divider / Section Label</option>
|
<option value="divider">Divider / Section Label</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{!isDivider && <>
|
{!isDivider && (
|
||||||
<label className="block text-sm text-gray-400 mb-1">Visualization</label>
|
<>
|
||||||
<select value={vizType} onChange={e => setVizType(e.target.value as ChartConfig['viz_type'])} className={`${inputCls} mb-3`}>
|
<label className="block text-sm text-gray-400 mb-1">Visualization</label>
|
||||||
<option value="line">Line Chart</option>
|
<select
|
||||||
<option value="table">Table</option>
|
value={vizType}
|
||||||
</select>
|
onChange={(e) => setVizType(e.target.value as ChartConfig['viz_type'])}
|
||||||
</>}
|
className={`${inputCls} mb-3`}
|
||||||
|
>
|
||||||
|
<option value="line">Line Chart</option>
|
||||||
|
<option value="table">Table</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSignals && <>
|
{isSignals && (
|
||||||
<label className="block text-sm text-gray-400 mb-1">Signal</label>
|
<>
|
||||||
<select value={signalType} onChange={e => setSignalType(e.target.value as ChartConfig['signal_type'])} className={`${inputCls} mb-3`}>
|
<label className="block text-sm text-gray-400 mb-1">Signal</label>
|
||||||
<option value="both">Both (green + red)</option>
|
<select
|
||||||
<option value="green">Green only</option>
|
value={signalType}
|
||||||
<option value="red">Red only</option>
|
onChange={(e) => setSignalType(e.target.value as ChartConfig['signal_type'])}
|
||||||
</select>
|
className={`${inputCls} mb-3`}
|
||||||
|
>
|
||||||
|
<option value="both">Both (green + red)</option>
|
||||||
|
<option value="green">Green only</option>
|
||||||
|
<option value="red">Red only</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
|
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
|
||||||
<select value={orderBy} onChange={e => setOrderBy(e.target.value as ChartConfig['order_by'])} className={`${inputCls} mb-3`}>
|
<select
|
||||||
<option value="value_asc">Latest lowest values</option>
|
value={orderBy}
|
||||||
<option value="value_desc">Latest highest values</option>
|
onChange={(e) => setOrderBy(e.target.value as ChartConfig['order_by'])}
|
||||||
<option value="delta_asc">Biggest decrease (last 10 min)</option>
|
className={`${inputCls} mb-3`}
|
||||||
<option value="delta_desc">Biggest increase (last 10 min)</option>
|
>
|
||||||
</select>
|
<option value="value_asc">Latest lowest values</option>
|
||||||
|
<option value="value_desc">Latest highest values</option>
|
||||||
|
<option value="delta_asc">Biggest decrease (last 10 min)</option>
|
||||||
|
<option value="delta_desc">Biggest increase (last 10 min)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Max series (lines)</label>
|
<label className="block text-sm text-gray-400 mb-1">Max series (lines)</label>
|
||||||
<input type="number" min={1} max={200} value={seriesLimit}
|
<input
|
||||||
onChange={e => setSeriesLimit(Number(e.target.value))}
|
type="number"
|
||||||
className={`${inputCls} mb-3`} />
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={seriesLimit}
|
||||||
|
onChange={(e) => setSeriesLimit(Number(e.target.value))}
|
||||||
|
className={`${inputCls} mb-3`}
|
||||||
|
/>
|
||||||
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Combinators (comma-separated, empty = all)</label>
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
<input value={combinators} onChange={e => setCombinators(e.target.value)}
|
Combinators (comma-separated, empty = all)
|
||||||
placeholder="nauvis, nauvis-orbit" className={`${inputCls} mb-3`} />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<label className="text-sm text-gray-400 flex-1">Item filters</label>
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={useRegex} onChange={e => setUseRegex(e.target.checked)}
|
|
||||||
className="accent-indigo-500" />
|
|
||||||
Use regex
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<input
|
||||||
<input value={whitelist} onChange={e => setWhitelist(e.target.value)}
|
value={combinators}
|
||||||
placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'}
|
onChange={(e) => setCombinators(e.target.value)}
|
||||||
className={`${inputCls} mb-1`} />
|
placeholder="nauvis, nauvis-orbit"
|
||||||
<p className="text-xs text-gray-500 mb-2">Whitelist — localized names or item keys accepted (empty = all)</p>
|
className={`${inputCls} mb-3`}
|
||||||
<input value={blacklist} onChange={e => setBlacklist(e.target.value)}
|
/>
|
||||||
placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'}
|
|
||||||
className={`${inputCls} mb-1`} />
|
|
||||||
<p className="text-xs text-gray-500 mb-3">Blacklist — localized names or item keys accepted</p>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
{!isDivider && <>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="flex gap-3 mb-3">
|
<label className="text-sm text-gray-400 flex-1">Item filters</label>
|
||||||
<div className="flex-1">
|
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||||
<label className="block text-sm text-gray-400 mb-1">Y Min (empty = auto)</label>
|
<input
|
||||||
<input type="number" value={yMin} onChange={e => setYMin(e.target.value)}
|
type="checkbox"
|
||||||
placeholder="auto" className={inputCls} />
|
checked={useRegex}
|
||||||
|
onChange={(e) => setUseRegex(e.target.checked)}
|
||||||
|
className="accent-indigo-500"
|
||||||
|
/>
|
||||||
|
Use regex
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<input
|
||||||
<label className="block text-sm text-gray-400 mb-1">Y Max (empty = auto)</label>
|
value={whitelist}
|
||||||
<input type="number" value={yMax} onChange={e => setYMax(e.target.value)}
|
onChange={(e) => setWhitelist(e.target.value)}
|
||||||
placeholder="auto" className={inputCls} />
|
placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'}
|
||||||
</div>
|
className={`${inputCls} mb-1`}
|
||||||
</div>
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Whitelist — localized names or item keys accepted (empty = all)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
value={blacklist}
|
||||||
|
onChange={(e) => setBlacklist(e.target.value)}
|
||||||
|
placeholder={useRegex ? 'Holz|stone' : 'Wood, stone'}
|
||||||
|
className={`${inputCls} mb-1`}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Blacklist — localized names or item keys accepted
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Y Scale</label>
|
{!isDivider && (
|
||||||
<select value={yScale} onChange={e => setYScale(e.target.value as ChartConfig['y_scale'])} className={`${inputCls} mb-3`}>
|
<>
|
||||||
<option value="linear">Linear</option>
|
<div className="flex gap-3 mb-3">
|
||||||
<option value="log">Symmetric Log (arcsinh)</option>
|
<div className="flex-1">
|
||||||
</select>
|
<label className="block text-sm text-gray-400 mb-1">Y Min (empty = auto)</label>
|
||||||
</>}
|
<input
|
||||||
|
type="number"
|
||||||
|
value={yMin}
|
||||||
|
onChange={(e) => setYMin(e.target.value)}
|
||||||
|
placeholder="auto"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Y Max (empty = auto)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={yMax}
|
||||||
|
onChange={(e) => setYMax(e.target.value)}
|
||||||
|
placeholder="auto"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Y Scale</label>
|
||||||
|
<select
|
||||||
|
value={yScale}
|
||||||
|
onChange={(e) => setYScale(e.target.value as ChartConfig['y_scale'])}
|
||||||
|
className={`${inputCls} mb-3`}
|
||||||
|
>
|
||||||
|
<option value="linear">Linear</option>
|
||||||
|
<option value="log">Symmetric Log (arcsinh)</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 mb-4">
|
<div className="flex gap-3 mb-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-sm text-gray-400 mb-1">Width (1–6 cols)</label>
|
<label className="block text-sm text-gray-400 mb-1">Width (1–6 cols)</label>
|
||||||
<input type="number" min={1} max={6} value={width}
|
<input
|
||||||
onChange={e => setWidth(Number(e.target.value))} className={inputCls} />
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={6}
|
||||||
|
value={width}
|
||||||
|
onChange={(e) => setWidth(Number(e.target.value))}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-sm text-gray-400 mb-1">Height (rows)</label>
|
<label className="block text-sm text-gray-400 mb-1">Height (rows)</label>
|
||||||
<input type="number" min={2} max={20} value={height}
|
<input
|
||||||
onChange={e => setHeight(Number(e.target.value))} className={inputCls} />
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={20}
|
||||||
|
value={height}
|
||||||
|
onChange={(e) => setHeight(Number(e.target.value))}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button onClick={onClose} className="px-4 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700">Cancel</button>
|
<button
|
||||||
<button onClick={handleSave} className="px-4 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded">Save</button>
|
onClick={onClose}
|
||||||
|
className="px-4 py-1.5 text-sm text-gray-400 hover:text-white rounded hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,27 @@
|
|||||||
|
|
||||||
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 type { Layout, LayoutItem } from 'react-grid-layout';
|
|
||||||
import 'react-grid-layout/css/styles.css';
|
|
||||||
import 'react-resizable/css/styles.css';
|
|
||||||
import { useApp } from '@/lib/context';
|
|
||||||
import { fetchCharts, createChart, updateChart, deleteChart, fetchSignals, fetchSessions, fetchUps } from '@/lib/api';
|
|
||||||
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
|
|
||||||
import ChartCard from './ChartCard';
|
import ChartCard from './ChartCard';
|
||||||
import ChartEditor from './ChartEditor';
|
import ChartEditor from './ChartEditor';
|
||||||
|
|
||||||
const COLS = 6;
|
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 {
|
||||||
|
fetchCharts,
|
||||||
|
createChart,
|
||||||
|
updateChart,
|
||||||
|
deleteChart,
|
||||||
|
fetchSignals,
|
||||||
|
fetchSessions,
|
||||||
|
fetchUps,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { useApp } from '@/lib/context';
|
||||||
|
|
||||||
|
const COLS = 6;
|
||||||
const ROW_HEIGHT = 80;
|
const ROW_HEIGHT = 80;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,21 +31,25 @@ interface Props {
|
|||||||
|
|
||||||
export default function Dashboard({ alerts }: Props) {
|
export default function Dashboard({ alerts }: Props) {
|
||||||
const { timeRange, timeMode, getFromTo } = useApp();
|
const { timeRange, timeMode, getFromTo } = useApp();
|
||||||
const [charts, setCharts] = useState<ChartConfig[]>([]);
|
const [charts, setCharts] = useState<ChartConfig[]>([]);
|
||||||
const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map());
|
const [signalData, setSignalData] = useState<Map<string, SignalRow[]>>(new Map());
|
||||||
const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map());
|
const [upsData, setUpsData] = useState<Map<string, UpsRow[]>>(new Map());
|
||||||
const [sessions, setSessions] = useState<SessionBoundary[]>([]);
|
const [sessions, setSessions] = useState<SessionBoundary[]>([]);
|
||||||
const [editingChart, setEditingChart] = useState<ChartConfig | null>(null);
|
const [editingChart, setEditingChart] = useState<ChartConfig | null>(null);
|
||||||
const [creatingChart, setCreatingChart] = useState(false);
|
const [creatingChart, setCreatingChart] = useState(false);
|
||||||
const [containerWidth, setContainerWidth] = useState(1200);
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartsRef = useRef<ChartConfig[]>([]);
|
const chartsRef = useRef<ChartConfig[]>([]);
|
||||||
const refreshingRef = useRef(false);
|
const refreshingRef = useRef(false);
|
||||||
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => { chartsRef.current = charts; }, [charts]);
|
useEffect(() => {
|
||||||
useEffect(() => { fetchCharts().then(setCharts); }, []);
|
chartsRef.current = charts;
|
||||||
|
}, [charts]);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCharts().then(setCharts);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshData = useCallback(async () => {
|
const refreshData = useCallback(async () => {
|
||||||
if (refreshingRef.current) return;
|
if (refreshingRef.current) return;
|
||||||
@@ -44,24 +59,27 @@ export default function Dashboard({ alerts }: Props) {
|
|||||||
refreshingRef.current = true;
|
refreshingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const { from, to } = getFromTo();
|
const { from, to } = getFromTo();
|
||||||
const signalCharts = current.filter(c => c.chart_type === 'signals');
|
const signalCharts = current.filter((c) => c.chart_type === 'signals');
|
||||||
const upsCharts = current.filter(c => c.chart_type === 'ups');
|
const upsCharts = current.filter((c) => c.chart_type === 'ups');
|
||||||
|
|
||||||
if (signalCharts.length === 0 && upsCharts.length === 0) return;
|
if (signalCharts.length === 0 && upsCharts.length === 0) return;
|
||||||
|
|
||||||
const [newSessions, ...results] = await Promise.all([
|
const [newSessions, ...results] = await Promise.all([
|
||||||
fetchSessions(from, to),
|
fetchSessions(from, to),
|
||||||
...signalCharts.map(c => fetchSignals({
|
...signalCharts.map((c) =>
|
||||||
combinator: c.filter_combinators ?? undefined,
|
fetchSignals({
|
||||||
item: c.filter_items ?? undefined,
|
combinator: c.filter_combinators ?? undefined,
|
||||||
exclude: c.filter_items_exclude ?? undefined,
|
item: c.filter_items ?? undefined,
|
||||||
signal: c.signal_type,
|
exclude: c.filter_items_exclude ?? undefined,
|
||||||
time_mode: timeMode,
|
signal: c.signal_type,
|
||||||
from, to,
|
time_mode: timeMode,
|
||||||
regex: c.filter_items_regex || undefined,
|
from,
|
||||||
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}),
|
to,
|
||||||
})),
|
regex: c.filter_items_regex || undefined,
|
||||||
...upsCharts.map(c => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
|
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...upsCharts.map((c) => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSessions(newSessions as SessionBoundary[]);
|
setSessions(newSessions as SessionBoundary[]);
|
||||||
@@ -98,47 +116,56 @@ export default function Dashboard({ alerts }: Props) {
|
|||||||
|
|
||||||
async function handleCreate(draft: Omit<ChartConfig, 'id'>) {
|
async function handleCreate(draft: Omit<ChartConfig, 'id'>) {
|
||||||
const created = await createChart(draft);
|
const created = await createChart(draft);
|
||||||
setCharts(cs => [...cs, created]);
|
setCharts((cs) => [...cs, created]);
|
||||||
setCreatingChart(false);
|
setCreatingChart(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) {
|
async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) {
|
||||||
const updated = await updateChart(id, draft);
|
const updated = await updateChart(id, draft);
|
||||||
setCharts(cs => cs.map(c => c.id === id ? updated : c));
|
setCharts((cs) => cs.map((c) => (c.id === id ? updated : c)));
|
||||||
setEditingChart(null);
|
setEditingChart(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
await deleteChart(id);
|
await deleteChart(id);
|
||||||
setCharts(cs => cs.filter(c => c.id !== id));
|
setCharts((cs) => cs.filter((c) => c.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLayoutChange(layout: Layout) {
|
function handleLayoutChange(layout: Layout) {
|
||||||
const items = layout as readonly LayoutItem[];
|
const items = layout as readonly LayoutItem[];
|
||||||
const changed = items.filter(item => {
|
const changed = items.filter((item) => {
|
||||||
const chart = chartsRef.current.find(c => c.id === item.i);
|
const chart = chartsRef.current.find((c) => c.id === item.i);
|
||||||
return chart && (
|
return (
|
||||||
chart.pos_x !== item.x || chart.pos_y !== item.y ||
|
chart &&
|
||||||
chart.width !== item.w || chart.height !== item.h
|
(chart.pos_x !== item.x ||
|
||||||
|
chart.pos_y !== item.y ||
|
||||||
|
chart.width !== item.w ||
|
||||||
|
chart.height !== item.h)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (changed.length === 0) return;
|
if (changed.length === 0) return;
|
||||||
|
|
||||||
setCharts(cs => cs.map(c => {
|
setCharts((cs) =>
|
||||||
const l = changed.find(item => item.i === c.id);
|
cs.map((c) => {
|
||||||
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c;
|
const l = changed.find((item) => item.i === c.id);
|
||||||
}));
|
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current);
|
if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current);
|
||||||
layoutSaveTimer.current = setTimeout(() => {
|
layoutSaveTimer.current = setTimeout(() => {
|
||||||
changed.forEach(item =>
|
changed.forEach((item) =>
|
||||||
updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }),
|
updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }),
|
||||||
);
|
);
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout: Layout = charts.map(c => ({
|
const layout: Layout = charts.map((c) => ({
|
||||||
i: c.id, x: c.pos_x, y: c.pos_y, w: c.width, h: c.height,
|
i: c.id,
|
||||||
|
x: c.pos_x,
|
||||||
|
y: c.pos_y,
|
||||||
|
w: c.width,
|
||||||
|
h: c.height,
|
||||||
minW: 1,
|
minW: 1,
|
||||||
minH: c.chart_type === 'divider' ? 1 : 2,
|
minH: c.chart_type === 'divider' ? 1 : 2,
|
||||||
}));
|
}));
|
||||||
@@ -153,7 +180,8 @@ export default function Dashboard({ alerts }: Props) {
|
|||||||
dragConfig={{ handle: '.drag-handle' }}
|
dragConfig={{ handle: '.drag-handle' }}
|
||||||
resizeConfig={{
|
resizeConfig={{
|
||||||
handleComponent: (axis, ref) => (
|
handleComponent: (axis, ref) => (
|
||||||
<span ref={ref}
|
<span
|
||||||
|
ref={ref}
|
||||||
className="react-resizable-handle react-resizable-handle-se"
|
className="react-resizable-handle react-resizable-handle-se"
|
||||||
style={{
|
style={{
|
||||||
borderRight: '3px solid #4b5563',
|
borderRight: '3px solid #4b5563',
|
||||||
@@ -164,16 +192,18 @@ export default function Dashboard({ alerts }: Props) {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{charts.map(c => (
|
{charts.map((c) => (
|
||||||
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
|
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
config={c}
|
config={c}
|
||||||
rows={signalData.get(c.id) ?? []}
|
rows={signalData.get(c.id) ?? []}
|
||||||
upsRows={upsData.get(c.id) ?? []}
|
upsRows={upsData.get(c.id) ?? []}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
alerts={alerts.filter(a =>
|
alerts={alerts.filter(
|
||||||
!c.filter_combinators || !a.combinator ||
|
(a) =>
|
||||||
c.filter_combinators.includes(a.combinator),
|
!c.filter_combinators ||
|
||||||
|
!a.combinator ||
|
||||||
|
c.filter_combinators.includes(a.combinator),
|
||||||
)}
|
)}
|
||||||
timeMode={timeMode}
|
timeMode={timeMode}
|
||||||
onEdit={() => setEditingChart(c)}
|
onEdit={() => setEditingChart(c)}
|
||||||
@@ -184,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"
|
||||||
>
|
>
|
||||||
@@ -196,10 +227,10 @@ export default function Dashboard({ alerts }: Props) {
|
|||||||
{editingChart && (
|
{editingChart && (
|
||||||
<ChartEditor
|
<ChartEditor
|
||||||
initial={editingChart}
|
initial={editingChart}
|
||||||
onSave={draft => handleUpdate(editingChart.id, draft)}
|
onSave={(draft) => handleUpdate(editingChart.id, draft)}
|
||||||
onClose={() => setEditingChart(null)}
|
onClose={() => setEditingChart(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -11,7 +12,7 @@ export default function TimeRangeSelector() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex rounded overflow-hidden border border-gray-700">
|
<div className="flex rounded overflow-hidden border border-gray-700">
|
||||||
{RANGES.map(r => (
|
{RANGES.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r}
|
key={r}
|
||||||
onClick={() => setTimeRange(r)}
|
onClick={() => setTimeRange(r)}
|
||||||
@@ -27,7 +28,7 @@ export default function TimeRangeSelector() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex rounded overflow-hidden border border-gray-700">
|
<div className="flex rounded overflow-hidden border border-gray-700">
|
||||||
{(['real', 'tick'] as TimeMode[]).map(m => (
|
{(['real', 'tick'] as TimeMode[]).map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
onClick={() => setTimeMode(m)}
|
onClick={() => setTimeMode(m)}
|
||||||
@@ -43,4 +44,4 @@ export default function TimeRangeSelector() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ services:
|
|||||||
POSTGRES_PASSWORD: factorio
|
POSTGRES_PASSWORD: factorio
|
||||||
POSTGRES_DB: factorio
|
POSTGRES_DB: factorio
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- '5432:5432'
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
|||||||
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,20 +1,39 @@
|
|||||||
import type { ChartConfig, AlertConfig, TriggeredAlert, SignalRow, SessionBoundary, UpsRow, TimeMode } from './types';
|
import type {
|
||||||
|
ChartConfig,
|
||||||
|
AlertConfig,
|
||||||
|
TriggeredAlert,
|
||||||
|
SignalRow,
|
||||||
|
SessionBoundary,
|
||||||
|
UpsRow,
|
||||||
|
TimeMode,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
let _token = '';
|
let _token = '';
|
||||||
export function setToken(token: string) { _token = token; }
|
export function setToken(token: string) {
|
||||||
|
_token = token;
|
||||||
|
}
|
||||||
|
|
||||||
function url(path: string, params: Record<string, string | string[] | boolean | number | undefined> = {}) {
|
function url(
|
||||||
const u = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000');
|
path: string,
|
||||||
|
params: Record<string, string | string[] | boolean | number | undefined> = {},
|
||||||
|
) {
|
||||||
|
const u = new URL(
|
||||||
|
path,
|
||||||
|
typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000',
|
||||||
|
);
|
||||||
u.searchParams.set('token', _token);
|
u.searchParams.set('token', _token);
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
if (v === undefined) continue;
|
if (v === undefined) continue;
|
||||||
if (Array.isArray(v)) v.forEach(val => u.searchParams.append(k, String(val)));
|
if (Array.isArray(v)) v.forEach((val) => u.searchParams.append(k, String(val)));
|
||||||
else u.searchParams.set(k, String(v));
|
else u.searchParams.set(k, String(v));
|
||||||
}
|
}
|
||||||
return u.toString();
|
return u.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get<T>(path: string, params?: Record<string, string | string[] | boolean | number | undefined>): Promise<T> {
|
async function get<T>(
|
||||||
|
path: string,
|
||||||
|
params?: Record<string, string | string[] | boolean | number | undefined>,
|
||||||
|
): Promise<T> {
|
||||||
const res = await fetch(url(path, params));
|
const res = await fetch(url(path, params));
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -45,7 +64,11 @@ export function fetchSignals(params: {
|
|||||||
return get('/api/signals', params as Record<string, string | string[]>);
|
return get('/api/signals', params as Record<string, string | string[]>);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchUps(params: { combinator?: string; from?: string; to?: string }): Promise<UpsRow[]> {
|
export function fetchUps(params: {
|
||||||
|
combinator?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}): Promise<UpsRow[]> {
|
||||||
return get('/api/ups', params);
|
return get('/api/ups', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +76,31 @@ export function fetchSessions(from: string, to: string): Promise<SessionBoundary
|
|||||||
return get('/api/sessions', { from, to });
|
return get('/api/sessions', { from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchCharts(): Promise<ChartConfig[]> { return get('/api/charts'); }
|
export function fetchCharts(): Promise<ChartConfig[]> {
|
||||||
export function createChart(body: Omit<ChartConfig, 'id'>): Promise<ChartConfig> { return mutate('POST', '/api/charts', body); }
|
return get('/api/charts');
|
||||||
export function updateChart(id: string, body: Partial<ChartConfig>): Promise<ChartConfig> { return mutate('PUT', `/api/charts/${id}`, body); }
|
}
|
||||||
export function deleteChart(id: string): Promise<{ ok: boolean }> { return mutate('DELETE', `/api/charts/${id}`); }
|
export function createChart(body: Omit<ChartConfig, 'id'>): Promise<ChartConfig> {
|
||||||
|
return mutate('POST', '/api/charts', body);
|
||||||
|
}
|
||||||
|
export function updateChart(id: string, body: Partial<ChartConfig>): Promise<ChartConfig> {
|
||||||
|
return mutate('PUT', `/api/charts/${id}`, body);
|
||||||
|
}
|
||||||
|
export function deleteChart(id: string): Promise<{ ok: boolean }> {
|
||||||
|
return mutate('DELETE', `/api/charts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAlerts(): Promise<AlertConfig[]> { return get('/api/alerts'); }
|
export function fetchAlerts(): Promise<AlertConfig[]> {
|
||||||
export function createAlert(body: Omit<AlertConfig, 'id' | 'active'>): Promise<AlertConfig> { return mutate('POST', '/api/alerts', body); }
|
return get('/api/alerts');
|
||||||
export function updateAlert(id: string, body: Partial<AlertConfig>): Promise<AlertConfig> { return mutate('PUT', `/api/alerts/${id}`, body); }
|
}
|
||||||
export function deleteAlert(id: string): Promise<{ ok: boolean }> { return mutate('DELETE', `/api/alerts/${id}`); }
|
export function createAlert(body: Omit<AlertConfig, 'id' | 'active'>): Promise<AlertConfig> {
|
||||||
export function checkAlerts(): Promise<TriggeredAlert[]> { return get('/api/alerts/check'); }
|
return mutate('POST', '/api/alerts', body);
|
||||||
|
}
|
||||||
|
export function updateAlert(id: string, body: Partial<AlertConfig>): Promise<AlertConfig> {
|
||||||
|
return mutate('PUT', `/api/alerts/${id}`, body);
|
||||||
|
}
|
||||||
|
export function deleteAlert(id: string): Promise<{ ok: boolean }> {
|
||||||
|
return mutate('DELETE', `/api/alerts/${id}`);
|
||||||
|
}
|
||||||
|
export function checkAlerts(): Promise<TriggeredAlert[]> {
|
||||||
|
return get('/api/alerts/check');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -23,4 +26,54 @@ export function withAuth(handler: Handler): Handler {
|
|||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
@@ -19,4 +19,4 @@ export function isAuthorized(req: NextRequest): boolean {
|
|||||||
|
|
||||||
export function unauthorized(): NextResponse {
|
export function unauthorized(): NextResponse {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export type ColorMap = Map<string, string>;
|
export type ColorMap = Map<string, string>;
|
||||||
|
|
||||||
declare global { var __colorMapCache: ColorMap | undefined; }
|
declare global {
|
||||||
|
var __colorMapCache: ColorMap | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseColorCsv(text: string): ColorMap {
|
export function parseColorCsv(text: string): ColorMap {
|
||||||
const map: ColorMap = new Map();
|
const map: ColorMap = new Map();
|
||||||
|
|||||||
@@ -1,49 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, {
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import type { TimeRange, TimeMode, TriggeredAlert } from './types';
|
|
||||||
import { 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;
|
||||||
setTimeRange: (r: TimeRange) => void;
|
setTimeRange: (r: TimeRange) => void;
|
||||||
timeMode: TimeMode;
|
timeMode: TimeMode;
|
||||||
setTimeMode: (m: TimeMode) => void;
|
setTimeMode: (m: TimeMode) => void;
|
||||||
triggeredAlerts: TriggeredAlert[];
|
triggeredAlerts: TriggeredAlert[];
|
||||||
refreshAlerts: () => Promise<void>;
|
refreshAlerts: () => Promise<void>;
|
||||||
getFromTo: () => { from: string; to: string };
|
getFromTo: () => { from: string; to: string };
|
||||||
localeMap: LocaleMap;
|
localeMap: LocaleMap;
|
||||||
reverseMap: ReverseMap;
|
reverseMap: ReverseMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppContext = createContext<AppContextValue | null>(null);
|
const AppContext = createContext<AppContextValue | null>(null);
|
||||||
|
|
||||||
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[]>([]);
|
||||||
|
|
||||||
const reverseMap = buildReverseMap(localeMap);
|
const reverseMap = buildReverseMap(localeMap);
|
||||||
|
|
||||||
const getFromTo = useCallback(() => {
|
const getFromTo = useCallback(() => {
|
||||||
const to = new Date();
|
const to = new Date();
|
||||||
const from = new Date(to.getTime() - TIME_RANGE_MS[timeRange]);
|
const from = new Date(to.getTime() - TIME_RANGE_MS[timeRange]);
|
||||||
return { from: from.toISOString(), to: to.toISOString() };
|
return { from: from.toISOString(), to: to.toISOString() };
|
||||||
}, [timeRange]);
|
}, [timeRange]);
|
||||||
@@ -54,20 +48,30 @@ export function AppProvider({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const poll = () => checkAlerts().then(a => { if (!cancelled) setTriggeredAlerts(a); });
|
const poll = () =>
|
||||||
|
checkAlerts().then((a) => {
|
||||||
|
if (!cancelled) setTriggeredAlerts(a);
|
||||||
|
});
|
||||||
poll();
|
poll();
|
||||||
const id = setInterval(poll, 30_000);
|
const id = setInterval(poll, 30_000);
|
||||||
return () => { cancelled = true; clearInterval(id); };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider
|
<AppContext.Provider
|
||||||
value={{
|
value={{
|
||||||
timeRange, setTimeRange,
|
timeRange,
|
||||||
timeMode, setTimeMode,
|
setTimeRange,
|
||||||
triggeredAlerts, refreshAlerts,
|
timeMode,
|
||||||
|
setTimeMode,
|
||||||
|
triggeredAlerts,
|
||||||
|
refreshAlerts,
|
||||||
getFromTo,
|
getFromTo,
|
||||||
localeMap, reverseMap,
|
localeMap,
|
||||||
|
reverseMap,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -80,4 +84,4 @@ export function useApp() {
|
|||||||
const ctx = useContext(AppContext);
|
const ctx = useContext(AppContext);
|
||||||
if (!ctx) throw new Error('useApp must be used within AppProvider');
|
if (!ctx) throw new Error('useApp must be used within AppProvider');
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,4 +10,4 @@ if (process.env.NODE_ENV !== 'production') {
|
|||||||
globalThis.__pgPool = pool;
|
globalThis.__pgPool = pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default pool;
|
export default pool;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
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 { var __serverLocaleCache: LocaleMap | undefined; }
|
declare global {
|
||||||
|
var __serverLocaleCache: LocaleMap | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads and merges EN + DE locale CSVs from the public directory.
|
* Loads and merges EN + DE locale CSVs from the public directory.
|
||||||
@@ -23,7 +27,10 @@ export function getServerLocaleMap(): LocaleMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged: LocaleMap = new Map([...load('factorio_english_items.csv'), ...load('factorio_german_items.csv')]);
|
const merged: LocaleMap = new Map([
|
||||||
|
...load('factorio_english_items.csv'),
|
||||||
|
...load('factorio_german_items.csv'),
|
||||||
|
]);
|
||||||
globalThis.__serverLocaleCache = merged;
|
globalThis.__serverLocaleCache = merged;
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ function splitCsvLine(line: string): string[] {
|
|||||||
for (let i = 0; i < line.length; i++) {
|
for (let i = 0; i < line.length; i++) {
|
||||||
const ch = line[i];
|
const ch = line[i];
|
||||||
if (ch === '"') {
|
if (ch === '"') {
|
||||||
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
|
if (inQuote && line[i + 1] === '"') {
|
||||||
else inQuote = !inQuote;
|
cur += '"';
|
||||||
|
i++;
|
||||||
|
} else inQuote = !inQuote;
|
||||||
} else if (ch === ',' && !inQuote) {
|
} else if (ch === ',' && !inQuote) {
|
||||||
cols.push(cur);
|
cols.push(cur);
|
||||||
cur = '';
|
cur = '';
|
||||||
@@ -33,7 +35,9 @@ function splitCsvLine(line: string): string[] {
|
|||||||
return cols;
|
return cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global { var __localeCache: LocaleMap | undefined; }
|
declare global {
|
||||||
|
var __localeCache: LocaleMap | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCsv(path: string): Promise<LocaleMap> {
|
async function loadCsv(path: string): Promise<LocaleMap> {
|
||||||
try {
|
try {
|
||||||
@@ -100,4 +104,4 @@ export function matchKeys(pattern: string, map: LocaleMap): string[] {
|
|||||||
if (re.test(key) || re.test(name)) result.add(key);
|
if (re.test(key) || re.test(name)) result.add(key);
|
||||||
}
|
}
|
||||||
return [...result];
|
return [...result];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
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.
|
||||||
*/
|
*/
|
||||||
export async function getSessionBoundaries(
|
export async function getSessionBoundaries(from: Date, to: Date): Promise<SessionBoundary[]> {
|
||||||
from: Date,
|
|
||||||
to: Date,
|
|
||||||
): Promise<SessionBoundary[]> {
|
|
||||||
const result = await pool.query<{ real_time: Date; game_tick: string }>(
|
const result = await pool.query<{ real_time: Date; game_tick: string }>(
|
||||||
`SELECT real_time, game_tick
|
`SELECT real_time, game_tick
|
||||||
FROM tick_timing
|
FROM tick_timing
|
||||||
@@ -20,10 +18,12 @@ export async function getSessionBoundaries(
|
|||||||
const rows = result.rows;
|
const rows = result.rows;
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
const boundaries: SessionBoundary[] = [{
|
const boundaries: SessionBoundary[] = [
|
||||||
real_time: rows[0].real_time.toISOString(),
|
{
|
||||||
game_tick: parseInt(rows[0].game_tick, 10),
|
real_time: rows[0].real_time.toISOString(),
|
||||||
}];
|
game_tick: parseInt(rows[0].game_tick, 10),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
for (let i = 1; i < rows.length; i++) {
|
for (let i = 1; i < rows.length; i++) {
|
||||||
if (rows[i].real_time.getTime() - rows[i - 1].real_time.getTime() > 30 * 60 * 1000) {
|
if (rows[i].real_time.getTime() - rows[i - 1].real_time.getTime() > 30 * 60 * 1000) {
|
||||||
@@ -35,4 +35,4 @@ export async function getSessionBoundaries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return boundaries;
|
return boundaries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface AlertConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TriggeredAlert extends AlertConfig {
|
export interface TriggeredAlert extends AlertConfig {
|
||||||
current_value: number;
|
current_value: number;
|
||||||
combinator_match: string;
|
combinator_match: string;
|
||||||
/** Actual matched item_key — differs from item_key when item_key_is_regex=true */
|
/** Actual matched item_key — differs from item_key when item_key_is_regex=true */
|
||||||
matched_item_key: string;
|
matched_item_key: string;
|
||||||
@@ -63,9 +63,9 @@ export type TimeRange = '30m' | '1h' | '6h' | '24h' | '7d' | '30d';
|
|||||||
|
|
||||||
export const TIME_RANGE_MS: Record<TimeRange, number> = {
|
export const TIME_RANGE_MS: Record<TimeRange, number> = {
|
||||||
'30m': 30 * 60 * 1000,
|
'30m': 30 * 60 * 1000,
|
||||||
'1h': 60 * 60 * 1000,
|
'1h': 60 * 60 * 1000,
|
||||||
'6h': 6 * 60 * 60 * 1000,
|
'6h': 6 * 60 * 60 * 1000,
|
||||||
'24h': 24 * 60 * 60 * 1000,
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,51 +2,67 @@
|
|||||||
exports.up = (pgm) => {
|
exports.up = (pgm) => {
|
||||||
pgm.sql(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`);
|
pgm.sql(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`);
|
||||||
|
|
||||||
pgm.createTable('signals', {
|
pgm.createTable(
|
||||||
real_time: { type: 'timestamptz', notNull: true },
|
'signals',
|
||||||
game_tick: { type: 'bigint', notNull: true },
|
{
|
||||||
combinator: { type: 'text', notNull: true },
|
real_time: { type: 'timestamptz', notNull: true },
|
||||||
item_key: { type: 'text', notNull: true },
|
game_tick: { type: 'bigint', notNull: true },
|
||||||
green: { type: 'integer', notNull: true, default: 0 },
|
combinator: { type: 'text', notNull: true },
|
||||||
red: { type: 'integer', notNull: true, default: 0 },
|
item_key: { type: 'text', notNull: true },
|
||||||
logistic: { type: 'integer' },
|
green: { type: 'integer', notNull: true, default: 0 },
|
||||||
}, { ifNotExists: true });
|
red: { type: 'integer', notNull: true, default: 0 },
|
||||||
|
logistic: { type: 'integer' },
|
||||||
|
},
|
||||||
|
{ ifNotExists: true },
|
||||||
|
);
|
||||||
|
|
||||||
pgm.sql(`SELECT create_hypertable('signals', 'real_time', if_not_exists => true)`);
|
pgm.sql(`SELECT create_hypertable('signals', 'real_time', if_not_exists => true)`);
|
||||||
pgm.sql(`CREATE INDEX IF NOT EXISTS signals_combinator_item_key_real_time_idx ON signals (combinator, item_key, real_time DESC)`);
|
pgm.sql(
|
||||||
|
`CREATE INDEX IF NOT EXISTS signals_combinator_item_key_real_time_idx ON signals (combinator, item_key, real_time DESC)`,
|
||||||
|
);
|
||||||
pgm.sql(`CREATE INDEX IF NOT EXISTS signals_game_tick_idx ON signals (game_tick DESC)`);
|
pgm.sql(`CREATE INDEX IF NOT EXISTS signals_game_tick_idx ON signals (game_tick DESC)`);
|
||||||
pgm.sql(`SELECT add_retention_policy('signals', INTERVAL '30 days', if_not_exists => true)`);
|
pgm.sql(`SELECT add_retention_policy('signals', INTERVAL '30 days', if_not_exists => true)`);
|
||||||
|
|
||||||
pgm.createTable('tick_timing', {
|
pgm.createTable(
|
||||||
real_time: { type: 'timestamptz', notNull: true },
|
'tick_timing',
|
||||||
game_tick: { type: 'bigint', notNull: true },
|
{
|
||||||
combinator: { type: 'text', notNull: true },
|
real_time: { type: 'timestamptz', notNull: true },
|
||||||
}, { ifNotExists: true });
|
game_tick: { type: 'bigint', notNull: true },
|
||||||
|
combinator: { type: 'text', notNull: true },
|
||||||
|
},
|
||||||
|
{ ifNotExists: true },
|
||||||
|
);
|
||||||
|
|
||||||
pgm.sql(`SELECT create_hypertable('tick_timing', 'real_time', if_not_exists => true)`);
|
pgm.sql(`SELECT create_hypertable('tick_timing', 'real_time', if_not_exists => true)`);
|
||||||
pgm.sql(`CREATE INDEX IF NOT EXISTS tick_timing_combinator_real_time_idx ON tick_timing (combinator, real_time DESC)`);
|
pgm.sql(
|
||||||
|
`CREATE INDEX IF NOT EXISTS tick_timing_combinator_real_time_idx ON tick_timing (combinator, real_time DESC)`,
|
||||||
|
);
|
||||||
pgm.sql(`SELECT add_retention_policy('tick_timing', INTERVAL '30 days', if_not_exists => true)`);
|
pgm.sql(`SELECT add_retention_policy('tick_timing', INTERVAL '30 days', if_not_exists => true)`);
|
||||||
|
|
||||||
pgm.createTable('charts', {
|
pgm.createTable(
|
||||||
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
'charts',
|
||||||
title: { type: 'text', notNull: true },
|
{
|
||||||
pos_x: { type: 'integer', notNull: true, default: 0 },
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
pos_y: { type: 'integer', notNull: true, default: 0 },
|
title: { type: 'text', notNull: true },
|
||||||
width: { type: 'integer', notNull: true, default: 2 },
|
pos_x: { type: 'integer', notNull: true, default: 0 },
|
||||||
height: { type: 'integer', notNull: true, default: 4 },
|
pos_y: { type: 'integer', notNull: true, default: 0 },
|
||||||
signal_type: { type: 'text', notNull: true, default: 'both' },
|
width: { type: 'integer', notNull: true, default: 2 },
|
||||||
chart_type: { type: 'text', notNull: true, default: 'signals' },
|
height: { type: 'integer', notNull: true, default: 4 },
|
||||||
viz_type: { type: 'text', notNull: true, default: 'line' },
|
signal_type: { type: 'text', notNull: true, default: 'both' },
|
||||||
filter_combinators: { type: 'text[]' },
|
chart_type: { type: 'text', notNull: true, default: 'signals' },
|
||||||
filter_items: { type: 'text[]' },
|
viz_type: { type: 'text', notNull: true, default: 'line' },
|
||||||
filter_items_exclude: { type: 'text[]' },
|
filter_combinators: { type: 'text[]' },
|
||||||
filter_items_regex: { type: 'boolean', notNull: true, default: false },
|
filter_items: { type: 'text[]' },
|
||||||
y_min: { type: 'real' },
|
filter_items_exclude: { type: 'text[]' },
|
||||||
y_max: { type: 'real' },
|
filter_items_regex: { type: 'boolean', notNull: true, default: false },
|
||||||
series_limit: { type: 'integer', notNull: true, default: 20 },
|
y_min: { type: 'real' },
|
||||||
order_by: { type: 'text', notNull: true, default: 'time' },
|
y_max: { type: 'real' },
|
||||||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
series_limit: { type: 'integer', notNull: true, default: 20 },
|
||||||
}, { ifNotExists: true });
|
order_by: { type: 'text', notNull: true, default: 'time' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
},
|
||||||
|
{ ifNotExists: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Use DO blocks so constraints are idempotent on existing DBs
|
// Use DO blocks so constraints are idempotent on existing DBs
|
||||||
pgm.sql(`DO $$ BEGIN
|
pgm.sql(`DO $$ BEGIN
|
||||||
@@ -58,24 +74,28 @@ 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
|
||||||
ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'));
|
ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'));
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
|
||||||
|
|
||||||
pgm.createTable('alerts', {
|
pgm.createTable(
|
||||||
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
'alerts',
|
||||||
item_key: { type: 'text', notNull: true },
|
{
|
||||||
item_key_is_regex: { type: 'boolean', notNull: true, default: false },
|
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
|
||||||
combinator: { type: 'text' },
|
item_key: { type: 'text', notNull: true },
|
||||||
signal_type: { type: 'text', notNull: true, default: 'green' },
|
item_key_is_regex: { type: 'boolean', notNull: true, default: false },
|
||||||
condition: { type: 'text', notNull: true },
|
combinator: { type: 'text' },
|
||||||
threshold: { type: 'integer', notNull: true },
|
signal_type: { type: 'text', notNull: true, default: 'green' },
|
||||||
active: { type: 'boolean', notNull: true, default: true },
|
condition: { type: 'text', notNull: true },
|
||||||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
threshold: { type: 'integer', notNull: true },
|
||||||
}, { ifNotExists: true });
|
active: { type: 'boolean', notNull: true, default: true },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
},
|
||||||
|
{ ifNotExists: true },
|
||||||
|
);
|
||||||
|
|
||||||
pgm.sql(`DO $$ BEGIN
|
pgm.sql(`DO $$ BEGIN
|
||||||
ALTER TABLE alerts ADD CONSTRAINT alerts_signal_type_check CHECK (signal_type IN ('green','red'));
|
ALTER TABLE alerts ADD CONSTRAINT alerts_signal_type_check CHECK (signal_type IN ('green','red'));
|
||||||
@@ -85,10 +105,14 @@ exports.up = (pgm) => {
|
|||||||
ALTER TABLE alerts ADD CONSTRAINT alerts_condition_check CHECK (condition IN ('above','below'));
|
ALTER TABLE alerts ADD CONSTRAINT alerts_condition_check CHECK (condition IN ('above','below'));
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
|
||||||
|
|
||||||
pgm.createTable('settings', {
|
pgm.createTable(
|
||||||
key: { type: 'text', primaryKey: true },
|
'settings',
|
||||||
value: { type: 'text', notNull: true },
|
{
|
||||||
}, { ifNotExists: true });
|
key: { type: 'text', primaryKey: true },
|
||||||
|
value: { type: 'text', notNull: true },
|
||||||
|
},
|
||||||
|
{ ifNotExists: true },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.down = () => Promise.resolve();
|
exports.down = () => Promise.resolve();
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ exports.up = (pgm) => {
|
|||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
|
||||||
|
|
||||||
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
|
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
|
||||||
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'))`);
|
pgm.sql(
|
||||||
|
`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'))`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.down = (pgm) => {
|
exports.down = (pgm) => {
|
||||||
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
|
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
|
||||||
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc'))`);
|
pgm.sql(
|
||||||
|
`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc'))`,
|
||||||
|
);
|
||||||
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_y_scale_check`);
|
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_y_scale_check`);
|
||||||
pgm.sql(`ALTER TABLE charts DROP COLUMN IF EXISTS y_scale`);
|
pgm.sql(`ALTER TABLE charts DROP COLUMN IF EXISTS y_scale`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
/** @type {import('node-pg-migrate').MigrationBuilder} */
|
/** @type {import('node-pg-migrate').MigrationBuilder} */
|
||||||
exports.up = (pgm) => {
|
exports.up = (pgm) => {
|
||||||
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
|
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
|
||||||
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'))`);
|
pgm.sql(
|
||||||
|
`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'))`,
|
||||||
|
);
|
||||||
|
|
||||||
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
|
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
|
||||||
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups','divider'))`);
|
pgm.sql(
|
||||||
|
`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups','divider'))`,
|
||||||
|
);
|
||||||
|
|
||||||
// Migrate any existing stacked charts to line
|
// Migrate any existing stacked charts to line
|
||||||
pgm.sql(`UPDATE charts SET viz_type = 'line' WHERE viz_type = 'stacked'`);
|
pgm.sql(`UPDATE charts SET viz_type = 'line' WHERE viz_type = 'stacked'`);
|
||||||
@@ -12,8 +16,12 @@ exports.up = (pgm) => {
|
|||||||
|
|
||||||
exports.down = (pgm) => {
|
exports.down = (pgm) => {
|
||||||
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
|
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
|
||||||
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'))`);
|
pgm.sql(
|
||||||
|
`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'))`,
|
||||||
|
);
|
||||||
|
|
||||||
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
|
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
|
||||||
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`);
|
pgm.sql(
|
||||||
};
|
`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1840
web/package-lock.json
generated
1840
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"format": "prettier --check .",
|
||||||
|
"format:fix": "prettier --write .",
|
||||||
"migrate": "node-pg-migrate up -m migrations",
|
"migrate": "node-pg-migrate up -m migrations",
|
||||||
"migrate:down": "node-pg-migrate down -m migrations",
|
"migrate:down": "node-pg-migrate down -m migrations",
|
||||||
"migrate:create": "node-pg-migrate create -m migrations"
|
"migrate:create": "node-pg-migrate create -m migrations"
|
||||||
@@ -22,13 +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",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.60.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ uranium-235,#64dd17
|
|||||||
crude-oil,#1a1a1a
|
crude-oil,#1a1a1a
|
||||||
heavy-oil,#4e342e
|
heavy-oil,#4e342e
|
||||||
light-oil,#ff8a65
|
light-oil,#ff8a65
|
||||||
petroleum-gas,#1a0030
|
petroleum-gas,#3d006b
|
||||||
lubricant,#1b5e20
|
lubricant,#1b5e20
|
||||||
sulfuric-acid,#b2dfdb
|
sulfuric-acid,#b2dfdb
|
||||||
water,#1565c0
|
water,#1565c0
|
||||||
@@ -96,7 +96,7 @@ medium-electric-pole,#687d8d
|
|||||||
big-electric-pole,#566c7b
|
big-electric-pole,#566c7b
|
||||||
small-electric-pole,#8c9eab
|
small-electric-pole,#8c9eab
|
||||||
small-iron-electric-pole,#8996a9
|
small-iron-electric-pole,#8996a9
|
||||||
steel-chest,#787878
|
steel-chest,#cb3f2a
|
||||||
iron-chest,#907c64
|
iron-chest,#907c64
|
||||||
wooden-chest,#634b3b
|
wooden-chest,#634b3b
|
||||||
transport-belt,#b3b3b3
|
transport-belt,#b3b3b3
|
||||||
@@ -157,10 +157,10 @@ kr-fuel-refinery,#bb580c
|
|||||||
kr-quarry-drill,#68804d
|
kr-quarry-drill,#68804d
|
||||||
kr-express-loader,#00838f
|
kr-express-loader,#00838f
|
||||||
kr-electric-mining-drill-mk2,#47382a
|
kr-electric-mining-drill-mk2,#47382a
|
||||||
kr-steel-pipe,#6e6e6e
|
kr-steel-pipe,#666666
|
||||||
kr-steel-pipe-to-ground,#787878
|
kr-steel-pipe-to-ground,#cb522a
|
||||||
kr-fluid-storage-2,#2415c1
|
kr-fluid-storage-2,#2415c1
|
||||||
kr-se-loader,#0039b3
|
kr-se-loader,#434960
|
||||||
beryllium,#81c784
|
beryllium,#81c784
|
||||||
beryllium-ore,#558b2f
|
beryllium-ore,#558b2f
|
||||||
beryllium-sulfate,#00695c
|
beryllium-sulfate,#00695c
|
||||||
@@ -241,11 +241,11 @@ mineral-water,#3236f5
|
|||||||
chlorine,#b0dbde
|
chlorine,#b0dbde
|
||||||
nitric-acid,#ffcc80
|
nitric-acid,#ffcc80
|
||||||
se-bioscrubber,#174f30
|
se-bioscrubber,#174f30
|
||||||
se-space-coolant-hot,#da6e16
|
se-space-coolant-hot,#f58700
|
||||||
se-space-water,#6c59f7
|
se-space-water,#6c59f7
|
||||||
se-chemical-gel,#ad25aa
|
se-chemical-gel,#ad25aa
|
||||||
se-vitalic-acid,#7ec44f
|
se-vitalic-acid,#7ec44f
|
||||||
se-vitalic-reagent,#6db342
|
se-vitalic-reagent,#49de17
|
||||||
se-vitalic-epoxy,#5d9f38
|
se-vitalic-epoxy,#5d9f38
|
||||||
se-neural-gel,#b39ddb
|
se-neural-gel,#b39ddb
|
||||||
se-neural-gel-2,#9575cd
|
se-neural-gel-2,#9575cd
|
||||||
@@ -300,7 +300,7 @@ se-meteor-defence-ammo,#ed533b
|
|||||||
se-dynamic-emitter,#3913aa
|
se-dynamic-emitter,#3913aa
|
||||||
se-gammaray-detector,#009bb3
|
se-gammaray-detector,#009bb3
|
||||||
se-pylon-substation,#ffff52
|
se-pylon-substation,#ffff52
|
||||||
se-rocket-launch-pad,#707070
|
se-rocket-launch-pad,#6b6b6b
|
||||||
se-rocket-landing-pad,#5e5e5e
|
se-rocket-landing-pad,#5e5e5e
|
||||||
se-cargo-rocket-fuel-tank,#ff7a05
|
se-cargo-rocket-fuel-tank,#ff7a05
|
||||||
se-cargo-rocket-cargo-pod,#ff985c
|
se-cargo-rocket-cargo-pod,#ff985c
|
||||||
@@ -316,13 +316,13 @@ se-beryllium-ore,#3b7a2a
|
|||||||
se-beryllium-sulfate,#005461
|
se-beryllium-sulfate,#005461
|
||||||
se-holmium-ore,#a53eac
|
se-holmium-ore,#a53eac
|
||||||
se-holmium-ore-crushed,#a93da0
|
se-holmium-ore-crushed,#a93da0
|
||||||
se-compact-beacon,#4612a5
|
se-compact-beacon,#491e7b
|
||||||
se-recycling-facility,#49a780
|
se-recycling-facility,#49a780
|
||||||
se-rocket-science-pack,#e3e3e3
|
se-rocket-science-pack,#e3e3e3
|
||||||
lubricant-barrel,#ffc107
|
lubricant-barrel,#ffc107
|
||||||
heavy-oil-barrel,#434028
|
heavy-oil-barrel,#434028
|
||||||
light-oil-barrel,#ffb56b
|
light-oil-barrel,#ffb56b
|
||||||
petroleum-gas-barrel,#240038
|
petroleum-gas-barrel,#4d007e
|
||||||
se-material-science-pack-1,#fffc42
|
se-material-science-pack-1,#fffc42
|
||||||
se-material-science-pack-2,#ffbe1a
|
se-material-science-pack-2,#ffbe1a
|
||||||
se-material-science-pack-3,#f5c800
|
se-material-science-pack-3,#f5c800
|
||||||
|
|||||||
|
Reference in New Issue
Block a user