12 Commits
v0.0.5 ... main

Author SHA1 Message Date
Sebastian Seedorf
dc5ded77d5 feat: lighten petroleum-gas and petroleum-gas-barrel colors, run fix-colors
Some checks failed
Build & Push / build (push) Failing after 58s
2026-06-04 16:14:42 +02:00
Sebastian Seedorf
4b05f2968e refactor: extract signals filter builder, add ESLint 10 config, fix low-hanging issues 2026-06-04 14:09:12 +02:00
Sebastian Seedorf
cf9bb33ecb chore: add prettier with config and format all files 2026-06-04 11:44:20 +02:00
Sebastian Seedorf
d212ae3f30 fix: nudge duplicate colors ±2 hue/light for uniqueness
All checks were successful
Build & Push / build (push) Successful in 3m30s
2026-06-04 11:06:41 +02:00
Sebastian Seedorf
955b0a890d feat: item color map, fix regex matching, fix sort order, fix resize handle 2026-06-04 11:04:58 +02:00
Sebastian Seedorf
b9377daa04 fix: visible resize handle via resizeConfig.handleComponent 2026-06-03 15:28:24 +02:00
Sebastian Seedorf
d6c2bb0b6a fix: backend ORDER BY CASE for series sort, default value_asc 2026-06-03 15:08:25 +02:00
Sebastian Seedorf
25db053a7b feat: draggable resizer between plot and legend in chart cards 2026-06-03 13:12:40 +02:00
Sebastian Seedorf
11b4e021fe fix: legend CSS via <style> tag instead of globals.css (Tailwind v4 tree-shaking) 2026-06-03 13:05:33 +02:00
Sebastian Seedorf
654d3849eb fix: legend CSS, SI prefix for x-axis ticks, table sort cleanup 2026-06-03 13:00:34 +02:00
Sebastian Seedorf
3506d1f6c5 feat: y-axis SI prefix, hide tick from legend, fix table sort 2026-06-03 12:52:45 +02:00
Sebastian Seedorf
8c83e8b8e8 feat: tag Docker images with semver alongside SHA and latest
All checks were successful
Build & Push / build (push) Successful in 3m48s
2026-06-02 16:15:30 +02:00
54 changed files with 3980 additions and 812 deletions

View File

@@ -34,18 +34,44 @@ jobs:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push app
uses: https://git.sebse.de/sebse/actions/docker-build-push@v1
- name: Extract metadata (app)
id: meta
uses: docker/metadata-action@v5
with:
image: ${{ env.IMAGE }}
build-context: ./web
images: ${{ env.IMAGE }}
tags: |
type=sha,prefix=,format=short
type=raw,value=latest
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push app
uses: docker/build-push-action@v5
with:
context: ./web
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=${{ env.IMAGE }}:latest
cache-to: type=inline
- name: Extract metadata (migrate)
id: meta-migrate
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_MIGRATE }}
tags: |
type=sha,prefix=,format=short
type=raw,value=latest
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push migrate
uses: https://git.sebse.de/sebse/actions/docker-build-push@v1
uses: docker/build-push-action@v5
with:
image: ${{ env.IMAGE_MIGRATE }}
build-context: ./web
dockerfile: ./web/Dockerfile.migrate
context: ./web
file: ./web/Dockerfile.migrate
push: true
tags: ${{ steps.meta-migrate.outputs.tags }}
cache-from: type=registry,ref=${{ env.IMAGE_MIGRATE }}:latest
cache-to: type=inline
- name: Package and push helm chart
uses: https://git.sebse.de/sebse/actions/helm-package-push@v1

