Initial web
This commit is contained in:
65
web/lib/api.ts
Normal file
65
web/lib/api.ts
Normal 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
26
web/lib/apiHelpers.ts
Normal 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
22
web/lib/auth.ts
Normal 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
83
web/lib/context.tsx
Normal 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
14
web/lib/db.ts
Normal 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
29
web/lib/localeServer.ts
Normal 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
103
web/lib/localization.ts
Normal 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
38
web/lib/sessions.ts
Normal 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
71
web/lib/types.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user