Initial web

This commit is contained in:
Caesar2011
2026-05-17 19:55:53 +02:00
parent 6e3499812e
commit 20ed6ee9fb
58 changed files with 8541 additions and 0 deletions

65
web/lib/api.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { ChartConfig, AlertConfig, TriggeredAlert, SignalRow, SessionBoundary, UpsRow, TimeMode } from './types';
let _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');
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)));
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> {
const res = await fetch(url(path, params));
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function mutate<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(url(path), {
method,
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
export function fetchSignals(params: {
combinator?: string[];
item?: string[];
exclude?: string[];
signal?: string;
time_mode?: TimeMode;
from?: string;
to?: string;
regex?: boolean;
order_by?: string;
limit?: number;
}): Promise<SignalRow[]> {
return get('/api/signals', params as Record<string, string | string[]>);
}
export function fetchUps(params: { combinator?: string; from?: string; to?: string }): Promise<UpsRow[]> {
return get('/api/ups', params);
}
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 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'); }

26
web/lib/apiHelpers.ts Normal file
View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAuthorized, unauthorized } from './auth';
type RouteContext = { params: Promise<Record<string, string>> };
type Handler = (req: NextRequest, ctx: RouteContext) => Promise<NextResponse>;
/**
* Wraps a route handler with auth checking and unified error handling.
*
* @example
* export const GET = withAuth(async (req) => {
* const rows = await pool.query('...');
* return NextResponse.json(rows);
* });
*/
export function withAuth(handler: Handler): Handler {
return async (req, ctx) => {
if (!isAuthorized(req)) return unauthorized();
try {
return await handler(req, ctx);
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
}

22
web/lib/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Returns true if the request carries a valid API token.
* Accepts token via `?token=` query param or `Authorization: Bearer <token>` header.
*/
export function isAuthorized(req: NextRequest): boolean {
const expected = process.env.API_TOKEN;
if (!expected) return false;
const queryToken = req.nextUrl.searchParams.get('token');
if (queryToken === expected) return true;
const authHeader = req.headers.get('authorization');
if (authHeader === `Bearer ${expected}`) return true;
return false;
}
export function unauthorized(): NextResponse {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

83
web/lib/context.tsx Normal file
View File

@@ -0,0 +1,83 @@
'use client';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import type { TimeRange, TimeMode, TriggeredAlert } from './types';
import { TIME_RANGE_MS } from './types';
import { checkAlerts } from './api';
import { buildReverseMap } from './localization';
import type { LocaleMap, ReverseMap } from './localization';
interface AppContextValue {
timeRange: TimeRange;
setTimeRange: (r: TimeRange) => void;
timeMode: TimeMode;
setTimeMode: (m: TimeMode) => void;
triggeredAlerts: TriggeredAlert[];
refreshAlerts: () => Promise<void>;
getFromTo: () => { from: string; to: string };
localeMap: LocaleMap;
reverseMap: ReverseMap;
}
const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({
token: _token,
localeMap,
children,
}: {
token: string;
localeMap: LocaleMap;
children: React.ReactNode;
}) {
const [timeRange, setTimeRange] = useState<TimeRange>('6h');
const [timeMode, setTimeMode] = useState<TimeMode>('real');
const [triggeredAlerts, setTriggeredAlerts] = useState<TriggeredAlert[]>([]);
const reverseMap = buildReverseMap(localeMap);
const getFromTo = useCallback(() => {
const to = new Date();
const from = new Date(to.getTime() - TIME_RANGE_MS[timeRange]);
return { from: from.toISOString(), to: to.toISOString() };
}, [timeRange]);
const refreshAlerts = useCallback(async () => {
setTriggeredAlerts(await checkAlerts());
}, []);
useEffect(() => {
let cancelled = false;
const poll = () => checkAlerts().then(a => { if (!cancelled) setTriggeredAlerts(a); });
poll();
const id = setInterval(poll, 30_000);
return () => { cancelled = true; clearInterval(id); };
}, []);
return (
<AppContext.Provider
value={{
timeRange, setTimeRange,
timeMode, setTimeMode,
triggeredAlerts, refreshAlerts,
getFromTo,
localeMap, reverseMap,
}}
>
{children}
</AppContext.Provider>
);
}
/** Must be used within {@link AppProvider}. */
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppProvider');
return ctx;
}

14
web/lib/db.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Pool } from 'pg';
declare global {
// eslint-disable-next-line no-var
var __pgPool: Pool | undefined;
}
const pool = globalThis.__pgPool ?? new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== 'production') {
globalThis.__pgPool = pool;
}
export default pool;

29
web/lib/localeServer.ts Normal file
View File

@@ -0,0 +1,29 @@
import fs from 'fs';
import path from 'path';
import { parseCsv } from './localization';
import type { LocaleMap } from './localization';
declare global { var __serverLocaleCache: LocaleMap | undefined; }
/**
* Loads and merges EN + DE locale CSVs from the public directory.
* Cached for the lifetime of the server process.
* Server-only — never import from client components.
*/
export function getServerLocaleMap(): LocaleMap {
if (globalThis.__serverLocaleCache) return globalThis.__serverLocaleCache;
const pub = path.join(process.cwd(), 'public');
function load(filename: string): LocaleMap {
try {
return parseCsv(fs.readFileSync(path.join(pub, filename), 'utf8'));
} catch {
return new Map();
}
}
const merged: LocaleMap = new Map([...load('factorio_english_items.csv'), ...load('factorio_german_items.csv')]);
globalThis.__serverLocaleCache = merged;
return merged;
}

103
web/lib/localization.ts Normal file
View File

@@ -0,0 +1,103 @@
export type LocaleMap = Map<string, string>;
export type ReverseMap = Map<string, string>;
/** Parses a 3-column CSV (section, item_key, localized_name). */
export function parseCsv(text: string): LocaleMap {
const map: LocaleMap = new Map();
const lines = text.split(/\r?\n/).slice(1);
for (const line of lines) {
if (!line.trim()) continue;
const cols = splitCsvLine(line);
if (cols.length >= 3) map.set(cols[1], cols[2]);
}
return map;
}
function splitCsvLine(line: string): string[] {
const cols: string[] = [];
let cur = '';
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
else inQuote = !inQuote;
} else if (ch === ',' && !inQuote) {
cols.push(cur);
cur = '';
} else {
cur += ch;
}
}
cols.push(cur);
return cols;
}
declare global { var __localeCache: LocaleMap | undefined; }
async function loadCsv(path: string): Promise<LocaleMap> {
try {
const res = await fetch(path);
return res.ok ? parseCsv(await res.text()) : new Map();
} catch {
return new Map();
}
}
/**
* Fetches and merges DE + EN locale CSVs.
* Result is cached for the lifetime of the page.
*/
export async function getLocaleMap(): Promise<LocaleMap> {
if (globalThis.__localeCache) return globalThis.__localeCache;
const [en, de] = await Promise.all([
loadCsv('/factorio_english_items.csv'),
loadCsv('/factorio_german_items.csv'),
]);
const merged: LocaleMap = new Map([...en, ...de]);
globalThis.__localeCache = merged;
return merged;
}
/** Resolves an `item_key` to its localized display name, falling back to the key itself. */
export function resolveName(key: string, map: LocaleMap): string {
return map.get(key) ?? key;
}
/**
* Builds a reverse lookup: lowercased localized name → item_key.
* Used for normalizing user input back to raw keys.
*/
export function buildReverseMap(map: LocaleMap): ReverseMap {
const rev: ReverseMap = new Map();
for (const [key, name] of map) {
rev.set(name.toLowerCase(), key);
}
return rev;
}
/**
* Resolves a single user-typed token (localized name or raw key) to a raw item_key.
* Falls back to the input itself if no match is found.
*/
export function resolveKey(input: string, rev: ReverseMap): string {
return rev.get(input.toLowerCase()) ?? input;
}
/**
* Applies a regex pattern against both raw item_keys and localized names,
* returning the union of all matching raw item_keys.
*/
export function matchKeys(pattern: string, map: LocaleMap): string[] {
let re: RegExp;
try {
re = new RegExp(pattern, 'i');
} catch {
return [];
}
const result = new Set<string>();
for (const [key, name] of map) {
if (re.test(key) || re.test(name)) result.add(key);
}
return [...result];
}