6
web/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
out
dist
charts
public/*.csv

7
web/.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100
}

View File

@@ -1,5 +1,7 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

View File

@@ -20,3 +20,4 @@ DATABASE_URL=postgresql://factorio:factorio@localhost:5432/factorio npm run migr
# 4. Start dev server
cp .env.local.example .env.local # fill in API_TOKEN
npm run dev
```

View File

@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { type NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const PUT = withAuth(async (req: NextRequest, { params }) => {
const { id } = await params;
const body = await req.json();
const { item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active } = body;
const { item_key, item_key_is_regex, combinator, signal_type, condition, threshold, active } =
body;
const result = await pool.query(
`UPDATE alerts SET

View File

@@ -1,9 +1,11 @@
import { NextResponse } from 'next/server';
import pool from '@/lib/db';
import type { AlertConfig, TriggeredAlert } from '@/lib/types';
import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
import { getServerLocaleMap } from '@/lib/localeServer';
import { resolveName } from '@/lib/localization';
import type { AlertConfig, TriggeredAlert } from '@/lib/types';
export const GET = withAuth(async () => {
const alertsResult = await pool.query<AlertConfig>(
@@ -13,7 +15,10 @@ export const GET = withAuth(async () => {
if (alertsResult.rows.length === 0) return NextResponse.json([]);
const latestResult = await pool.query<{
combinator: string; item_key: string; green: number; red: number;
combinator: string;
item_key: string;
green: number;
red: number;
}>(
`SELECT DISTINCT ON (combinator, item_key) combinator, item_key, green, red
FROM signals
@@ -22,9 +27,7 @@ export const GET = withAuth(async () => {
const localeMap = getServerLocaleMap();
const latestMap = new Map(
latestResult.rows.map(r => [`${r.combinator}::${r.item_key}`, r]),
);
const latestMap = new Map(latestResult.rows.map((r) => [`${r.combinator}::${r.item_key}`, r]));
const triggered: TriggeredAlert[] = [];
@@ -49,7 +52,8 @@ export const GET = withAuth(async () => {
const value = alert.signal_type === 'green' ? vals.green : vals.red;
const fired = alert.condition === 'above' ? value > alert.threshold : value < alert.threshold;
if (fired) triggered.push({
if (fired)
triggered.push({
...alert,
current_value: value,
combinator_match: combinator,

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { type NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const GET = withAuth(async () => {
const result = await pool.query('SELECT * FROM alerts ORDER BY created_at DESC');
@@ -10,15 +11,16 @@ export const GET = withAuth(async () => {
export const POST = withAuth(async (req: NextRequest) => {
const body = await req.json();
const {
item_key, item_key_is_regex = false,
combinator = null, signal_type = 'green', condition, threshold,
item_key,
item_key_is_regex = false,
combinator = null,
signal_type = 'green',
condition,
threshold,
} = body;
if (!item_key || !condition || threshold === undefined) {
return NextResponse.json(
{ error: 'item_key, condition, threshold required' },
{ status: 400 },
);
return NextResponse.json({ error: 'item_key, condition, threshold required' }, { status: 400 });
}
const result = await pool.query(

View File

@@ -1,15 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { type NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const PUT = withAuth(async (req: NextRequest, { params }) => {
const { id } = await params;
const body = await req.json();
const {
title, pos_x, pos_y, width, height,
signal_type, chart_type, viz_type,
filter_items_regex, y_scale,
series_limit, order_by,
title,
pos_x,
pos_y,
width,
height,
signal_type,
chart_type,
viz_type,
filter_items_regex,
y_scale,
series_limit,
order_by,
} = body;
const hasFilterCombinators = 'filter_combinators' in body;
@@ -40,15 +49,28 @@ export const PUT = withAuth(async (req: NextRequest, { params }) => {
WHERE id = $23
RETURNING *`,
[
title, pos_x, pos_y, width, height, signal_type, chart_type, viz_type,
hasFilterCombinators, body.filter_combinators ?? null,
hasFilterItems, body.filter_items ?? null,
hasFilterItemsExclude, body.filter_items_exclude ?? null,
title,
pos_x,
pos_y,
width,
height,
signal_type,
chart_type,
viz_type,
hasFilterCombinators,
body.filter_combinators ?? null,
hasFilterItems,
body.filter_items ?? null,
hasFilterItemsExclude,
body.filter_items_exclude ?? null,
filter_items_regex,
hasYMin, body.y_min ?? null,
hasYMax, body.y_max ?? null,
hasYMin,
body.y_min ?? null,
hasYMax,
body.y_max ?? null,
y_scale,
series_limit, order_by,
series_limit,
order_by,
id,
],
);

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { type NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
export const GET = withAuth(async () => {
const result = await pool.query('SELECT * FROM charts ORDER BY pos_y ASC, pos_x ASC');
@@ -11,12 +12,22 @@ export const POST = withAuth(async (req: NextRequest) => {
const body = await req.json();
const {
title,
pos_x = 0, pos_y = 0, width = 2, height = 4,
signal_type = 'both', chart_type = 'signals', viz_type = 'line',
filter_combinators = null, filter_items = null,
filter_items_exclude = null, filter_items_regex = false,
y_min = null, y_max = null, y_scale = 'linear',
series_limit = 20, order_by = 'time',
pos_x = 0,
pos_y = 0,
width = 2,
height = 4,
signal_type = 'both',
chart_type = 'signals',
viz_type = 'line',
filter_combinators = null,
filter_items = null,
filter_items_exclude = null,
filter_items_regex = false,
y_min = null,
y_max = null,
y_scale = 'linear',
series_limit = 20,
order_by = 'value_asc',
} = body;
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
@@ -28,9 +39,25 @@ export const POST = withAuth(async (req: NextRequest) => {
y_min,y_max,y_scale,series_limit,order_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *`,
[title,pos_x,pos_y,width,height,signal_type,chart_type,viz_type,
filter_combinators,filter_items,filter_items_exclude,filter_items_regex,
y_min,y_max,y_scale,series_limit,order_by],
[
title,
pos_x,
pos_y,
width,
height,
signal_type,
chart_type,
viz_type,
filter_combinators,
filter_items,
filter_items_exclude,
filter_items_regex,
y_min,
y_max,
y_scale,
series_limit,
order_by,
],
);
return NextResponse.json(result.rows[0], { status: 201 });
});

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { type NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/apiHelpers';
import pool from '@/lib/db';
interface CircuitNetwork {
green: Record<string, number>;
@@ -47,7 +48,15 @@ export const POST = withAuth(async (req: NextRequest, { params }) => {
let idx = 1;
for (const key of allKeys) {
placeholders.push(`($${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++})`);
values.push(realTime, game_tick, combinator, key, green[key] ?? 0, red[key] ?? 0, logistic[key] ?? null);
values.push(
realTime,
game_tick,
combinator,
key,
green[key] ?? 0,
red[key] ?? 0,
logistic[key] ?? null,
);
}
await client.query(

View File

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

View File

@@ -1,8 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';
import { withAuth, buildItemFilter } from '@/lib/apiHelpers';
import pool from '@/lib/db';
import { withAuth } from '@/lib/apiHelpers';
import { getServerLocaleMap } from '@/lib/localeServer';
import { matchKeys } from '@/lib/localization';
export const GET = withAuth(async (req: NextRequest) => {
const p = req.nextUrl.searchParams;
@@ -13,93 +12,59 @@ export const GET = withAuth(async (req: NextRequest) => {
const from = p.get('from');
const to = p.get('to');
const useRegex = p.get('regex') === 'true';
const orderBy = p.get('order_by') ?? 'time';
const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null;
const orderBy = p.get('order_by') ?? 'value_asc';
const limitStr = p.get('limit');
const limit = limitStr ? parseInt(limitStr, 10) : null;
const conditions: string[] = [];
const values: unknown[] = [];
let i = 1;
const param = { current: 1 };
if (combinators.length > 0) {
conditions.push(`combinator = ANY($${i++})`);
conditions.push(`combinator = ANY($${param.current++})`);
values.push(combinators);
}
if (itemsWhitelist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
// Each pattern is expanded to matching keys (tested against key AND localized name).
// Union all patterns — if a pattern matches nothing, it contributes no keys.
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length === 0) return NextResponse.json([]);
conditions.push(`item_key = ANY($${i++})`);
values.push(keys);
} else {
conditions.push(`item_key = ANY($${i++})`);
values.push(itemsWhitelist);
}
}
const wlClause = buildItemFilter(itemsWhitelist, useRegex, true, values, param);
if (wlClause) conditions.push(wlClause);
if (itemsBlacklist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
// If blacklist pattern matches nothing, nothing to exclude — skip condition
if (keys.length > 0) {
conditions.push(`item_key != ALL($${i++})`);
values.push(keys);
}
} else {
conditions.push(`item_key != ALL($${i++})`);
values.push(itemsBlacklist);
}
}
const blClause = buildItemFilter(itemsBlacklist, useRegex, false, values, param);
if (blClause) conditions.push(blClause);
if (from) { conditions.push(`real_time >= $${i++}`); values.push(new Date(from)); }
if (to) { conditions.push(`real_time <= $${i++}`); values.push(new Date(to)); }
if (from) {
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 valueCol = signalType === 'red' ? 'red' : 'green';
const selectCols =
signalType === 'green' ? 'real_time, game_tick, combinator, item_key, green'
: signalType === 'red' ? 'real_time, game_tick, combinator, item_key, red'
signalType === 'green'
? 'real_time, game_tick, combinator, item_key, green'
: signalType === 'red'
? 'real_time, game_tick, combinator, item_key, red'
: 'real_time, game_tick, combinator, item_key, green, red';
if ((orderBy === 'delta_asc' || orderBy === 'delta_desc') && limit !== null) {
const baseConditions: string[] = [];
const baseValues: unknown[] = [];
let j = 1;
const baseParam = { current: 1 };
if (combinators.length > 0) {
baseConditions.push(`combinator = ANY($${j++})`);
baseConditions.push(`combinator = ANY($${baseParam.current++})`);
baseValues.push(combinators);
}
if (itemsWhitelist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length === 0) return NextResponse.json([]);
baseConditions.push(`item_key = ANY($${j++})`);
baseValues.push(keys);
} else {
baseConditions.push(`item_key = ANY($${j++})`);
baseValues.push(itemsWhitelist);
}
}
if (itemsBlacklist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length > 0) {
baseConditions.push(`item_key != ALL($${j++})`);
baseValues.push(keys);
}
} else {
baseConditions.push(`item_key != ALL($${j++})`);
baseValues.push(itemsBlacklist);
}
}
const wlBase = buildItemFilter(itemsWhitelist, useRegex, true, baseValues, baseParam);
if (wlBase) baseConditions.push(wlBase);
const blBase = buildItemFilter(itemsBlacklist, useRegex, false, baseValues, baseParam);
if (blBase) baseConditions.push(blBase);
const baseWhere = baseConditions.length > 0 ? `WHERE ${baseConditions.join(' AND ')}` : '';
const baseWhereAnd = baseConditions.length > 0 ? `AND ${baseConditions.join(' AND ')}` : '';
@@ -132,30 +97,45 @@ export const GET = withAuth(async (req: NextRequest) => {
SELECT combinator, item_key, delta
FROM deltas
ORDER BY delta ${orderBy === 'delta_asc' ? 'ASC' : 'DESC'}
LIMIT $${j}
LIMIT $${baseParam.current}
`;
const deltaResult = await pool.query<{ combinator: string; item_key: string; delta: number }>(
deltaQuery,
[...baseValues, limit],
);
const deltaResult = await pool.query<{
combinator: string;
item_key: string;
delta: number;
}>(deltaQuery, [...baseValues, limit]);
const top = deltaResult.rows;
if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map((_, idx) =>
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
const seriesConditions = top.map(
(_, idx) =>
`(combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1})`,
);
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const orderCase = top
.map(
(_, idx) =>
`WHEN combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1} THEN ${idx}`,
)
.join(' ');
const result = await pool.query(
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY real_time ASC`,
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
`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])],
);
return NextResponse.json(result.rows);
}
if ((orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') && limit !== null) {
const latestVals = await pool.query<{ combinator: string; item_key: string; val: number }>(
if (
(orderBy === 'value_asc' || orderBy === 'value_desc' || orderBy === 'abs_desc') &&
limit !== null
) {
const latestVals = await pool.query<{
combinator: string;
item_key: string;
val: number;
}>(
`SELECT DISTINCT ON (combinator, item_key)
combinator, item_key, ${valueCol} AS val
FROM signals ${where}
@@ -166,18 +146,26 @@ export const GET = withAuth(async (req: NextRequest) => {
let sorted = latestVals.rows;
if (orderBy === 'value_asc') sorted = [...sorted].sort((a, b) => a.val - b.val);
if (orderBy === 'value_desc') sorted = [...sorted].sort((a, b) => b.val - a.val);
if (orderBy === 'abs_desc') sorted = [...sorted].sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
if (orderBy === 'abs_desc')
sorted = [...sorted].sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
const top = sorted.slice(0, limit);
if (top.length === 0) return NextResponse.json([]);
const seriesConditions = top.map((_, idx) =>
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
const seriesConditions = top.map(
(_, idx) =>
`(combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1})`,
);
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
const orderCase = top
.map(
(_, idx) =>
`WHEN combinator = $${param.current + idx * 2} AND item_key = $${param.current + idx * 2 + 1} THEN ${idx}`,
)
.join(' ');
const result = await pool.query(
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY real_time ASC`,
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
`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])],
);
return NextResponse.json(result.rows);
}

View File

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

View File

@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
:root {
--background: #111827;

View File

@@ -1,14 +1,12 @@
import type { Metadata } from "next";
import "./globals.css";
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: "Factorio Dashboard",
description: "Factorio signal monitor",
title: 'Factorio Dashboard',
description: 'Factorio signal monitor',
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col">{children}</body>

View File

@@ -1,15 +1,17 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { AppProvider, useApp } from '@/lib/context';
import { setToken, fetchAlerts } from '@/lib/api';
import { getLocaleMap } from '@/lib/localization';
import type { AlertConfig } from '@/lib/types';
import { useEffect, useState, Suspense } from 'react';
import type { LocaleMap } from '@/lib/localization';
import TimeRangeSelector from '@/components/TimeRangeSelector';
import type { AlertConfig } from '@/lib/types';
import AlertPanel from '@/components/AlertPanel';
import Dashboard from '@/components/Dashboard';
import TimeRangeSelector from '@/components/TimeRangeSelector';
import { setToken, fetchAlerts } from '@/lib/api';
import { AppProvider, useApp } from '@/lib/context';
import { getLocaleMap } from '@/lib/localization';
function AppShell({ alerts }: { alerts: AlertConfig[] }) {
const { triggeredAlerts } = useApp();
@@ -72,9 +74,7 @@ function DashboardApp() {
if (!ready) {
return (
<div className="flex min-h-screen items-center justify-center text-gray-400">
Loading
</div>
<div className="flex min-h-screen items-center justify-center text-gray-400">Loading</div>
);
}

139
web/bin/fix-colors.ts Normal file
View File

@@ -0,0 +1,139 @@
import { readFileSync, writeFileSync } from 'fs';
const CSV = 'public/factorio_item_colors.csv';
function hexToHsl(h: string): [number, number, number] {
const r = parseInt(h.slice(1, 3), 16) / 255,
g = parseInt(h.slice(3, 5), 16) / 255,
b = parseInt(h.slice(5, 7), 16) / 255;
const mx = Math.max(r, g, b),
mn = Math.min(r, g, b),
l = (mx + mn) / 2;
if (mx === mn) return [0, 0, Math.round(l * 100)];
const d = mx - mn,
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let hue: number;
if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (mx === g) hue = ((b - r) / d + 2) * 60;
else hue = ((r - g) / d + 4) * 60;
return [Math.round(hue), Math.round(s * 100), Math.round(l * 100)];
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s,
x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
m = l - c / 2;
let r = 0,
g = 0,
b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
const to = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, '0');
return '#' + to(r) + to(g) + to(b);
}
function djb2(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
return h >>> 0;
}
function hueDist(a: number, b: number): number {
const d = Math.abs(a - b);
return Math.min(d, 360 - d);
}
let rawText: string;
try {
rawText = readFileSync(CSV, 'utf-8');
} catch (e) {
console.error('Failed to read CSV:', e);
process.exit(1);
}
const lines = rawText.trim().split('\n');
const header = lines[0];
const raw = lines
.slice(1)
.filter((l) => l.trim())
.map((l) => {
const [k, c] = l.split(',');
return { key: k.trim(), color: c.trim() };
});
// Dedup by key
const seen = new Set<string>();
const rows: typeof raw = [];
for (const r of raw) {
if (seen.has(r.key)) continue;
seen.add(r.key);
rows.push(r);
}
const n = rows.length;
const parent = Array.from({ length: n }, (_, i) => i);
function find(x: number): number {
while (parent[x] !== x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
function union(a: number, b: number) {
parent[find(a)] = find(b);
}
const hsl = rows.map((r, i) => ({ ...r, hsl: hexToHsl(r.color), idx: i }));
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
if (
hueDist(hsl[i].hsl[0], hsl[j].hsl[0]) <= 0.5 &&
Math.abs(hsl[i].hsl[2] - hsl[j].hsl[2]) <= 0.5
)
union(i, j);
}
}
const groups = new Map<number, typeof hsl>();
for (let i = 0; i < n; i++) {
const root = find(i);
const group = groups.get(root) ?? [];
if (!groups.has(root)) groups.set(root, group);
group.push(hsl[i]);
}
let fixed = 0;
for (const [, items] of groups) {
if (items.length < 2) continue;
items.sort((a, b) => a.idx - b.idx);
const [oh, os, ol] = items[0].hsl;
for (let i = 1; i < items.length; i++) {
const hash = djb2(items[i].key);
const h = (oh + ((hash % 5) - 2) + i * 7 + 360) % 360;
const l = Math.max(0, Math.min(100, ol + (((hash >> 4) % 5) - 2)));
items[i].color = hslToHex(h, os, l);
fixed++;
}
}
hsl.sort((a, b) => a.idx - b.idx);
writeFileSync(CSV, header + '\n' + hsl.map((r) => `${r.key},${r.color}`).join('\n') + '\n');
if (fixed) console.log(`Fixed ${fixed} close colors`);

View File

@@ -3,4 +3,4 @@ name: factorio-signal-exporter
description: Factorio Signal Exporter — Next.js dashboard with TimescaleDB
type: application
version: 0.0.0-dev
appVersion: "latest"
appVersion: 'latest'

View File

@@ -13,10 +13,10 @@ imagePullSecrets: []
app:
replicaCount: 1
## API token for ingest POST and dashboard GET ?token=
apiToken: ""
apiToken: ''
## If set, use this existing K8s secret instead of creating one.
## Secret must contain keys: API_TOKEN, DATABASE_URL
existingSecret: ""
existingSecret: ''
db:
## TimescaleDB credentials — used to build DATABASE_URL and configure the StatefulSet
@@ -25,7 +25,7 @@ db:
name: factorio
port: 5432
storage: 10Gi
storageClassName: ""
storageClassName: ''
service:
type: ClusterIP
@@ -33,10 +33,10 @@ service:
ingress:
enabled: false
className: ""
className: ''
host: factorio.example.com
tls: false
tlsSecretName: ""
tlsSecretName: ''
annotations: {}
resources: {}

View File

@@ -1,18 +1,22 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useApp } from '@/lib/context';
import { fetchAlerts, createAlert, updateAlert, deleteAlert } from '@/lib/api';
import { resolveName, resolveKey } from '@/lib/localization';
import type { AlertConfig } from '@/lib/types';
import { fetchAlerts, createAlert, updateAlert, deleteAlert } from '@/lib/api';
import { useApp } from '@/lib/context';
import { resolveName, resolveKey } from '@/lib/localization';
interface Props {
open: boolean;
onClose: () => void;
}
const inputCls = 'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
const selectCls = 'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
const inputCls =
'w-full bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-indigo-500';
const selectCls =
'flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm text-white focus:outline-none';
interface AlertFormState {
itemKey: string;
@@ -24,7 +28,14 @@ interface 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 {
@@ -50,7 +61,11 @@ function Tooltip({ text }: { text: string }) {
}
function AlertForm({
value, onChange, onSubmit, onCancel, submitLabel,
value,
onChange,
onSubmit,
onCancel,
submitLabel,
}: {
value: AlertFormState;
onChange: (s: AlertFormState) => void;
@@ -62,36 +77,64 @@ function AlertForm({
<div className="space-y-2">
<input
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'}
className={inputCls}
/>
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
<input type="checkbox" checked={value.itemKeyIsRegex}
onChange={e => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
className="accent-indigo-500" />
<input
type="checkbox"
checked={value.itemKeyIsRegex}
onChange={(e) => onChange({ ...value, itemKeyIsRegex: e.target.checked })}
className="accent-indigo-500"
/>
Item key is regex
</label>
<input value={value.combinator} onChange={e => onChange({ ...value, combinator: e.target.value })}
placeholder="combinator (empty = all)" className={inputCls} />
<input
value={value.combinator}
onChange={(e) => onChange({ ...value, combinator: e.target.value })}
placeholder="combinator (empty = all)"
className={inputCls}
/>
<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="red">Red</option>
</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="above">Above</option>
</select>
</div>
<input type="number" value={value.threshold} onChange={e => onChange({ ...value, threshold: e.target.value })}
placeholder="threshold" className={inputCls} />
<input
type="number"
value={value.threshold}
onChange={(e) => onChange({ ...value, threshold: e.target.value })}
placeholder="threshold"
className={inputCls}
/>
<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}
</button>
{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
</button>
)}
@@ -125,7 +168,9 @@ export default function AlertPanel({ open, onClose }: Props) {
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
osc.start();
osc.stop(ctx.currentTime + 0.4);
} catch { /* AudioContext blocked */ }
} catch {
/* AudioContext blocked */
}
}
prevTriggeredCount.current = triggeredAlerts.length;
}, [triggeredAlerts.length]);
@@ -146,7 +191,7 @@ export default function AlertPanel({ open, onClose }: Props) {
condition: newForm.condition,
threshold: parseInt(newForm.threshold, 10),
});
setAlerts(a => [created, ...a]);
setAlerts((a) => [created, ...a]);
setNewForm(emptyForm());
await refreshAlerts();
}
@@ -161,14 +206,14 @@ export default function AlertPanel({ open, onClose }: Props) {
condition: editForm.condition,
threshold: parseInt(editForm.threshold, 10),
});
setAlerts(a => a.map(x => x.id === id ? updated : x));
setAlerts((a) => a.map((x) => (x.id === id ? updated : x)));
setEditingId(null);
await refreshAlerts();
}
async function handleDelete(id: string) {
await deleteAlert(id);
setAlerts(a => a.filter(x => x.id !== id));
setAlerts((a) => a.filter((x) => x.id !== id));
await refreshAlerts();
}
@@ -178,21 +223,33 @@ export default function AlertPanel({ open, onClose }: Props) {
}
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">
<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>
{triggeredAlerts.length > 0 && (
<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) => (
<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 className="text-red-400">({a.combinator_match})</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 && (
<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">
<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 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.map(a => (
{alerts.map((a) => (
<div key={a.id} className="bg-gray-800 rounded p-2 text-xs text-gray-300">
{editingId === a.id ? (
<AlertForm
@@ -221,18 +283,32 @@ export default function AlertPanel({ open, onClose }: Props) {
) : (
<div className="flex justify-between items-start">
<div>
<span className="font-medium text-white">{resolveName(a.item_key, localeMap)}</span>
{a.item_key_is_regex && (
<Tooltip text={`Regex pattern: /${a.item_key}/`} />
)}
<span className="font-medium text-white">
{resolveName(a.item_key, localeMap)}
</span>
{a.item_key_is_regex && <Tooltip text={`Regex pattern: /${a.item_key}/`} />}
{a.combinator && <span className="text-gray-400"> @ {a.combinator}</span>}
<br />
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>[{a.signal_type}]</span>
{' '}{a.condition} {a.threshold}
<span className={a.signal_type === 'green' ? 'text-green-400' : 'text-red-400'}>
[{a.signal_type}]
</span>{' '}
{a.condition} {a.threshold}
</div>
<div className="flex gap-1 ml-2 shrink-0">
<button onClick={() => startEdit(a)} className="text-gray-500 hover:text-indigo-400"></button>
<button onClick={() => handleDelete(a.id)} className="text-gray-500 hover:text-red-400"></button>
<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>
)}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, useState, useCallback } from 'react';
interface HeaderProps {
title: string;
@@ -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">
<span className="text-sm font-medium text-gray-200 truncate">{title}</span>
<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 onClick={onDelete} className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
<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>
);
@@ -28,26 +40,76 @@ interface CardShellProps extends HeaderProps {
empty: boolean;
children: React.ReactNode;
/** Ref to the div where the uPlot legend will be mounted */
legendContainerRef?: React.RefObject<HTMLDivElement>;
legendContainerRef?: React.RefObject<HTMLDivElement | null>;
}
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) {
export function CardShell({
title,
onEdit,
onDelete,
empty,
children,
legendContainerRef,
}: CardShellProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [legendHeight, setLegendHeight] = useState<number | null>(null);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const el = legendContainerRef?.current;
if (!el) return;
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
function onMove(ev: MouseEvent) {
if (!dragRef.current) return;
const delta = dragRef.current.startY - ev.clientY;
const containerH = containerRef.current?.offsetHeight ?? 400;
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
setLegendHeight(newH);
}
function onUp() {
dragRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[legendContainerRef],
);
return (
<div 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} />
{empty ? (
<EmptyState />
) : (
<>
{/* Plot fills remaining space */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Legend scrolls independently, capped at 25% card height */}
<div className="flex-1 min-h-0">{children}</div>
{legendContainerRef && (
<>
<div
onMouseDown={handleMouseDown}
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
/>
<div
ref={legendContainerRef}
className="shrink-0 max-h-[25%] overflow-y-auto border-t border-gray-800 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={
legendHeight != null
? { height: legendHeight, maxHeight: legendHeight }
: { maxHeight: '25%' }
}
/>
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
</>
)}
</>
)}
</div>

View File

@@ -7,11 +7,25 @@ interface Props {
export default function DividerCard({ title, onEdit, onDelete }: Props) {
return (
<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 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 onClick={onDelete} className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
<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>
);

View File

@@ -1,15 +1,26 @@
'use client';
import 'uplot/dist/uPlot.min.css';
import { useState, useEffect } from 'react';
import uPlot from 'uplot';
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
import { CardShell } from './CardShell';
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
import {
makeYScale,
makeAnnotationHooks,
makeSignalsSeries,
makeSignalsAxes,
CURSOR_NO_DRAG,
} from './plotHelpers';
import { buildSeriesData } from './seriesData';
import { usePlot } from './usePlot';
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
import type { ColorMap } from '@/lib/colors';
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow, TimeMode } from '@/lib/types';
import { getColorMap } from '@/lib/colors';
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
interface Props {
config: ChartConfig;
@@ -21,8 +32,21 @@ interface Props {
onDelete: () => void;
}
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
export default function SignalsChart({
config,
rows,
sessions,
alerts,
timeMode,
onEdit,
onDelete,
}: Props) {
const { localeMap } = useApp();
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
useEffect(() => {
getColorMap().then(setColorMap);
}, []);
const { containerRef, legendRef } = usePlot(
(el, w, h, lRef) => {
@@ -30,12 +54,15 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode,
if (!data) return null;
const { keys, allXs, data: seriesData } = data;
const sessionXs = sessions.map(s => timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000);
const sessionXs = sessions.map((s) =>
timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000,
);
const alertThresholds = alerts
.filter(a => config.signal_type === 'both' || config.signal_type === a.signal_type)
.map(a => a.threshold);
.filter((a) => config.signal_type === 'both' || config.signal_type === a.signal_type)
.map((a) => a.threshold);
return new uPlot({
return new uPlot(
{
width: w,
height: h,
cursor: CURSOR_NO_DRAG,
@@ -44,20 +71,39 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode,
if (lRef.current) lRef.current.appendChild(legendEl);
},
},
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap)),
axes: makeSignalsAxes(timeMode),
series: makeSignalsSeries(keys, timeMode, (key) => resolveName(key, localeMap), colorMap),
axes: makeSignalsAxes(timeMode, locale),
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
}, [allXs, ...seriesData], el);
},
[rows, sessions, alerts, config, timeMode, localeMap],
[allXs, ...seriesData],
el,
);
},
[
rows,
sessions,
alerts,
config.signal_type,
config.y_min,
config.y_max,
config.y_scale,
timeMode,
localeMap,
],
);
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" />
</CardShell>
);

View File

@@ -1,8 +1,15 @@
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
import { useState, useEffect } from 'react';
import { CardShell } from './CardShell';
import type { ColorMap } from '@/lib/colors';
import type { ChartConfig, SignalRow } from '@/lib/types';
import { getColorMap, getItemColor } from '@/lib/colors';
import { useApp } from '@/lib/context';
import { formatSI } from '@/lib/formatNumber';
import { resolveName } from '@/lib/localization';
interface Props {
config: ChartConfig;
rows: SignalRow[];
@@ -12,12 +19,16 @@ interface Props {
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
const { localeMap } = useApp();
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
useEffect(() => {
getColorMap().then(setColorMap);
}, []);
const latest = new Map<string, { green?: number; red?: number }>();
for (const row of rows) {
latest.set(`${row.combinator}::${row.item_key}`, { green: row.green, red: row.red });
}
const tableRows = [...latest.entries()].sort((a, b) => (a[1].green ?? 0) - (b[1].green ?? 0));
const tableRows = [...latest.entries()].slice(0, config.series_limit);
return (
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0}>
@@ -27,8 +38,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
<tr>
<th className="text-left px-2 py-1">Item</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 !== 'green' && <th className="text-right px-2 py-1 text-red-400">Red (NP)</th>}
{config.signal_type !== 'red' && (
<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>
</thead>
<tbody>
@@ -36,16 +51,24 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
const [combinator, item_key] = key.split('::');
return (
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
<td className="px-2 py-0.5">{resolveName(item_key, localeMap)}</td>
<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"
style={{ backgroundColor: getItemColor(item_key, colorMap) }}
/>
{resolveName(item_key, localeMap)}
</td>
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
{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'}`}>
{vals.green?.toLocaleString() ?? '--'}
<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) : '--'}
</td>
)}
{config.signal_type !== 'green' && (
<td className="px-2 py-0.5 text-right font-mono text-orange-400">
{vals.red?.toLocaleString() ?? '--'}
{vals.red != null ? formatSI(vals.red, undefined, 0) : '--'}
</td>
)}
</tr>

View File

@@ -2,11 +2,14 @@
import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot';
import { CardShell } from './CardShell';
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
import { usePlot } from './usePlot';
import type { ChartConfig, UpsRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
import type { ChartConfig, UpsRow, TimeMode } from '@/lib/types';
import { formatSI } from '@/lib/formatNumber';
interface Props {
config: ChartConfig;
@@ -26,17 +29,22 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
? a.game_tick - b.game_tick
: new Date(a.real_time).getTime() - new Date(b.real_time).getTime(),
);
const xs = sorted.map(r => timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000);
const ys = sorted.map(r => r.ups);
const xs = sorted.map((r) =>
timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000,
);
const ys = sorted.map((r) => r.ups);
const xAxis: uPlot.Axis = {
...AXIS_BASE,
...(timeMode === 'real' && {
values: (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
}),
values:
timeMode === 'real'
? (_u, vals) =>
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))),
};
return new uPlot({
return new uPlot(
{
width: w,
height: h,
cursor: CURSOR_NO_DRAG,
@@ -49,18 +57,30 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
],
axes: [xAxis, { ...AXIS_BASE }],
axes: [
xAxis,
{ ...AXIS_BASE, values: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))) },
],
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
}, [xs, ys], el);
},
[xs, ys],
el,
);
},
[upsRows, config.y_min, config.y_max, config.y_scale, timeMode],
);
return (
<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" />
</CardShell>
);

View File

@@ -1,9 +1,16 @@
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow, UpsRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
import UpsChart from './UpsChart';
import DividerCard from './DividerCard';
import SignalsChart from './SignalsChart';
import TableViz from './TableViz';
import DividerCard from './DividerCard';
import UpsChart from './UpsChart';
import type {
AlertConfig,
ChartConfig,
SessionBoundary,
SignalRow,
UpsRow,
TimeMode,
} from '@/lib/types';
export interface ChartCardProps {
config: ChartConfig;
@@ -18,8 +25,17 @@ export interface ChartCardProps {
export default function ChartCard(props: ChartCardProps) {
const { config } = props;
if (config.chart_type === 'divider') return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
if (config.chart_type === 'divider')
return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
if (config.chart_type === 'ups') return <UpsChart {...props} />;
if (config.viz_type === 'table') return <TableViz config={props.config} rows={props.rows} 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}
/>
);
return <SignalsChart {...props} />;
}

View File

@@ -1,18 +1,9 @@
import uPlot from 'uplot';
import type { ColorMap } from '@/lib/colors';
import type { ChartConfig } from '@/lib/types';
import type uPlot from 'uplot';
// --- Color helpers ---
function djb2(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
return h >>> 0;
}
function hslColor(key: string): string {
const hue = djb2(key) % 360;
return `hsl(${hue},70%,60%)`;
}
import { getItemColor } from '@/lib/colors';
import { formatSI } from '@/lib/formatNumber';
const SEMANTIC_GREEN = '#4ade80';
const SEMANTIC_RED = '#f87171';
@@ -27,6 +18,7 @@ export function getSeriesStyle(
uCombs: number,
uItems: number,
uSigs: number,
colorMap: ColorMap = new Map(),
): SeriesStyle {
const [combinator, item_key, sig] = key.split('::');
@@ -34,9 +26,15 @@ export function getSeriesStyle(
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
}
if (uItems > 1) {
return { color: hslColor(item_key), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
return {
color: getItemColor(item_key, colorMap),
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
};
}
return { color: hslColor(combinator), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
return {
color: getItemColor(combinator, colorMap),
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
};
}
/**
@@ -84,9 +82,11 @@ export function makeYScale(
return {
distr: 4,
asinh: 1,
...(yMin !== null || yMax !== null ? {
...(yMin !== null || yMax !== null
? {
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
} : {}),
}
: {}),
};
}
@@ -94,10 +94,10 @@ export function makeYScale(
return {
dir: 1,
range: (_u, dataMin, dataMax) => {
const lo = yMin ?? (dataMin ?? 0);
const hi = yMax ?? (dataMax ?? 1);
const lo = yMin ?? dataMin ?? 0;
const hi = yMax ?? dataMax ?? 1;
if (lo === hi) return [lo - 1, hi + 1];
const pad = (yMin == null || yMax == null) ? Math.abs(hi - lo) * 0.05 : 0;
const pad = yMin == null || yMax == null ? Math.abs(hi - lo) * 0.05 : 0;
return [lo - pad, hi + pad];
},
};
@@ -108,7 +108,8 @@ export function makeAnnotationHooks(
alertThresholds: number[],
): uPlot.Options['hooks'] {
return {
draw: [(u) => {
draw: [
(u) => {
const { ctx, bbox } = u;
ctx.save();
@@ -117,18 +118,25 @@ export function makeAnnotationHooks(
ctx.setLineDash([4, 4]);
for (const sx of sessionXs) {
const cx = Math.round(u.valToPos(sx, 'x', true));
ctx.beginPath(); ctx.moveTo(cx, bbox.top); ctx.lineTo(cx, bbox.top + bbox.height); ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx, bbox.top);
ctx.lineTo(cx, bbox.top + bbox.height);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(248,113,113,0.7)';
ctx.setLineDash([6, 3]);
for (const t of alertThresholds) {
const cy = Math.round(u.valToPos(t, 'y', true));
ctx.beginPath(); ctx.moveTo(bbox.left, cy); ctx.lineTo(bbox.left + bbox.width, cy); ctx.stroke();
ctx.beginPath();
ctx.moveTo(bbox.left, cy);
ctx.lineTo(bbox.left + bbox.width, cy);
ctx.stroke();
}
ctx.restore();
}],
},
],
};
}
@@ -136,10 +144,11 @@ export function makeSignalsSeries(
keys: string[],
timeMode: 'real' | 'tick',
resolveName: (key: string) => string,
colorMap: ColorMap = new Map(),
): uPlot.Series[] {
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
const uSigs = new Set(keys.map(k => k.split('::')[2])).size;
const uCombs = new Set(keys.map((k) => k.split('::')[0])).size;
const uItems = new Set(keys.map((k) => k.split('::')[1])).size;
const uSigs = new Set(keys.map((k) => k.split('::')[2])).size;
const xSeries: uPlot.Series = {
label: timeMode === 'tick' ? 'Tick' : 'Time',
@@ -151,9 +160,9 @@ export function makeSignalsSeries(
return [
xSeries,
...keys.map(k => {
...keys.map((k) => {
const [, item_key] = k.split('::');
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs);
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
return {
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
stroke: color,
@@ -164,15 +173,21 @@ export function makeSignalsSeries(
];
}
export function makeSignalsAxes(timeMode: 'real' | 'tick'): uPlot.Axis[] {
export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPlot.Axis[] {
return [
{
...AXIS_BASE,
...(timeMode === 'real' && {
values: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
}),
values:
timeMode === 'real'
? (_u: uPlot, vals: (number | null)[]) =>
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
: (_u: uPlot, vals: (number | null)[]) =>
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
},
{
...AXIS_BASE,
values: (_u: uPlot, vals: (number | null)[]) =>
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
},
{ ...AXIS_BASE },
];
}

View File

@@ -1,5 +1,4 @@
import type { SignalRow, ChartConfig } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
import type { SignalRow, ChartConfig, TimeMode } from '@/lib/types';
const MAX_SERIES = 80;
@@ -17,26 +16,37 @@ export function buildSeriesData(
const seriesMap = new Map<string, Map<number, number>>();
for (const row of rows) {
for (const [sig, val] of [['green', row.green], ['red', row.red]] as ['green' | 'red', number | undefined][]) {
for (const [sig, val] of [
['green', row.green],
['red', row.red],
] as ['green' | 'red', number | undefined][]) {
if (signalType !== 'both' && signalType !== sig) continue;
if (val === undefined) continue;
const key = `${row.combinator}::${row.item_key}::${sig}`;
const x = timeMode === 'tick'
const x =
timeMode === 'tick'
? parseInt(row.game_tick, 10)
: new Date(row.real_time).getTime() / 1000;
if (!seriesMap.has(key)) seriesMap.set(key, new Map());
seriesMap.get(key)!.set(x, val);
seriesMap.get(key)?.set(x, val);
}
}
if (seriesMap.size === 0) return null;
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
const allXs = [...new Set(keys.flatMap(k => [...seriesMap.get(k)!.keys()]))].sort((a, b) => a - b);
const allXs = [
...new Set(
keys.flatMap((k) => {
const m = seriesMap.get(k);
return m ? [...m.keys()] : [];
}),
),
].sort((a, b) => a - b);
const data = keys.map(k => {
const m = seriesMap.get(k)!;
return allXs.map(x => m.get(x)); // undefined = gap
const data = keys.map((k) => {
const m = seriesMap.get(k);
return m ? allXs.map((x) => m.get(x)) : []; // undefined = gap
});
return { keys, allXs, data };

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef } from 'react';
import uPlot from 'uplot';
import { useCallback, useEffect, useRef, type DependencyList } from 'react';
import type uPlot from 'uplot';
export type BuildFn = (
el: HTMLDivElement,
w: number,
h: number,
legendRef: React.RefObject<HTMLDivElement>,
legendRef: React.RefObject<HTMLDivElement | null>,
) => uPlot | null;
/** Converts a data index to the pixel x position uPlot expects for setCursor */
@@ -24,18 +25,21 @@ function idxToPixel(plot: uPlot, idx: number): number {
*/
export function usePlot(
build: BuildFn,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deps: any[],
): { containerRef: React.RefObject<HTMLDivElement | null>; legendRef: React.RefObject<HTMLDivElement> } {
deps: DependencyList,
): {
containerRef: React.RefObject<HTMLDivElement | null>;
legendRef: React.RefObject<HTMLDivElement | null>;
} {
const containerRef = useRef<HTMLDivElement | null>(null);
const legendRef = useRef<HTMLDivElement>(null!);
const legendRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(null);
const lastIdxRef = useRef<number>(0);
const rebuild = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const w = el.clientWidth, h = el.clientHeight;
const w = el.clientWidth,
h = el.clientHeight;
if (w < 10 || h < 10) return;
plotRef.current?.destroy();
@@ -60,12 +64,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);
useEffect(() => {
rebuild();
return () => { plotRef.current?.destroy(); plotRef.current = null; };
return () => {
plotRef.current?.destroy();
plotRef.current = null;
};
}, [rebuild]);
useEffect(() => {

View File

@@ -1,9 +1,11 @@
'use client';
import { useState } from 'react';
import type { ChartConfig } from '@/lib/types';
import { useApp } from '@/lib/context';
import { resolveKey } from '@/lib/localization';
import type { ChartConfig } from '@/lib/types';
type DraftChart = Omit<ChartConfig, 'id'>;
@@ -21,23 +23,30 @@ const inputCls =
* to raw item_keys. Unknown tokens are kept as-is.
*/
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;
return arr.map(t => resolveKey(t, reverseMap));
return arr.map((t) => resolveKey(t, reverseMap));
}
export default function ChartEditor({ initial, onSave, onClose }: Props) {
const { reverseMap } = useApp();
const [title, setTitle] = useState(initial?.title ?? '');
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(initial?.chart_type ?? 'signals');
const [chartType, setChartType] = useState<ChartConfig['chart_type']>(
initial?.chart_type ?? 'signals',
);
const [vizType, setVizType] = useState<ChartConfig['viz_type']>(initial?.viz_type ?? 'line');
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(initial?.signal_type ?? 'both');
const [signalType, setSignalType] = useState<ChartConfig['signal_type']>(
initial?.signal_type ?? 'both',
);
const [combinators, setCombinators] = useState((initial?.filter_combinators ?? []).join(', '));
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'time');
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'value_asc');
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
@@ -46,7 +55,10 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
const [height, setHeight] = useState(initial?.height ?? 4);
function splitCombinators(): string[] | null {
const arr = combinators.split(',').map(x => x.trim()).filter(Boolean);
const arr = combinators
.split(',')
.map((x) => x.trim())
.filter(Boolean);
return arr.length > 0 ? arr : null;
}
@@ -56,10 +68,14 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
// Regex mode: store pattern exactly as typed — server expands via matchKeys at query time.
// Non-regex mode: resolve each comma-token (localized name or raw key) to raw key.
const filter_items = useRegex
? (whitelist.trim() ? [whitelist.trim()] : null)
? whitelist.trim()
? [whitelist.trim()]
: null
: normalizeList(whitelist, reverseMap);
const filter_items_exclude = useRegex
? (blacklist.trim() ? [blacklist.trim()] : null)
? blacklist.trim()
? [blacklist.trim()]
: null
: normalizeList(blacklist, reverseMap);
onSave({
@@ -69,7 +85,8 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
signal_type: signalType,
pos_x: initial?.pos_x ?? 0,
pos_y: initial?.pos_y ?? 0,
width, height,
width,
height,
filter_combinators: chartType === 'divider' ? null : splitCombinators(),
filter_items: chartType === 'divider' ? null : filter_items,
filter_items_exclude: chartType === 'divider' ? null : filter_items_exclude,
@@ -86,44 +103,69 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
const isDivider = chartType === 'divider';
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
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">
{initial ? 'Edit Chart' : 'New Chart'}
</h2>
<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>
<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="ups">UPS / Game Tick Rate</option>
<option value="divider">Divider / Section Label</option>
</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`}>
<select
value={vizType}
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`}>
<select
value={signalType}
onChange={(e) => setSignalType(e.target.value as ChartConfig['signal_type'])}
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>
<select value={orderBy} onChange={e => setOrderBy(e.target.value as ChartConfig['order_by'])} className={`${inputCls} mb-3`}>
<option value="time">Time (no sort)</option>
<select
value={orderBy}
onChange={(e) => setOrderBy(e.target.value as ChartConfig['order_by'])}
className={`${inputCls} mb-3`}
>
<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>
@@ -131,69 +173,133 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
</select>
<label className="block text-sm text-gray-400 mb-1">Max series (lines)</label>
<input type="number" min={1} max={200} value={seriesLimit}
onChange={e => setSeriesLimit(Number(e.target.value))}
className={`${inputCls} mb-3`} />
<input
type="number"
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>
<input value={combinators} onChange={e => setCombinators(e.target.value)}
placeholder="nauvis, nauvis-orbit" className={`${inputCls} mb-3`} />
<label className="block text-sm text-gray-400 mb-1">
Combinators (comma-separated, empty = all)
</label>
<input
value={combinators}
onChange={(e) => setCombinators(e.target.value)}
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" />
<input
type="checkbox"
checked={useRegex}
onChange={(e) => setUseRegex(e.target.checked)}
className="accent-indigo-500"
/>
Use regex
</label>
</div>
<input value={whitelist} onChange={e => setWhitelist(e.target.value)}
<input
value={whitelist}
onChange={(e) => setWhitelist(e.target.value)}
placeholder={useRegex ? 'wissen.*|Iron Plate' : 'Iron Plate, copper-plate'}
className={`${inputCls} mb-1`} />
<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)}
className={`${inputCls} mb-1`}
/>
<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>
</>}
className={`${inputCls} mb-1`}
/>
<p className="text-xs text-gray-500 mb-3">
Blacklist localized names or item keys accepted
</p>
</>
)}
{!isDivider && <>
{!isDivider && (
<>
<div className="flex gap-3 mb-3">
<div className="flex-1">
<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} />
<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} />
<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`}>
<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-1">
<label className="block text-sm text-gray-400 mb-1">Width (16 cols)</label>
<input type="number" min={1} max={6} value={width}
onChange={e => setWidth(Number(e.target.value))} className={inputCls} />
<input
type="number"
min={1}
max={6}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className={inputCls}
/>
</div>
<div className="flex-1">
<label className="block text-sm text-gray-400 mb-1">Height (rows)</label>
<input type="number" min={2} max={20} value={height}
onChange={e => setHeight(Number(e.target.value))} className={inputCls} />
<input
type="number"
min={2}
max={20}
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
className={inputCls}
/>
</div>
</div>
<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 onClick={handleSave} className="px-4 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded">Save</button>
<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>

View File

@@ -2,15 +2,26 @@
import { useState, useEffect, useRef, useCallback } from 'react';
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 ChartEditor from './ChartEditor';
import type { ChartConfig, SignalRow, UpsRow, SessionBoundary, AlertConfig } from '@/lib/types';
import type { Layout, LayoutItem } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import {
fetchCharts,
createChart,
updateChart,
deleteChart,
fetchSignals,
fetchSessions,
fetchUps,
} from '@/lib/api';
import { useApp } from '@/lib/context';
const COLS = 6;
const ROW_HEIGHT = 80;
@@ -33,8 +44,12 @@ export default function Dashboard({ alerts }: Props) {
const refreshingRef = useRef(false);
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { chartsRef.current = charts; }, [charts]);
useEffect(() => { fetchCharts().then(setCharts); }, []);
useEffect(() => {
chartsRef.current = charts;
}, [charts]);
useEffect(() => {
fetchCharts().then(setCharts);
}, []);
const refreshData = useCallback(async () => {
if (refreshingRef.current) return;
@@ -44,24 +59,27 @@ export default function Dashboard({ alerts }: Props) {
refreshingRef.current = true;
try {
const { from, to } = getFromTo();
const signalCharts = current.filter(c => c.chart_type === 'signals');
const upsCharts = current.filter(c => c.chart_type === 'ups');
const signalCharts = current.filter((c) => c.chart_type === 'signals');
const upsCharts = current.filter((c) => c.chart_type === 'ups');
if (signalCharts.length === 0 && upsCharts.length === 0) return;
const [newSessions, ...results] = await Promise.all([
fetchSessions(from, to),
...signalCharts.map(c => fetchSignals({
...signalCharts.map((c) =>
fetchSignals({
combinator: c.filter_combinators ?? undefined,
item: c.filter_items ?? undefined,
exclude: c.filter_items_exclude ?? undefined,
signal: c.signal_type,
time_mode: timeMode,
from, to,
from,
to,
regex: c.filter_items_regex || undefined,
...(c.order_by !== 'time' ? { order_by: c.order_by, limit: c.series_limit } : {}),
})),
...upsCharts.map(c => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
}),
),
...upsCharts.map((c) => fetchUps({ combinator: c.filter_combinators?.[0], from, to })),
]);
setSessions(newSessions as SessionBoundary[]);
@@ -98,47 +116,56 @@ export default function Dashboard({ alerts }: Props) {
async function handleCreate(draft: Omit<ChartConfig, 'id'>) {
const created = await createChart(draft);
setCharts(cs => [...cs, created]);
setCharts((cs) => [...cs, created]);
setCreatingChart(false);
}
async function handleUpdate(id: string, draft: Omit<ChartConfig, 'id'>) {
const updated = await updateChart(id, draft);
setCharts(cs => cs.map(c => c.id === id ? updated : c));
setCharts((cs) => cs.map((c) => (c.id === id ? updated : c)));
setEditingChart(null);
}
async function handleDelete(id: string) {
await deleteChart(id);
setCharts(cs => cs.filter(c => c.id !== id));
setCharts((cs) => cs.filter((c) => c.id !== id));
}
function handleLayoutChange(layout: Layout) {
const items = layout as readonly LayoutItem[];
const changed = items.filter(item => {
const chart = chartsRef.current.find(c => c.id === item.i);
return chart && (
chart.pos_x !== item.x || chart.pos_y !== item.y ||
chart.width !== item.w || chart.height !== item.h
const changed = items.filter((item) => {
const chart = chartsRef.current.find((c) => c.id === item.i);
return (
chart &&
(chart.pos_x !== item.x ||
chart.pos_y !== item.y ||
chart.width !== item.w ||
chart.height !== item.h)
);
});
if (changed.length === 0) return;
setCharts(cs => cs.map(c => {
const l = changed.find(item => item.i === c.id);
setCharts((cs) =>
cs.map((c) => {
const l = changed.find((item) => item.i === c.id);
return l ? { ...c, pos_x: l.x, pos_y: l.y, width: l.w, height: l.h } : c;
}));
}),
);
if (layoutSaveTimer.current) clearTimeout(layoutSaveTimer.current);
layoutSaveTimer.current = setTimeout(() => {
changed.forEach(item =>
changed.forEach((item) =>
updateChart(item.i, { pos_x: item.x, pos_y: item.y, width: item.w, height: item.h }),
);
}, 500);
}
const layout: Layout = charts.map(c => ({
i: c.id, x: c.pos_x, y: c.pos_y, w: c.width, h: c.height,
const layout: Layout = charts.map((c) => ({
i: c.id,
x: c.pos_x,
y: c.pos_y,
w: c.width,
h: c.height,
minW: 1,
minH: c.chart_type === 'divider' ? 1 : 2,
}));
@@ -151,16 +178,31 @@ export default function Dashboard({ alerts }: Props) {
onLayoutChange={handleLayoutChange}
gridConfig={{ cols: COLS, rowHeight: ROW_HEIGHT, margin: [8, 8] }}
dragConfig={{ handle: '.drag-handle' }}
resizeConfig={{
handleComponent: (axis, ref) => (
<span
ref={ref}
className="react-resizable-handle react-resizable-handle-se"
style={{
borderRight: '3px solid #4b5563',
borderBottom: '3px solid #4b5563',
opacity: 0.6,
}}
/>
),
}}
>
{charts.map(c => (
{charts.map((c) => (
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
<ChartCard
config={c}
rows={signalData.get(c.id) ?? []}
upsRows={upsData.get(c.id) ?? []}
sessions={sessions}
alerts={alerts.filter(a =>
!c.filter_combinators || !a.combinator ||
alerts={alerts.filter(
(a) =>
!c.filter_combinators ||
!a.combinator ||
c.filter_combinators.includes(a.combinator),
)}
timeMode={timeMode}
@@ -172,6 +214,7 @@ export default function Dashboard({ alerts }: Props) {
</GridLayout>
<button
aria-label="Create chart"
onClick={() => setCreatingChart(true)}
className="fixed bottom-6 right-6 w-12 h-12 bg-indigo-600 hover:bg-indigo-500 text-white rounded-full text-2xl shadow-lg flex items-center justify-center z-30"
>
@@ -184,7 +227,7 @@ export default function Dashboard({ alerts }: Props) {
{editingChart && (
<ChartEditor
initial={editingChart}
onSave={draft => handleUpdate(editingChart.id, draft)}
onSave={(draft) => handleUpdate(editingChart.id, draft)}
onClose={() => setEditingChart(null)}
/>
)}

View File

@@ -1,8 +1,9 @@
'use client';
import { useApp } from '@/lib/context';
import type { TimeRange, TimeMode } from '@/lib/types';
import { useApp } from '@/lib/context';
const RANGES: TimeRange[] = ['30m', '1h', '6h', '24h', '7d', '30d'];
export default function TimeRangeSelector() {
@@ -11,7 +12,7 @@ export default function TimeRangeSelector() {
return (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex rounded overflow-hidden border border-gray-700">
{RANGES.map(r => (
{RANGES.map((r) => (
<button
key={r}
onClick={() => setTimeRange(r)}
@@ -27,7 +28,7 @@ export default function TimeRangeSelector() {
</div>
<div className="flex rounded overflow-hidden border border-gray-700">
{(['real', 'tick'] as TimeMode[]).map(m => (
{(['real', 'tick'] as TimeMode[]).map((m) => (
<button
key={m}
onClick={() => setTimeMode(m)}

View File

@@ -7,7 +7,7 @@ services:
POSTGRES_PASSWORD: factorio
POSTGRES_DB: factorio
ports:
- "5432:5432"
- '5432:5432'
volumes:
- db_data:/var/lib/postgresql/data

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

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

View File

@@ -1,20 +1,39 @@
import type { ChartConfig, AlertConfig, TriggeredAlert, SignalRow, SessionBoundary, UpsRow, TimeMode } from './types';
import type {
ChartConfig,
AlertConfig,
TriggeredAlert,
SignalRow,
SessionBoundary,
UpsRow,
TimeMode,
} from './types';
let _token = '';
export function setToken(token: string) { _token = token; }
export function setToken(token: string) {
_token = token;
}
function url(path: string, params: Record<string, string | string[] | boolean | number | undefined> = {}) {
const u = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000');
function url(
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);
for (const [k, v] of Object.entries(params)) {
if (v === undefined) continue;
if (Array.isArray(v)) v.forEach(val => u.searchParams.append(k, String(val)));
if (Array.isArray(v)) v.forEach((val) => u.searchParams.append(k, String(val)));
else u.searchParams.set(k, String(v));
}
return u.toString();
}
async function get<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));
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
@@ -45,7 +64,11 @@ export function fetchSignals(params: {
return get('/api/signals', params as Record<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);
}
@@ -53,13 +76,31 @@ export function fetchSessions(from: string, to: string): Promise<SessionBoundary
return get('/api/sessions', { from, to });
}
export function fetchCharts(): Promise<ChartConfig[]> { return get('/api/charts'); }
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 fetchCharts(): Promise<ChartConfig[]> {
return get('/api/charts');
}
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 createAlert(body: Omit<AlertConfig, 'id' | 'active'>): Promise<AlertConfig> { 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'); }
export function fetchAlerts(): Promise<AlertConfig[]> {
return get('/api/alerts');
}
export function createAlert(body: Omit<AlertConfig, 'id' | 'active'>): Promise<AlertConfig> {
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');
}

View File

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

View File

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

37
web/lib/colors.ts Normal file
View File

@@ -0,0 +1,37 @@
export type ColorMap = Map<string, string>;
declare global {
var __colorMapCache: ColorMap | undefined;
}
export function parseColorCsv(text: string): ColorMap {
const map: ColorMap = new Map();
const lines = text.split(/\r?\n/).slice(1);
for (const line of lines) {
if (!line.trim()) continue;
const [key, color] = line.split(',');
if (key && color) map.set(key.trim(), color.trim());
}
return map;
}
export async function getColorMap(): Promise<ColorMap> {
if (globalThis.__colorMapCache) return globalThis.__colorMapCache;
try {
const res = await fetch('/factorio_item_colors.csv');
globalThis.__colorMapCache = res.ok ? parseColorCsv(await res.text()) : new Map();
} catch {
globalThis.__colorMapCache = new Map();
}
return globalThis.__colorMapCache;
}
function djb2(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
return h >>> 0;
}
export function getItemColor(key: string, colorMap: ColorMap): string {
return colorMap.get(key) ?? `hsl(${djb2(key) % 360},70%,60%)`;
}

View File

@@ -1,17 +1,13 @@
'use client';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import type { TimeRange, TimeMode, TriggeredAlert } from './types';
import { TIME_RANGE_MS } from './types';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { checkAlerts } from './api';
import { buildReverseMap } from './localization';
import { TIME_RANGE_MS } from './types';
import type { LocaleMap, ReverseMap } from './localization';
import type { TimeRange, TimeMode, TriggeredAlert } from './types';
interface AppContextValue {
timeRange: TimeRange;
@@ -28,14 +24,12 @@ interface AppContextValue {
const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({
token: _token,
localeMap,
children,
}: {
token: string;
localeMap: LocaleMap;
children: React.ReactNode;
}) {
} & { token?: string }) {
const [timeRange, setTimeRange] = useState<TimeRange>('6h');
const [timeMode, setTimeMode] = useState<TimeMode>('real');
const [triggeredAlerts, setTriggeredAlerts] = useState<TriggeredAlert[]>([]);
@@ -54,20 +48,30 @@ export function AppProvider({
useEffect(() => {
let cancelled = false;
const poll = () => checkAlerts().then(a => { if (!cancelled) setTriggeredAlerts(a); });
const poll = () =>
checkAlerts().then((a) => {
if (!cancelled) setTriggeredAlerts(a);
});
poll();
const id = setInterval(poll, 30_000);
return () => { cancelled = true; clearInterval(id); };
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
return (
<AppContext.Provider
value={{
timeRange, setTimeRange,
timeMode, setTimeMode,
triggeredAlerts, refreshAlerts,
timeRange,
setTimeRange,
timeMode,
setTimeMode,
triggeredAlerts,
refreshAlerts,
getFromTo,
localeMap, reverseMap,
localeMap,
reverseMap,
}}
>
{children}

View File

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

23
web/lib/formatNumber.ts Normal file
View File

@@ -0,0 +1,23 @@
const SI_THRESHOLDS = [
{ limit: 1_000_000_000, divisor: 1_000_000_000, suffix: 'G' },
{ limit: 1_000_000, divisor: 1_000_000, suffix: 'M' },
{ limit: 1_000, divisor: 1_000, suffix: 'K' },
] as const;
export function formatSI(v: number, locale?: string, fractionDigits?: number): string {
const abs = Math.abs(v);
const fd = fractionDigits ?? 3;
for (const { limit, divisor, suffix } of SI_THRESHOLDS) {
if (abs >= limit) {
const formatted = new Intl.NumberFormat(locale, {
maximumFractionDigits: fd,
minimumFractionDigits: 0,
}).format(v / divisor);
return `${formatted}${suffix}`;
}
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: fractionDigits != null ? fractionDigits : 0,
minimumFractionDigits: 0,
}).format(v);
}

View File

@@ -1,9 +1,13 @@
import fs from 'fs';
import path from 'path';
import { parseCsv } from './localization';
import type { LocaleMap } from './localization';
declare global { var __serverLocaleCache: LocaleMap | undefined; }
declare global {
var __serverLocaleCache: LocaleMap | undefined;
}
/**
* Loads and merges EN + DE locale CSVs from the public directory.
@@ -23,7 +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;
return merged;
}

View File

@@ -20,8 +20,10 @@ function splitCsvLine(line: string): string[] {
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
else inQuote = !inQuote;
if (inQuote && line[i + 1] === '"') {
cur += '"';
i++;
} else inQuote = !inQuote;
} else if (ch === ',' && !inQuote) {
cols.push(cur);
cur = '';
@@ -33,7 +35,9 @@ function splitCsvLine(line: string): string[] {
return cols;
}
declare global { var __localeCache: LocaleMap | undefined; }
declare global {
var __localeCache: LocaleMap | undefined;
}
async function loadCsv(path: string): Promise<LocaleMap> {
try {

View File

@@ -1,14 +1,12 @@
import pool from '@/lib/db';
import type { SessionBoundary } from '@/lib/types';
import pool from '@/lib/db';
/**
* Returns session-start timestamps where any gap > 30 min exists
* in the global tick_timing timeline.
*/
export async function getSessionBoundaries(
from: Date,
to: Date,
): Promise<SessionBoundary[]> {
export async function getSessionBoundaries(from: Date, to: Date): Promise<SessionBoundary[]> {
const result = await pool.query<{ real_time: Date; game_tick: string }>(
`SELECT real_time, game_tick
FROM tick_timing
@@ -20,10 +18,12 @@ export async function getSessionBoundaries(
const rows = result.rows;
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),
}];
},
];
for (let i = 1; i < rows.length; i++) {
if (rows[i].real_time.getTime() - rows[i - 1].real_time.getTime() > 30 * 60 * 1000) {

View File

@@ -2,7 +2,9 @@
exports.up = (pgm) => {
pgm.sql(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`);
pgm.createTable('signals', {
pgm.createTable(
'signals',
{
real_time: { type: 'timestamptz', notNull: true },
game_tick: { type: 'bigint', notNull: true },
combinator: { type: 'text', notNull: true },
@@ -10,24 +12,36 @@ exports.up = (pgm) => {
green: { type: 'integer', notNull: true, default: 0 },
red: { type: 'integer', notNull: true, default: 0 },
logistic: { type: 'integer' },
}, { ifNotExists: true });
},
{ ifNotExists: true },
);
pgm.sql(`SELECT create_hypertable('signals', 'real_time', if_not_exists => true)`);
pgm.sql(`CREATE INDEX IF NOT EXISTS signals_combinator_item_key_real_time_idx ON signals (combinator, item_key, real_time DESC)`);
pgm.sql(
`CREATE INDEX IF NOT EXISTS signals_combinator_item_key_real_time_idx ON signals (combinator, item_key, real_time DESC)`,
);
pgm.sql(`CREATE INDEX IF NOT EXISTS signals_game_tick_idx ON signals (game_tick DESC)`);
pgm.sql(`SELECT add_retention_policy('signals', INTERVAL '30 days', if_not_exists => true)`);
pgm.createTable('tick_timing', {
pgm.createTable(
'tick_timing',
{
real_time: { type: 'timestamptz', notNull: true },
game_tick: { type: 'bigint', notNull: true },
combinator: { type: 'text', notNull: true },
}, { ifNotExists: true });
},
{ ifNotExists: true },
);
pgm.sql(`SELECT create_hypertable('tick_timing', 'real_time', if_not_exists => true)`);
pgm.sql(`CREATE INDEX IF NOT EXISTS tick_timing_combinator_real_time_idx ON tick_timing (combinator, real_time DESC)`);
pgm.sql(
`CREATE INDEX IF NOT EXISTS tick_timing_combinator_real_time_idx ON tick_timing (combinator, real_time DESC)`,
);
pgm.sql(`SELECT add_retention_policy('tick_timing', INTERVAL '30 days', if_not_exists => true)`);
pgm.createTable('charts', {
pgm.createTable(
'charts',
{
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
title: { type: 'text', notNull: true },
pos_x: { type: 'integer', notNull: true, default: 0 },
@@ -46,7 +60,9 @@ exports.up = (pgm) => {
series_limit: { type: 'integer', notNull: true, default: 20 },
order_by: { type: 'text', notNull: true, default: 'time' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
}, { ifNotExists: true });
},
{ ifNotExists: true },
);
// Use DO blocks so constraints are idempotent on existing DBs
pgm.sql(`DO $$ BEGIN
@@ -58,14 +74,16 @@ exports.up = (pgm) => {
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'));
ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`DO $$ BEGIN
ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.createTable('alerts', {
pgm.createTable(
'alerts',
{
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
item_key: { type: 'text', notNull: true },
item_key_is_regex: { type: 'boolean', notNull: true, default: false },
@@ -75,7 +93,9 @@ exports.up = (pgm) => {
threshold: { type: 'integer', notNull: true },
active: { type: 'boolean', notNull: true, default: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
}, { ifNotExists: true });
},
{ ifNotExists: true },
);
pgm.sql(`DO $$ BEGIN
ALTER TABLE alerts ADD CONSTRAINT alerts_signal_type_check CHECK (signal_type IN ('green','red'));
@@ -85,10 +105,14 @@ exports.up = (pgm) => {
ALTER TABLE alerts ADD CONSTRAINT alerts_condition_check CHECK (condition IN ('above','below'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.createTable('settings', {
pgm.createTable(
'settings',
{
key: { type: 'text', primaryKey: true },
value: { type: 'text', notNull: true },
}, { ifNotExists: true });
},
{ ifNotExists: true },
);
};
exports.down = () => Promise.resolve();

View File

@@ -7,12 +7,16 @@ exports.up = (pgm) => {
EXCEPTION WHEN duplicate_object THEN NULL; END $$`);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'))`);
pgm.sql(
`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc','delta_asc','delta_desc'))`,
);
};
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_order_by_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc'))`);
pgm.sql(
`ALTER TABLE charts ADD CONSTRAINT charts_order_by_check CHECK (order_by IN ('time','value_asc','value_desc','abs_desc'))`,
);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_y_scale_check`);
pgm.sql(`ALTER TABLE charts DROP COLUMN IF EXISTS y_scale`);
};

View File

@@ -1,10 +1,14 @@
/** @type {import('node-pg-migrate').MigrationBuilder} */
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'))`);
pgm.sql(
`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','table'))`,
);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups','divider'))`);
pgm.sql(
`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups','divider'))`,
);
// Migrate any existing stacked charts to line
pgm.sql(`UPDATE charts SET viz_type = 'line' WHERE viz_type = 'stacked'`);
@@ -12,8 +16,12 @@ exports.up = (pgm) => {
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_viz_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'))`);
pgm.sql(
`ALTER TABLE charts ADD CONSTRAINT charts_viz_type_check CHECK (viz_type IN ('line','stacked','table'))`,
);
pgm.sql(`ALTER TABLE charts DROP CONSTRAINT IF EXISTS charts_chart_type_check`);
pgm.sql(`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`);
pgm.sql(
`ALTER TABLE charts ADD CONSTRAINT charts_chart_type_check CHECK (chart_type IN ('signals','ups'))`,
);
};

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: "standalone",
output: 'standalone',
};
export default nextConfig;

1845
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"dev": "next dev --turbopack",
"build": "next build",
"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:down": "node-pg-migrate down -m migrations",
"migrate:create": "node-pg-migrate create -m migrations"
@@ -22,13 +24,20 @@
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^10.4.1",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react-x": "^5.8.11",
"globals": "^17.6.0",
"node-pg-migrate": "^8.0.4",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1"
}
}

View File

@@ -1,6 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
'@tailwindcss/postcss': {},
},
};