38
web/lib/sessions.ts Normal file
View File

@@ -0,0 +1,38 @@
import pool from '@/lib/db';
import type { SessionBoundary } from '@/lib/types';
/**
* 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[]> {
const result = await pool.query<{ real_time: Date; game_tick: string }>(
`SELECT real_time, game_tick
FROM tick_timing
WHERE real_time BETWEEN $1 AND $2
ORDER BY real_time ASC`,
[from, to],
);
const rows = result.rows;
if (rows.length === 0) return [];
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) {
boundaries.push({
real_time: rows[i].real_time.toISOString(),
game_tick: parseInt(rows[i].game_tick, 10),
});
}
}
return boundaries;
}

71
web/lib/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface ChartConfig {
id: string;
title: string;
pos_x: number;
pos_y: number;
width: number;
height: number;
signal_type: 'green' | 'red' | 'both';
chart_type: 'signals' | 'ups' | 'divider';
viz_type: 'line' | 'table';
filter_combinators: string[] | null;
filter_items: string[] | null;
filter_items_exclude: string[] | null;
filter_items_regex: boolean;
y_min: number | null;
y_max: number | null;
y_scale: 'linear' | 'log';
series_limit: number;
order_by: 'time' | 'value_asc' | 'value_desc' | 'abs_desc' | 'delta_asc' | 'delta_desc';
}
export interface AlertConfig {
id: string;
item_key: string;
item_key_is_regex: boolean;
combinator: string | null;
signal_type: 'green' | 'red';
condition: 'above' | 'below';
threshold: number;
active: boolean;
}
export interface TriggeredAlert extends AlertConfig {
current_value: number;
combinator_match: string;
/** Actual matched item_key — differs from item_key when item_key_is_regex=true */
matched_item_key: string;
}
export interface SignalRow {
real_time: string;
game_tick: string;
combinator: string;
item_key: string;
green?: number;
red?: number;
}
export interface UpsRow {
real_time: string;
game_tick: number;
combinator: string;
ups: number;
}
export interface SessionBoundary {
real_time: string;
game_tick: number;
}
export type TimeMode = 'real' | 'tick';
export type TimeRange = '30m' | '1h' | '6h' | '24h' | '7d' | '30d';
export const TIME_RANGE_MS: Record<TimeRange, number> = {
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
};