View File

@@ -0,0 +1,379 @@
item_key,color
copper-ore,#bf8040
copper-plate,#b87333
copper-cable,#e65100
iron-ore,#8d6e63
iron-plate,#bdbdbd
iron-gear-wheel,#a0a0a0
iron-stick,#888888
steel-plate,#757575
steel-gear-wheel,#5a5a5a
steel-beam,#424242
stone,#a1887f
stone-brick,#b8956a
coal,#37474f
uranium-ore,#66bb6a
uranium-238,#7cb342
uranium-235,#64dd17
crude-oil,#1a1a1a
heavy-oil,#4e342e
light-oil,#ff8a65
petroleum-gas,#3d006b
lubricant,#1b5e20
sulfuric-acid,#b2dfdb
water,#1565c0
steam,#e1f5fe
wood,#6d4c41
raw-wood,#3e2723
plastic-bar,#b0bec5
sulfur,#fff176
explosives,#ef5350
battery,#ff9800
empty-barrel,#90a4ae
filled-barrel,#546e7a
electronic-circuit,#4caf50
advanced-circuit,#e53935
processing-unit,#1565d2
automation-science-pack,#d32f2f
logistic-science-pack,#43a047
military-science-pack,#616161
chemical-science-pack,#00acc1
production-science-pack,#ff8f00
utility-science-pack,#7b1fa2
space-science-pack,#f48fb1
pipe,#78909c
engine-unit,#795548
electric-engine-unit,#4fc3f7
flying-robot-frame,#5e35b1
rocket-fuel,#ff5722
rocket-control-unit,#2e7d32
low-density-structure,#bcaaa4
heat-pipe,#bf360c
heat-exchanger,#263238
steam-turbine,#455a64
concrete,#9e9e9e
refined-concrete,#6d6d6d
landfill,#388e3c
cliff-explosives,#d84315
nuclear-fuel,#00c853
solid-fuel,#0d47a1
grenade,#5d4037
cluster-grenade,#c62828
landmine,#4a148c
fish,#29b6f6
glass,#4dd0e1
rail,#9e9e9e
rail-signal,#449c53
rail-chain-signal,#e33d2b
train-stop,#323f48
locomotive,#ce3c2c
cargo-wagon,#937966
fluid-wagon,#1450b8
artillery-wagon,#3d3d3d
artillery-turret,#383838
flamethrower-turret,#cc4e14
gun-turret,#5c5c5c
laser-turret,#e45d2f
radar,#378b45
roboport,#2196f3
construction-robot,#449c5f
logistic-robot,#133baa
speed-module,#f44336
speed-module-2,#e91e63
speed-module-3,#9c27b0
speed-module-5,#4c1178
effectivity-module,#4cae70
effectivity-module-2,#3a9256
effectivity-module-3,#30823f
effectivity-module-4,#1d6327
productivity-module,#ffb005
productivity-module-2,#ef6c00
productivity-module-3,#eb6600
productivity-module-5,#c5500d
beacon,#2078f3
substation,#b427b4
medium-electric-pole,#687d8d
big-electric-pole,#566c7b
small-electric-pole,#8c9eab
small-iron-electric-pole,#8996a9
steel-chest,#cb3f2a
iron-chest,#907c64
wooden-chest,#634b3b
transport-belt,#b3b3b3
fast-transport-belt,#50b1f7
express-transport-belt,#e56f34
underground-belt,#bfbfbf
fast-underground-belt,#4193f6
express-underground-belt,#e48d2f
splitter,#bdbdbd
fast-splitter,#3277f5
express-splitter,#dd8a1d
long-handed-inserter,#80604d
fast-inserter,#80664d
burner-inserter,#7c694b
inserter,#7c704b
stack-inserter,#47322a
burner-mining-drill,#706943
electric-mining-drill,#737345
area-mining-drill,#7c804d
electric-furnace,#384751
industrial-furnace,#475666
assembling-machine-3,#666666
centrifuge,#546578
chemical-plant,#0092c7
oil-refinery,#0f0f0f
pumpjack,#433428
offshore-pump,#162eca
boiler,#9e9e9e
steam-engine,#6c7d93
solar-panel,#ffb74d
accumulator,#0c59e9
lamp,#ffeb3b
constant-combinator,#495369
decider-combinator,#4d556f
arithmetic-combinator,#585b7e
power-switch,#ffb405
programmable-speaker,#751a89
aai-signal-receiver,#323c48
aai-signal-transmitter,#2a2f3c
textplate-small-copper,#e68600
used-up-uranium-fuel-cell,#34384b
uranium-fuel-cell,#5db669
electric-motor,#5976f7
motor,#4652f6
automation-core,#1712a5
iron-beam,#878787
kr-advanced-solar-panel,#ffc23d
kr-advanced-transport-belt,#26c6da
kr-advanced-loader,#0070a3
kr-advanced-splitter,#00bcd4
kr-superior-inserter,#74804d
kr-superior-filter-inserter,#5d4437
kr-superior-long-inserter,#6f804d
kr-superior-long-filter-inserter,#5d4737
kr-superior-underground-belt,#0054a8
kr-superior-loader,#0048bd
kr-fuel-refinery,#bb580c
kr-quarry-drill,#68804d
kr-express-loader,#00838f
kr-electric-mining-drill-mk2,#47382a
kr-steel-pipe,#666666
kr-steel-pipe-to-ground,#cb522a
kr-fluid-storage-2,#2415c1
kr-se-loader,#434960
beryllium,#81c784
beryllium-ore,#558b2f
beryllium-sulfate,#00695c
holmium,#ba68c8
holmium-ore,#ab47bc
holmium-solution,#e1bee7
cryonite,#00a8e0
cryonite-rod,#29aedb
cryonite-solution,#80deea
iridium,#cfd8dc
iridium-ore,#689f38
iridium-ingot,#e0e0e0
vulcanite,#ff7043
vulcanite-block,#e64a19
vulcanite-powder,#ffab91
imersite,#8e24aa
imersite-crystal,#283593
imersite-powder,#ce93d8
vita,#8bc34a
vita-extract,#19572a
vita-germination,#006064
naquium,#ffd54f
naquium-ore,#ffca28
naquium-ingot,#fff9c4
methane-ice,#b2ebf2
core-fragment,#4db6ac
rare-metals,#b0b0b0
raw-rare-metals,#a8a8a8
raw-imersite,#821e8f
sand,#c2b280
silicon,#6c7893
quartz,#e6e6e6
coke,#474747
se-copper-ingot,#b08130
se-iron-ingot,#84755c
se-steel-ingot,#707070
se-beryllium-ingot,#87c995
se-holmium-ingot,#c056c2
se-iridium-ingot,#d6dce1
se-cryonite-rod,#2397d1
se-cryonite-slush,#7bccea
se-vulcanite-crushed,#ff7b29
se-vulcanite-enriched,#ff6d29
se-vulcanite-block,#dc5a18
imersium-plate,#8e1d9a
se-kr-imersium-sulfide,#9f1e96
se-kr-fine-imersite-powder,#d58bd5
enriched-iron,#7e7358
electronic-components,#4eb183
empty-data-card,#512da8
heat-shielding,#00bfa5
thermodynamic-boiler,#311b92
cryogenic-plant,#0097a7
core-drill,#3d5afe
core-miner,#1a237e
se-quantum-processor,#123db5
se-holmium-cable,#c256bb
se-holmium-solenoid,#c45aae
se-superconductive-cable,#1c176e
se-data-storage-substrate,#4527a0
se-machine-learning-data,#3949ab
se-empty-data,#5c6bc0
se-broken-data,#7986cb
se-junk-data,#9fa8da
se-scrap,#757575
se-contaminated-scrap,#3a3122
se-genetic-data,#64b979
se-significant-data,#3f9751
se-experimental-genetic-data,#308249
se-atomic-data,#008fd6
se-star-probe-data,#ffea4d
se-significant-specimen,#00bdb6
se-specimen,#26a69a
se-bio-sludge,#2c7749
se-nutrient-gel,#005c5a
se-nutrient-gel-barrel,#004d40
mineral-water,#3236f5
chlorine,#b0dbde
nitric-acid,#ffcc80
se-bioscrubber,#174f30
se-space-coolant-hot,#f58700
se-space-water,#6c59f7
se-chemical-gel,#ad25aa
se-vitalic-acid,#7ec44f
se-vitalic-reagent,#49de17
se-vitalic-epoxy,#5d9f38
se-neural-gel,#b39ddb
se-neural-gel-2,#9575cd
se-plasma-stream,#d50000
se-proton-stream,#e040fb
se-ion-stream,#651fff
se-particle-stream,#304ffe
se-vitamelange-extract,#427a2a
se-vitamelange-spice,#498c31
se-water-ice,#e3f2fd
lithium,#9eadb7
lithium-chloride,#b0b8c4
lithium-sulfur-battery,#db9ad7
fertilizer,#4a432b
biomass,#33691e
biomethanol,#419b5d
se-surface-teleporter,#24166a
se-observation-frame,#5eab3f
se-observation-frame-blank,#5eb83d
se-core-fragment-se-beryllium,#6bbd80
se-core-fragment-se-cryonite,#0070e0
se-core-fragment-se-holmium,#ca6dad
se-core-fragment-se-imersite,#931f80
se-core-fragment-se-iridium,#c7ced6
se-core-fragment-se-naquium,#ffdf29
se-core-fragment-se-vita,#62c247
se-core-fragment-se-vulcanite,#ca6a16
se-core-fragment-omni,#46a5aa
se-core-fragment-se-iridium-ore,#409f38
se-core-fragment-se-vitamelange,#4ac144
se-aeroframe-bulkhead,#4ea9b7
se-aeroframe-scaffold,#26a2a6
se-aeroframe-pole,#00897b
se-heavy-girder,#686f8d
se-heavy-bearing,#607d8b
se-heavy-composite,#484766
se-space-pipe,#4e9bb7
se-space-transport-belt,#2280c9
se-space-underground-belt,#001cbd
se-space-splitter,#0057d1
se-space-accumulator,#734bf7
se-space-solar-panel,#ffe438
se-space-solar-panel-2,#ffa726
se-space-elevator-cable,#4b2494
se-space-pipe-to-ground,#5191b8
se-space-mirror,#d9d9d9
se-space-rail,#787a9b
se-space-platform-scaffold,#b8b8b8
se-space-probe-rocket,#ff913d
se-space-capsule,#42a5f5
se-meteor-defence-ammo,#ed533b
se-dynamic-emitter,#3913aa
se-gammaray-detector,#009bb3
se-pylon-substation,#ffff52
se-rocket-launch-pad,#6b6b6b
se-rocket-landing-pad,#5e5e5e
se-cargo-rocket-fuel-tank,#ff7a05
se-cargo-rocket-cargo-pod,#ff985c
se-cargo-rocket-section,#ffa570
se-cargo-rocket-section-packed,#ffdd75
se-lifesupport-canister,#7a32f5
se-used-lifesupport-canister,#929bb0
se-canister,#898fa9
se-magnetic-canister,#ca6da3
se-iridium-ore,#348c31
se-iridium-ore-crushed,#35973d
se-beryllium-ore,#3b7a2a
se-beryllium-sulfate,#005461
se-holmium-ore,#a53eac
se-holmium-ore-crushed,#a93da0
se-compact-beacon,#491e7b
se-recycling-facility,#49a780
se-rocket-science-pack,#e3e3e3
lubricant-barrel,#ffc107
heavy-oil-barrel,#434028
light-oil-barrel,#ffb56b
petroleum-gas-barrel,#4d007e
se-material-science-pack-1,#fffc42
se-material-science-pack-2,#ffbe1a
se-material-science-pack-3,#f5c800
se-material-science-pack-4,#e67e00
se-material-testing-pack,#f57c00
se-material-insight,#ffe0b2
se-material-catalogue-1,#efff42
se-material-catalogue-2,#ffd11a
se-material-catalogue-3,#f5e400
se-material-catalogue-4,#e6a100
se-astronomic-science-pack-1,#565abd
se-astronomic-science-pack-2,#373aa4
se-astronomic-insight,#c5cae9
se-astronomic-catalogue-1,#6c61c2
se-astronomic-catalogue-2,#423bb0
se-astronomic-catalogue-3,#2a2c98
se-astronomic-catalogue-4,#371b83
se-biological-science-pack-1,#69f0ae
se-biological-science-pack-2,#00e676
se-biological-science-pack-3,#00ad5f
se-biological-science-pack-4,#009624
se-biological-insight,#b9f6ca
se-biological-catalogue-1,#6af0c1
se-biological-catalogue-2,#00e68a
se-biological-catalogue-3,#00c77e
se-biological-catalogue-4,#009431
se-energy-science-pack-1,#f17493
se-energy-science-pack-2,#f06292
se-energy-science-pack-3,#ec407a
se-energy-science-pack-4,#d81b60
se-energy-insight,#fce4ec
se-energy-catalogue-1,#f17482
se-energy-catalogue-2,#ee446f
se-energy-catalogue-3,#e92549
se-energy-catalogue-4,#bf183f
se-deep-space-science-pack-1,#bfa6de
se-deep-space-science-pack-2,#a27cd0
se-deep-space-science-pack-3,#7e57c2
se-deep-space-science-pack-4,#6f37b9
se-kr-matter-science-pack-1,#ffef8a
se-kr-matter-science-pack-2,#e0ff57
se-kr-matter-liberation-data,#ffe494
blank-tech-card,#898ca9
singularity-tech-card,#351566
ltn-combinator,#4c1b83
ltn-stop,#383a51
ltn-delivery-address,#286c4b
ltn-provider-stack-threshold,#f34a1b
ltn-requester-stack-threshold,#1643f3
ltn-provider-threshold,#d45d35
ltn-requester-threshold,#5c12a5
ltn-max-trains,#621281
ltn-max-train-length,#66127d
ltn-locked-slots,#534d6f
1 item_key color
2 copper-ore #bf8040
3 copper-plate #b87333
4 copper-cable #e65100
5 iron-ore #8d6e63
6 iron-plate #bdbdbd
7 iron-gear-wheel #a0a0a0
8 iron-stick #888888
9 steel-plate #757575
10 steel-gear-wheel #5a5a5a
11 steel-beam #424242
12 stone #a1887f
13 stone-brick #b8956a
14 coal #37474f
15 uranium-ore #66bb6a
16 uranium-238 #7cb342
17 uranium-235 #64dd17
18 crude-oil #1a1a1a
19 heavy-oil #4e342e
20 light-oil #ff8a65
21 petroleum-gas #3d006b
22 lubricant #1b5e20
23 sulfuric-acid #b2dfdb
24 water #1565c0
25 steam #e1f5fe
26 wood #6d4c41
27 raw-wood #3e2723
28 plastic-bar #b0bec5
29 sulfur #fff176
30 explosives #ef5350
31 battery #ff9800
32 empty-barrel #90a4ae
33 filled-barrel #546e7a
34 electronic-circuit #4caf50
35 advanced-circuit #e53935
36 processing-unit #1565d2
37 automation-science-pack #d32f2f
38 logistic-science-pack #43a047
39 military-science-pack #616161
40 chemical-science-pack #00acc1
41 production-science-pack #ff8f00
42 utility-science-pack #7b1fa2
43 space-science-pack #f48fb1
44 pipe #78909c
45 engine-unit #795548
46 electric-engine-unit #4fc3f7
47 flying-robot-frame #5e35b1
48 rocket-fuel #ff5722
49 rocket-control-unit #2e7d32
50 low-density-structure #bcaaa4
51 heat-pipe #bf360c
52 heat-exchanger #263238
53 steam-turbine #455a64
54 concrete #9e9e9e
55 refined-concrete #6d6d6d
56 landfill #388e3c
57 cliff-explosives #d84315
58 nuclear-fuel #00c853
59 solid-fuel #0d47a1
60 grenade #5d4037
61 cluster-grenade #c62828
62 landmine #4a148c
63 fish #29b6f6
64 glass #4dd0e1
65 rail #9e9e9e
66 rail-signal #449c53
67 rail-chain-signal #e33d2b
68 train-stop #323f48
69 locomotive #ce3c2c
70 cargo-wagon #937966
71 fluid-wagon #1450b8
72 artillery-wagon #3d3d3d
73 artillery-turret #383838
74 flamethrower-turret #cc4e14
75 gun-turret #5c5c5c
76 laser-turret #e45d2f
77 radar #378b45
78 roboport #2196f3
79 construction-robot #449c5f
80 logistic-robot #133baa
81 speed-module #f44336
82 speed-module-2 #e91e63
83 speed-module-3 #9c27b0
84 speed-module-5 #4c1178
85 effectivity-module #4cae70
86 effectivity-module-2 #3a9256
87 effectivity-module-3 #30823f
88 effectivity-module-4 #1d6327
89 productivity-module #ffb005
90 productivity-module-2 #ef6c00
91 productivity-module-3 #eb6600
92 productivity-module-5 #c5500d
93 beacon #2078f3
94 substation #b427b4
95 medium-electric-pole #687d8d
96 big-electric-pole #566c7b
97 small-electric-pole #8c9eab
98 small-iron-electric-pole #8996a9
99 steel-chest #cb3f2a
100 iron-chest #907c64
101 wooden-chest #634b3b
102 transport-belt #b3b3b3
103 fast-transport-belt #50b1f7
104 express-transport-belt #e56f34
105 underground-belt #bfbfbf
106 fast-underground-belt #4193f6
107 express-underground-belt #e48d2f
108 splitter #bdbdbd
109 fast-splitter #3277f5
110 express-splitter #dd8a1d
111 long-handed-inserter #80604d
112 fast-inserter #80664d
113 burner-inserter #7c694b
114 inserter #7c704b
115 stack-inserter #47322a
116 burner-mining-drill #706943
117 electric-mining-drill #737345
118 area-mining-drill #7c804d
119 electric-furnace #384751
120 industrial-furnace #475666
121 assembling-machine-3 #666666
122 centrifuge #546578
123 chemical-plant #0092c7
124 oil-refinery #0f0f0f
125 pumpjack #433428
126 offshore-pump #162eca
127 boiler #9e9e9e
128 steam-engine #6c7d93
129 solar-panel #ffb74d
130 accumulator #0c59e9
131 lamp #ffeb3b
132 constant-combinator #495369
133 decider-combinator #4d556f
134 arithmetic-combinator #585b7e
135 power-switch #ffb405
136 programmable-speaker #751a89
137 aai-signal-receiver #323c48
138 aai-signal-transmitter #2a2f3c
139 textplate-small-copper #e68600
140 used-up-uranium-fuel-cell #34384b
141 uranium-fuel-cell #5db669
142 electric-motor #5976f7
143 motor #4652f6
144 automation-core #1712a5
145 iron-beam #878787
146 kr-advanced-solar-panel #ffc23d
147 kr-advanced-transport-belt #26c6da
148 kr-advanced-loader #0070a3
149 kr-advanced-splitter #00bcd4
150 kr-superior-inserter #74804d
151 kr-superior-filter-inserter #5d4437
152 kr-superior-long-inserter #6f804d
153 kr-superior-long-filter-inserter #5d4737
154 kr-superior-underground-belt #0054a8
155 kr-superior-loader #0048bd
156 kr-fuel-refinery #bb580c
157 kr-quarry-drill #68804d
158 kr-express-loader #00838f
159 kr-electric-mining-drill-mk2 #47382a
160 kr-steel-pipe #666666
161 kr-steel-pipe-to-ground #cb522a
162 kr-fluid-storage-2 #2415c1
163 kr-se-loader #434960
164 beryllium #81c784
165 beryllium-ore #558b2f
166 beryllium-sulfate #00695c
167 holmium #ba68c8
168 holmium-ore #ab47bc
169 holmium-solution #e1bee7
170 cryonite #00a8e0
171 cryonite-rod #29aedb
172 cryonite-solution #80deea
173 iridium #cfd8dc
174 iridium-ore #689f38
175 iridium-ingot #e0e0e0
176 vulcanite #ff7043
177 vulcanite-block #e64a19
178 vulcanite-powder #ffab91
179 imersite #8e24aa
180 imersite-crystal #283593
181 imersite-powder #ce93d8
182 vita #8bc34a
183 vita-extract #19572a
184 vita-germination #006064
185 naquium #ffd54f
186 naquium-ore #ffca28
187 naquium-ingot #fff9c4
188 methane-ice #b2ebf2
189 core-fragment #4db6ac
190 rare-metals #b0b0b0
191 raw-rare-metals #a8a8a8
192 raw-imersite #821e8f
193 sand #c2b280
194 silicon #6c7893
195 quartz #e6e6e6
196 coke #474747
197 se-copper-ingot #b08130
198 se-iron-ingot #84755c
199 se-steel-ingot #707070
200 se-beryllium-ingot #87c995
201 se-holmium-ingot #c056c2
202 se-iridium-ingot #d6dce1
203 se-cryonite-rod #2397d1
204 se-cryonite-slush #7bccea
205 se-vulcanite-crushed #ff7b29
206 se-vulcanite-enriched #ff6d29
207 se-vulcanite-block #dc5a18
208 imersium-plate #8e1d9a
209 se-kr-imersium-sulfide #9f1e96
210 se-kr-fine-imersite-powder #d58bd5
211 enriched-iron #7e7358
212 electronic-components #4eb183
213 empty-data-card #512da8
214 heat-shielding #00bfa5
215 thermodynamic-boiler #311b92
216 cryogenic-plant #0097a7
217 core-drill #3d5afe
218 core-miner #1a237e
219 se-quantum-processor #123db5
220 se-holmium-cable #c256bb
221 se-holmium-solenoid #c45aae
222 se-superconductive-cable #1c176e
223 se-data-storage-substrate #4527a0
224 se-machine-learning-data #3949ab
225 se-empty-data #5c6bc0
226 se-broken-data #7986cb
227 se-junk-data #9fa8da
228 se-scrap #757575
229 se-contaminated-scrap #3a3122
230 se-genetic-data #64b979
231 se-significant-data #3f9751
232 se-experimental-genetic-data #308249
233 se-atomic-data #008fd6
234 se-star-probe-data #ffea4d
235 se-significant-specimen #00bdb6
236 se-specimen #26a69a
237 se-bio-sludge #2c7749
238 se-nutrient-gel #005c5a
239 se-nutrient-gel-barrel #004d40
240 mineral-water #3236f5
241 chlorine #b0dbde
242 nitric-acid #ffcc80
243 se-bioscrubber #174f30
244 se-space-coolant-hot #f58700
245 se-space-water #6c59f7
246 se-chemical-gel #ad25aa
247 se-vitalic-acid #7ec44f
248 se-vitalic-reagent #49de17
249 se-vitalic-epoxy #5d9f38
250 se-neural-gel #b39ddb
251 se-neural-gel-2 #9575cd
252 se-plasma-stream #d50000
253 se-proton-stream #e040fb
254 se-ion-stream #651fff
255 se-particle-stream #304ffe
256 se-vitamelange-extract #427a2a
257 se-vitamelange-spice #498c31
258 se-water-ice #e3f2fd
259 lithium #9eadb7
260 lithium-chloride #b0b8c4
261 lithium-sulfur-battery #db9ad7
262 fertilizer #4a432b
263 biomass #33691e
264 biomethanol #419b5d
265 se-surface-teleporter #24166a
266 se-observation-frame #5eab3f
267 se-observation-frame-blank #5eb83d
268 se-core-fragment-se-beryllium #6bbd80
269 se-core-fragment-se-cryonite #0070e0
270 se-core-fragment-se-holmium #ca6dad
271 se-core-fragment-se-imersite #931f80
272 se-core-fragment-se-iridium #c7ced6
273 se-core-fragment-se-naquium #ffdf29
274 se-core-fragment-se-vita #62c247
275 se-core-fragment-se-vulcanite #ca6a16
276 se-core-fragment-omni #46a5aa
277 se-core-fragment-se-iridium-ore #409f38
278 se-core-fragment-se-vitamelange #4ac144
279 se-aeroframe-bulkhead #4ea9b7
280 se-aeroframe-scaffold #26a2a6
281 se-aeroframe-pole #00897b
282 se-heavy-girder #686f8d
283 se-heavy-bearing #607d8b
284 se-heavy-composite #484766
285 se-space-pipe #4e9bb7
286 se-space-transport-belt #2280c9
287 se-space-underground-belt #001cbd
288 se-space-splitter #0057d1
289 se-space-accumulator #734bf7
290 se-space-solar-panel #ffe438
291 se-space-solar-panel-2 #ffa726
292 se-space-elevator-cable #4b2494
293 se-space-pipe-to-ground #5191b8
294 se-space-mirror #d9d9d9
295 se-space-rail #787a9b
296 se-space-platform-scaffold #b8b8b8
297 se-space-probe-rocket #ff913d
298 se-space-capsule #42a5f5
299 se-meteor-defence-ammo #ed533b
300 se-dynamic-emitter #3913aa
301 se-gammaray-detector #009bb3
302 se-pylon-substation #ffff52
303 se-rocket-launch-pad #6b6b6b
304 se-rocket-landing-pad #5e5e5e
305 se-cargo-rocket-fuel-tank #ff7a05
306 se-cargo-rocket-cargo-pod #ff985c
307 se-cargo-rocket-section #ffa570
308 se-cargo-rocket-section-packed #ffdd75
309 se-lifesupport-canister #7a32f5
310 se-used-lifesupport-canister #929bb0
311 se-canister #898fa9
312 se-magnetic-canister #ca6da3
313 se-iridium-ore #348c31
314 se-iridium-ore-crushed #35973d
315 se-beryllium-ore #3b7a2a
316 se-beryllium-sulfate #005461
317 se-holmium-ore #a53eac
318 se-holmium-ore-crushed #a93da0
319 se-compact-beacon #491e7b
320 se-recycling-facility #49a780
321 se-rocket-science-pack #e3e3e3
322 lubricant-barrel #ffc107
323 heavy-oil-barrel #434028
324 light-oil-barrel #ffb56b
325 petroleum-gas-barrel #4d007e
326 se-material-science-pack-1 #fffc42
327 se-material-science-pack-2 #ffbe1a
328 se-material-science-pack-3 #f5c800
329 se-material-science-pack-4 #e67e00
330 se-material-testing-pack #f57c00
331 se-material-insight #ffe0b2
332 se-material-catalogue-1 #efff42
333 se-material-catalogue-2 #ffd11a
334 se-material-catalogue-3 #f5e400
335 se-material-catalogue-4 #e6a100
336 se-astronomic-science-pack-1 #565abd
337 se-astronomic-science-pack-2 #373aa4
338 se-astronomic-insight #c5cae9
339 se-astronomic-catalogue-1 #6c61c2
340 se-astronomic-catalogue-2 #423bb0
341 se-astronomic-catalogue-3 #2a2c98
342 se-astronomic-catalogue-4 #371b83
343 se-biological-science-pack-1 #69f0ae
344 se-biological-science-pack-2 #00e676
345 se-biological-science-pack-3 #00ad5f
346 se-biological-science-pack-4 #009624
347 se-biological-insight #b9f6ca
348 se-biological-catalogue-1 #6af0c1
349 se-biological-catalogue-2 #00e68a
350 se-biological-catalogue-3 #00c77e
351 se-biological-catalogue-4 #009431
352 se-energy-science-pack-1 #f17493
353 se-energy-science-pack-2 #f06292
354 se-energy-science-pack-3 #ec407a
355 se-energy-science-pack-4 #d81b60
356 se-energy-insight #fce4ec
357 se-energy-catalogue-1 #f17482
358 se-energy-catalogue-2 #ee446f
359 se-energy-catalogue-3 #e92549
360 se-energy-catalogue-4 #bf183f
361 se-deep-space-science-pack-1 #bfa6de
362 se-deep-space-science-pack-2 #a27cd0
363 se-deep-space-science-pack-3 #7e57c2
364 se-deep-space-science-pack-4 #6f37b9
365 se-kr-matter-science-pack-1 #ffef8a
366 se-kr-matter-science-pack-2 #e0ff57
367 se-kr-matter-liberation-data #ffe494
368 blank-tech-card #898ca9
369 singularity-tech-card #351566
370 ltn-combinator #4c1b83
371 ltn-stop #383a51
372 ltn-delivery-address #286c4b
373 ltn-provider-stack-threshold #f34a1b
374 ltn-requester-stack-threshold #1643f3
375 ltn-provider-threshold #d45d35
376 ltn-requester-threshold #5c12a5
377 ltn-max-trains #621281
378 ltn-max-train-length #66127d
379 ltn-locked-slots #534d6f