Merge pull request #23 from Salastil/claude/source-management-content-1w2yno

Widget rework
This commit is contained in:
Salastil
2026-08-03 22:24:01 -04:00
committed by GitHub
48 changed files with 2268 additions and 649 deletions
+15 -15
View File
@@ -1671,9 +1671,9 @@
}
},
"node_modules/fast-json-stringify/node_modules/fast-uri": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz",
"integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz",
"integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==",
"funding": [
{
"type": "github",
@@ -1696,9 +1696,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -1770,9 +1770,9 @@
}
},
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -1865,9 +1865,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -2592,9 +2592,9 @@
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
+65 -118
View File
@@ -4,9 +4,7 @@ import * as sourcesDb from '../storage/db/sources.js';
import * as eventsDb from '../storage/db/events.js';
import * as articlesDb from '../storage/db/articles.js';
import * as categoriesDb from '../storage/db/categories.js';
import * as stocksDb from '../storage/db/stocks.js';
import * as bookmarksDb from '../storage/db/bookmarks.js';
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { clearSourceContent, reissueSourceContent, reissueArticle, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
import { totalStorageBytes } from '../storage/media/index.js';
import { OllamaProvider } from '../inference/ollama-provider.js';
@@ -16,11 +14,22 @@ import { logger, listLogs } from '../storage/db/logs.js';
import * as backlogStats from '../queue/backlogStats.js';
import * as ollamaStats from '../inference/stats.js';
import * as telegramClient from '../telegram/client.js';
import { geocodeLocation } from '../weather/client.js';
import { pollWeatherNow } from '../weather/poller.js';
import { pollStocksNow } from '../stocks/poller.js';
import { browseCurrencies, fetchCurrentLeague } from '../poe2/client.js';
import { pollPoe2Now } from '../poe2/poller.js';
import { loadedWidgets } from '../widgets/registry.js';
import { installUploadedWidget } from '../widgets/install.js';
import { uninstallWidget } from '../widgets/uninstall.js';
import { swapLiveServer } from '../server.js';
import type { GlobalSettings } from '../storage/db/types.js';
// Rebuilds and swaps in the live Fastify instance to pick up a widget's newly
// (de)registered routes — MUST run after the triggering request has already sent its
// response, never awaited inline in that handler, since the swap closes the very
// instance serving it (see widgets/install.ts's and uninstall.ts's doc comments for
// why — this dropped the response entirely when tried inline during testing).
function scheduleServerSwap(context: string) {
setImmediate(() => {
swapLiveServer().catch((err) => logger.error('server', `Route swap after ${context} failed: ${(err as Error).message}`));
});
}
// Not part of GlobalSettings itself (nothing to persist) — computed fresh on every
// settings read/write so the Retention tab's "currently using" line and usage bar
@@ -44,24 +53,19 @@ export async function registerAdminRoutes(app: FastifyInstance) {
}
const before = settingsDb.getSettings();
const settings = withStorageUsed(settingsDb.updateSettings(body));
if (body.weather) {
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
}
if (body.widgets) {
// Re-enabling a widget (see the Widgets tab) should show fresh data right away
// instead of waiting out its normal cadence (up to 45m/15m/1h) — scheduler.ts
// skips polling entirely while a widget is disabled, so there's nothing recent
// to fall back on otherwise.
if (body.widgets.weather && !before.widgets.weather) {
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
}
if (body.widgets.stocks && !before.widgets.stocks) {
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
}
if (body.widgets.poe2 && !before.widgets.poe2) {
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
// instead of waiting out its normal cadence — scheduler.ts skips polling
// entirely while a widget is disabled, so there's nothing recent to fall back
// on otherwise. Generic over every loaded widget with a poll hook, rather than
// one hardcoded branch per widget.
for (const id of Object.keys(body.widgets) as (keyof GlobalSettings['widgets'])[]) {
if (body.widgets[id] && !before.widgets[id]) {
loadedWidgets
.get(id)
?.poll?.run()
.catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`));
}
}
}
return { ...settings, categoryPriority: categoriesDb.listCategories() };
@@ -298,107 +302,50 @@ export async function registerAdminRoutes(app: FastifyInstance) {
return reply.code(200).send(telegramClient.getStatus());
});
// --- Weather (config lives in global_settings — see PATCH /api/admin/settings above) ---
app.get('/api/admin/weather/geocode', async (req, reply) => {
const { query } = req.query as { query?: string };
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
try {
return await geocodeLocation(query.trim());
} catch (err) {
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
}
// Per-widget routes (weather geocode, stocks CRUD, bookmarks CRUD, poe2 browse/
// watchlist) are registered by each widget's own plugin — see widgets/registry.ts's
// generic registerAdminRoutes loop in index.ts. What's left here is the
// upload/delete lifecycle for pluggable widgets themselves.
// --- Pluggable widgets (upload/list/delete — see widgets/install.ts, uninstall.ts) ---
app.get('/api/admin/widgets', async () => installedWidgetsDb.listInstalled());
app.post('/api/admin/widgets', async (req, reply) => {
const { manifest, files } = req.body as { manifest?: unknown; files?: unknown };
const result = await installUploadedWidget(manifest, files);
if (!result.ok) return reply.code(400).send({ error: result.error });
reply.code(201).send({ id: result.id });
if (result.needsServerSwap) scheduleServerSwap(`installing "${result.id}"`);
});
// --- Stocks ---
app.get('/api/admin/stocks', async () => stocksDb.listStockTickers());
app.post('/api/admin/stocks', async (req, reply) => {
const { label, symbol } = req.body as { label?: string; symbol?: string };
if (!label || !label.trim() || !symbol || !symbol.trim()) {
return reply.code(400).send({ error: 'label and symbol are required' });
}
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
// and refreshes every existing ticker's price too.
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/stocks/:id', async (req, reply) => {
app.patch('/api/admin/widgets/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = stocksDb.updateStockTicker(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
stocksDb.deleteStockTicker(id);
return reply.code(204).send();
});
// --- Bookmarks ---
app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks());
app.post('/api/admin/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) {
return reply.code(400).send({ error: 'name and url are required' });
const { enabled } = req.body as { enabled?: boolean };
const widget = installedWidgetsDb.getInstalled(id);
if (!widget) return reply.code(404).send({ error: 'not found' });
if (typeof enabled === 'boolean') {
installedWidgetsDb.setEnabled(id, enabled);
// Re-enabling should show fresh data right away rather than waiting out the
// widget's own poll interval — same "immediate poll on enable" behavior the
// 4 built-ins get via PATCH /api/admin/settings above.
if (enabled && !widget.enabled) {
loadedWidgets
.get(id)
?.poll?.run()
.catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`));
}
}
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
return reply.code(201).send(created);
return installedWidgetsDb.getInstalled(id);
});
app.patch('/api/admin/bookmarks/:id', async (req, reply) => {
app.delete('/api/admin/widgets/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = bookmarksDb.updateBookmark(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
bookmarksDb.deleteBookmark(id);
return reply.code(204).send();
});
// --- PoE2 (league is always auto-detected, never admin-set — see poe2/poller.ts) ---
app.get('/api/admin/poe2/browse', async (_req, reply) => {
try {
const league = await fetchCurrentLeague();
return await browseCurrencies(league.id);
} catch (err) {
return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` });
}
});
app.get('/api/admin/poe2/watchlist', async () => poe2WatchlistDb.listWatchlist());
app.post('/api/admin/poe2/watchlist', async (req, reply) => {
const { base, quote } = req.body as {
base?: { currencyId?: string; name?: string };
quote?: { currencyId?: string; name?: string };
};
if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) {
return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' });
}
if (base.currencyId === quote.currencyId) {
return reply.code(400).send({ error: 'Base and quote currencies must be different' });
}
const created = poe2WatchlistDb.addWatchlistEntry(
{ currencyId: base.currencyId, name: base.name },
{ currencyId: quote.currencyId, name: quote.name }
);
// Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap,
// and refreshes every existing entry's rate too.
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => {
const { id } = req.params as { id: string };
poe2WatchlistDb.removeWatchlistEntry(id);
return reply.code(204).send();
const widget = installedWidgetsDb.getInstalled(id);
if (!widget) return reply.code(404).send({ error: 'not found' });
if (widget.source === 'builtin') return reply.code(400).send({ error: 'built-in widgets cannot be deleted' });
const hadRoutes = await uninstallWidget(id);
reply.code(204).send();
if (hadRoutes) scheduleServerSwap(`deleting "${id}"`);
});
// --- Logs ---
+27 -20
View File
@@ -4,9 +4,9 @@ import * as tagsDb from '../storage/db/tags.js';
import * as eventsDb from '../storage/db/events.js';
import * as categoriesDb from '../storage/db/categories.js';
import * as settingsDb from '../storage/db/settings.js';
import * as stocksDb from '../storage/db/stocks.js';
import * as bookmarksDb from '../storage/db/bookmarks.js';
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { getKv } from '../storage/db/widgetKv.js';
import type { WidgetReport } from '../widgets/report.js';
import { hasPrivateAccess } from './privateAccess.js';
export async function registerPublicRoutes(app: FastifyInstance) {
@@ -76,27 +76,34 @@ export async function registerPublicRoutes(app: FastifyInstance) {
return categories.filter((c) => !c.isPrivate);
});
// Per-widget enable flags + display order — see the admin panel's consolidated
// "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on these
// flags (see scheduler.ts); Sidebar.svelte renders in exactly this order.
// Per-widget enable flags + display order for the 4 built-ins — see the admin panel's
// consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on
// these flags (see scheduler.ts); Sidebar.svelte renders in exactly this order. The
// `pluggable` array lists enabled *uploaded* widgets separately (their ids aren't part
// of the closed weather|stocks|bookmarks|poe2 union `order` uses) — see
// widgets/registry.ts, Sidebar.svelte's GenericWidgetCard/DynamicWidgetSlot.
app.get('/api/widgets', async () => {
const { widgets, widgetOrder } = settingsDb.getSettings();
return { ...widgets, order: widgetOrder };
const pluggable = installedWidgetsDb
.listInstalled()
.filter((w) => w.source === 'uploaded' && w.enabled)
.map((w) => ({ id: w.id, displayName: w.displayName, frontendEntry: w.frontendEntry }));
return { ...widgets, order: widgetOrder, pluggable };
});
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
app.get('/api/weather', async () => settingsDb.getSettings().weather);
app.get('/api/stocks', async () => stocksDb.listStockTickers());
app.get('/api/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
// Generic live-data feed any widget (built-in or uploaded) can publish to via
// setKv(id, 'report', ...) in its own poll.run() — see widgets/report.ts. Registered
// once here rather than per-widget, so it works for a widget uploaded after this
// process started, with zero new route registration (Fastify refuses routes added
// after app.listen(), so this is what makes "poll live -> sidebar shows it live" work
// for an uploaded widget without a restart).
app.get('/api/widget/:id/report', async (req, reply) => {
const { id } = req.params as { id: string };
if (!installedWidgetsDb.getInstalled(id)?.enabled) return reply.code(404).send();
return { data: getKv<WidgetReport>(id, 'report') };
});
app.get('/api/poe2', async () => {
const { leagueName, updatedAt } = settingsDb.getSettings().poe2;
return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() };
});
// Per-widget public routes (GET /api/widget/weather, /stocks, /bookmarks, /poe2) are
// registered by each widget's own plugin — see widgets/registry.ts's generic
// registerPublicRoutes loop in index.ts.
}
+8 -84
View File
@@ -1,23 +1,11 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import fs from 'node:fs';
import path from 'node:path';
import { migrate } from './storage/db/index.js';
import { ADMIN_API_KEY } from './api/apiKey.js';
import { registerAuth } from './api/auth.js';
import { registerPublicRoutes } from './api/public.js';
import { registerAdminRoutes } from './api/admin.js';
import { registerMediaProxy } from './api/mediaProxy.js';
import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js';
import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js';
import { privateAccessConfigured } from './api/privateAccess.js';
import { startScheduler } from './queue/scheduler.js';
import { initFromSavedSession } from './telegram/client.js';
import { logger } from './storage/db/logs.js';
const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
import { loadAllWidgets } from './widgets/registry.js';
import { reloadServerRoutes } from './server.js';
function printApiKeyBanner() {
const line = '='.repeat(64);
@@ -27,7 +15,9 @@ function printApiKeyBanner() {
console.log(`\n${line}`);
console.log(' Homefeed admin API key (required for every /api/admin/* request)');
console.log(` ${ADMIN_API_KEY}`);
console.log(' This key is generated fresh on every restart — it will not be the same next time.');
console.log(' This key is generated fresh on every process restart — it will not be');
console.log(' the same next time. Installing/deleting a widget does NOT restart the');
console.log(' process (see server.ts) and does not change this key.');
console.log(`${line}\n`);
}
@@ -35,74 +25,8 @@ async function main() {
migrate();
printApiKeyBanner();
await initFromSavedSession();
const app = Fastify({ logger: false });
// Cross-origin is expected — see project-structure.md "Cross-origin and security
// implications". Not a wildcard: only the configured frontend origin is allowed.
// @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list,
// every PATCH (settings saves) and DELETE (removing sources/events) gets silently
// blocked by the browser at the CORS preflight stage, before the request ever
// reaches a route handler.
// credentials: true is required for the browser to send/accept the private-category
// login cookie cross-origin — safe only because origin is a specific value above,
// never a wildcard (the two are mutually exclusive per the CORS spec anyway).
await app.register(cors, {
origin: FRONTEND_ORIGIN,
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
});
await app.register(cookie);
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
// or POST with no payload) that still carries a Content-Type header — exactly what
// browsers' fetch() does when a client sets that header unconditionally. An empty
// body is just as valid as `{}` for routes that don't read req.body at all.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
if (typeof body !== 'string' || body.trim() === '') return done(null, {});
try {
done(null, JSON.parse(body));
} catch (err) {
done(err as Error, undefined);
}
});
await registerAuth(app);
await registerPublicRoutes(app);
await registerAdminRoutes(app);
await registerPrivateAccess(app);
// Fastify's own logger is off (see below) — without this, an unhandled exception
// in any route handler produces a bare 500 with zero trace anywhere, including the
// admin panel's own Logs tab. This is what "Save failed" with no log entry was.
app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => {
logger.error('server', `${req.method} ${req.url} failed: ${err.message}`);
reply.code(err.statusCode ?? 500).send({ error: err.message });
});
// Locally hosted media (see storage/media) — served directly rather than via a
// heavier static-file plugin, since this is a small, flat directory.
app.get('/media/:filename', async (req, reply) => {
const { filename } = req.params as { filename: string };
if (filename.includes('..') || filename.includes('/')) return reply.code(400).send();
const filePath = path.join(MEDIA_DIR, filename);
if (!fs.existsSync(filePath)) return reply.code(404).send();
return reply.send(fs.createReadStream(filePath));
});
// Static "/media/proxy" and "/media/telegram-proxy" take priority over the
// "/media/:filename" param route above regardless of registration order
// (find-my-way, Fastify's router, always prefers a static segment over a parametric
// one at the same depth).
await registerMediaProxy(app);
await registerTelegramMediaProxy(app);
app.get('/health', async () => ({ ok: true }));
await app.listen({ port: PORT, host: '0.0.0.0' });
logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`);
await loadAllWidgets();
await reloadServerRoutes();
if (!privateAccessConfigured()) {
logger.info('server', 'Private categories disabled — set PRIVATE_ACCESS_PASSWORD to enable');
}
-63
View File
@@ -1,63 +0,0 @@
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { fetchCurrentLeague, fetchCurrencyValues } from './client.js';
const DAY_MS = 24 * 60 * 60_000;
function pctChange(current: number, past: number | null): number | null {
if (past === null || past === 0) return null;
return ((current - past) / past) * 100;
}
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a pair
// (see api/admin.ts) — always re-detects the current challenge league fresh (cheap, guarantees
// correctness across league rotations with no separate staleness logic), then one overview
// request covers the whole watchlist. A pair whose base or quote currency is no longer traded
// this league gets its own lastError, it never aborts the rest of the batch.
export async function pollPoe2Now(): Promise<void> {
let league;
try {
league = await fetchCurrentLeague();
} catch (err) {
logger.error('poe2', `League lookup failed: ${(err as Error).message}`);
return;
}
const entries = poe2WatchlistDb.listWatchlist();
if (entries.length === 0) {
const { poe2 } = settingsDb.getSettings();
settingsDb.updateSettings({
poe2: { ...poe2, leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() }
});
return;
}
try {
const valuesById = await fetchCurrencyValues(league.id);
const now = new Date();
const nowIso = now.toISOString();
const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString();
for (const entry of entries) {
const baseValue = valuesById.get(entry.baseCurrencyId);
const quoteValue = valuesById.get(entry.quoteCurrencyId);
if (baseValue === undefined || quoteValue === undefined) {
poe2WatchlistDb.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league');
continue;
}
const rate = baseValue / quoteValue;
const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h));
poe2WatchlistDb.recordRate(entry.id, rate, nowIso);
poe2WatchlistDb.markPolled(entry.id, rate, change24h, null);
}
poe2WatchlistDb.pruneOldHistory();
settingsDb.updateSettings({
poe2: { leagueId: league.id, leagueName: league.name, updatedAt: nowIso }
});
} catch (err) {
logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`);
}
}
+40 -37
View File
@@ -4,18 +4,15 @@ import { runEventRecaps } from './eventsRecap.js';
import { runRetentionSweep } from './retention.js';
import { OllamaProvider } from '../inference/ollama-provider.js';
import * as settingsDb from '../storage/db/settings.js';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { logger } from '../storage/db/logs.js';
import { pollWeatherNow } from '../weather/poller.js';
import { pollStocksNow } from '../stocks/poller.js';
import { pollPoe2Now } from '../poe2/poller.js';
import { loadedWidgets } from '../widgets/registry.js';
import type { WidgetPlugin } from '../widgets/types.js';
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
const DIRECT_PUBLISH_TICK_MS = 60_000;
const SYNTHESIS_TICK_MS = 60_000;
const RETENTION_TICK_MS = 60 * 60_000; // hourly
const WEATHER_TICK_MS = 45 * 60_000;
const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather
const POE2_TICK_MS = 60 * 60_000; // poe.ninja's own overview data doesn't refresh faster than hourly, so polling more often than this just re-fetches the same numbers
/**
* Runs fn on every tick, but skips a tick outright if the previous one is still in
@@ -46,6 +43,40 @@ function everyTickSkippingOverlap(ms: number, fn: () => Promise<void>) {
}, ms);
}
// Per-widget setInterval handles, keyed by widget id — lets a single widget's polling be
// started/stopped independently (on live upload/delete, or an enable toggle) without
// touching any other widget's interval. Exported so widgets/install.ts and
// widgets/uninstall.ts can drive it directly.
export const widgetIntervals = new Map<string, NodeJS.Timeout>();
// Starts (or re-starts) polling for one widget — an immediate poll if it's currently
// enabled (unlike RSS sources, whose "due" check makes a brand-new source eligible on the
// very next 1-minute tick, a widget has no such shortcut; without this the sidebar would
// sit empty for up to a full poll interval after every restart or fresh install), then a
// recurring interval that re-checks the enabled flag on every tick — so disabling a widget
// stops the actual external polling, not just hides it in the sidebar.
export function startWidgetPolling(plugin: WidgetPlugin) {
if (!plugin.poll) return;
stopWidgetPolling(plugin.id);
if (installedWidgetsDb.getInstalled(plugin.id)?.enabled) {
plugin.poll.run().catch((err) => logger.error(plugin.id, `Initial poll failed: ${(err as Error).message}`));
}
const handle = setInterval(() => {
if (!installedWidgetsDb.getInstalled(plugin.id)?.enabled) return;
plugin.poll!.run().catch((err) => logger.error(plugin.id, `Poll tick failed: ${(err as Error).message}`));
}, plugin.poll.intervalMs);
widgetIntervals.set(plugin.id, handle);
}
export function stopWidgetPolling(id: string) {
const handle = widgetIntervals.get(id);
if (handle) {
clearInterval(handle);
widgetIntervals.delete(id);
}
}
export function startScheduler() {
const provider = () => {
const s = settingsDb.getSettings();
@@ -115,40 +146,12 @@ export function startScheduler() {
}
});
// Immediate first call for all three — unlike RSS sources (whose "due" check makes a
// brand-new source eligible on the very next 1-minute tick), weather/stocks/poe2 have
// no such shortcut; without this the sidebar is empty for up to 45/15/60 minutes after
// every restart. Each is also gated on its Widgets-tab enabled flag (see
// admin/settings' consolidated Widgets tab) — disabling a widget stops these external
// calls entirely rather than just hiding the sidebar box, so there's no pointless
// polling for something nobody's looking at. Re-enabling it triggers an immediate
// poll instead (see admin.ts's PATCH /api/admin/settings), same as this initial call.
if (settingsDb.getSettings().widgets.weather) {
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
for (const plugin of loadedWidgets.values()) {
startWidgetPolling(plugin);
}
setInterval(() => {
if (!settingsDb.getSettings().widgets.weather) return;
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
}, WEATHER_TICK_MS);
if (settingsDb.getSettings().widgets.stocks) {
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
}
setInterval(() => {
if (!settingsDb.getSettings().widgets.stocks) return;
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
}, STOCKS_TICK_MS);
if (settingsDb.getSettings().widgets.poe2) {
pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`));
}
setInterval(() => {
if (!settingsDb.getSettings().widgets.poe2) return;
pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`));
}, POE2_TICK_MS);
logger.info(
'scheduler',
'Started: poll every 1m, direct-publish every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m, poe2 every 1h'
`Started: poll every 1m, direct-publish every 1m, synthesis every 1m, retention every 1h, ${loadedWidgets.size} widget(s) polling on their own intervals`
);
}
+178
View File
@@ -0,0 +1,178 @@
import Fastify, { type FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import fs from 'node:fs';
import path from 'node:path';
import { registerAuth } from './api/auth.js';
import { registerPublicRoutes } from './api/public.js';
import { registerAdminRoutes } from './api/admin.js';
import { registerMediaProxy } from './api/mediaProxy.js';
import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js';
import { registerPrivateAccess } from './api/privateAccess.js';
import { loadedWidgets } from './widgets/registry.js';
import { logger } from './storage/db/logs.js';
const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
let currentApp: FastifyInstance | null = null;
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
// Cross-origin is expected — see project-structure.md "Cross-origin and security
// implications". Not a wildcard: only the configured frontend origin is allowed.
// @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list,
// every PATCH (settings saves) and DELETE (removing sources/events) gets silently
// blocked by the browser at the CORS preflight stage, before the request ever
// reaches a route handler.
// credentials: true is required for the browser to send/accept the private-category
// login cookie cross-origin — safe only because origin is a specific value above,
// never a wildcard (the two are mutually exclusive per the CORS spec anyway).
await app.register(cors, {
origin: FRONTEND_ORIGIN,
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
});
await app.register(cookie);
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
// or POST with no payload) that still carries a Content-Type header — exactly what
// browsers' fetch() does when a client sets that header unconditionally. An empty
// body is just as valid as `{}` for routes that don't read req.body at all.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
if (typeof body !== 'string' || body.trim() === '') return done(null, {});
try {
done(null, JSON.parse(body));
} catch (err) {
done(err as Error, undefined);
}
});
await registerAuth(app);
await registerPublicRoutes(app);
await registerAdminRoutes(app);
await registerPrivateAccess(app);
// Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its
// own routes here rather than being hardcoded into public.ts/admin.ts. Runs after
// registerAuth so any /api/admin/* route a widget registers is gated by the same
// X-Api-Key preHandler automatically.
for (const plugin of loadedWidgets.values()) {
plugin.registerPublicRoutes?.(app);
plugin.registerAdminRoutes?.(app);
}
// Fastify's own logger is off (see below) — without this, an unhandled exception
// in any route handler produces a bare 500 with zero trace anywhere, including the
// admin panel's own Logs tab. This is what "Save failed" with no log entry was.
app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => {
logger.error('server', `${req.method} ${req.url} failed: ${err.message}`);
reply.code(err.statusCode ?? 500).send({ error: err.message });
});
// Locally hosted media (see storage/media) — served directly rather than via a
// heavier static-file plugin, since this is a small, flat directory.
app.get('/media/:filename', async (req, reply) => {
const { filename } = req.params as { filename: string };
if (filename.includes('..') || filename.includes('/')) return reply.code(400).send();
const filePath = path.join(MEDIA_DIR, filename);
if (!fs.existsSync(filePath)) return reply.code(404).send();
return reply.send(fs.createReadStream(filePath));
});
// A widget's optional pre-built frontend bundle (see widgets/manifest.ts's
// frontendEntry) — one generic wildcard route rather than one per widget, so it works
// for a widget uploaded after this process started, with no restart (unlike a
// widget's own custom API routes, which need reloadServerRoutes() below to activate).
// Explicit Content-Type is required here (unlike /media/:filename above) — browsers
// reject a dynamically-imported module whose response isn't served as a JS MIME
// type. The wildcard also lets a bundle's own relative imports (e.g. `import
// './helper.mjs'`) resolve automatically, since the browser requests those against
// this same route.
app.get('/widget-assets/:id/*', async (req, reply) => {
const { id } = req.params as { id: string };
const rel = (req.params as { '*': string })['*'];
if (rel.includes('..')) return reply.code(400).send();
const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel);
if (!fs.existsSync(filePath)) return reply.code(404).send();
if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript');
else if (rel.endsWith('.css')) reply.type('text/css');
return reply.send(fs.createReadStream(filePath));
});
// Static "/media/proxy" and "/media/telegram-proxy" take priority over the
// "/media/:filename" param route above regardless of registration order
// (find-my-way, Fastify's router, always prefers a static segment over a parametric
// one at the same depth).
await registerMediaProxy(app);
await registerTelegramMediaProxy(app);
app.get('/health', async () => ({ ok: true }));
return app;
}
/**
* Builds a fresh Fastify instance with every currently loaded widget's routes and swaps
* it in for the running one — the only way to pick up a route a live-uploaded widget
* declares, since Fastify refuses to add routes to an already-listening instance (throws
* "instance is already listening" synchronously). Called once at process startup (with no
* previous instance to close) and again after any widget install/uninstall that changes
* the route set (see widgets/install.ts, uninstall.ts).
*
* Deliberately reuses everything else already live in this process — the DB connection,
* the in-memory widget registry, the scheduler's setInterval loops, the Telegram client's
* session, and the admin API key all stay untouched. Only the HTTP server + its router are
* rebuilt, which is what makes this meaningfully better than a full process restart: none
* of that state is lost, and in particular the admin API key (regenerated only on true
* process start) stays valid, so installing a widget never logs the admin out.
*
* Split into two steps rather than one, because of a real deadlock/dropped-response bug
* hit in testing: the install/delete admin routes that trigger a reload are themselves
* served BY the live app instance. Awaiting the full swap (which closes that very instance)
* from inside its own still-executing request handler closed the connection before the
* response could be flushed — the client saw a bare connection reset, not a 204/201.
*
* validateRoutesBuildable() never touches the live server at all (builds on an OS-assigned
* ephemeral port and closes it again), so it's safe to await synchronously inside a
* request handler — that's what lets a broken widget's install be rejected/rolled back in
* the same response. swapLiveServer() is the part that actually closes the current
* instance; callers that are themselves inside a request handler for the live instance
* MUST defer this past sending their response (e.g. via setImmediate — see
* api/admin.ts's widget install/delete routes). Boot-time startup (see index.ts) has no
* in-flight request to worry about, so it just awaits both in sequence via
* reloadServerRoutes() below.
*/
export async function validateRoutesBuildable(): Promise<void> {
const candidate = await buildApp();
await candidate.listen({ port: 0, host: '127.0.0.1' });
await candidate.close();
}
export async function swapLiveServer(): Promise<void> {
const oldApp = currentApp;
// The new instance binds the same fixed PORT the old one holds, so the old one has to
// let go of it first — there's a brief window (typically well under a second) where
// nothing is listening on PORT. Acceptable for a self-hosted single-admin tool where
// this only fires right after an admin's own widget install/delete action.
if (oldApp) await oldApp.close();
const newApp = await buildApp();
await newApp.listen({ port: PORT, host: '0.0.0.0' });
currentApp = newApp;
logger.info(
'server',
`Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN}) — ${loadedWidgets.size} widget(s) registered`
);
}
/** Boot-time convenience — validate then swap in one call. Only safe when there's no in-flight request being served by the instance being replaced (i.e. process startup). */
export async function reloadServerRoutes(): Promise<void> {
await validateRoutesBuildable();
await swapLiveServer();
}
+153 -66
View File
@@ -18,6 +18,25 @@ db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;');
export function migrate() {
const tableExists = (name: string) =>
!!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(name);
// Renames oldName to newName, but only if newName isn't already taken — a DB that's
// been through an interrupted migration, or an earlier build of the widget system
// that created the widget_<id>_-named table directly (e.g. via plugin.migrate()'s
// CREATE TABLE IF NOT EXISTS running before this rename ever got a chance to), can
// end up with both names present. In that case the widget_<id>_-named table is the
// one actually in use (every read/write goes through it), so the old-named leftover
// is just dropped rather than failing outright on the name collision.
const renameTableIfSafe = (oldName: string, newName: string) => {
if (!tableExists(oldName)) return;
if (tableExists(newName)) {
db.exec(`DROP TABLE "${oldName}";`);
return;
}
db.exec(`ALTER TABLE "${oldName}" RENAME TO "${newName}";`);
};
// Admin auth moved from username/password + sessions to a per-launch API key (see
// api/apiKey.ts, api/auth.ts) — these tables, and any stored password hash or live
// session in them, have no further purpose and are dropped rather than left as
@@ -33,6 +52,26 @@ export function migrate() {
db.exec('DROP TABLE IF EXISTS poe2_watchlist;');
}
// PoE2 moved into the pluggable-widget system (backend/src/widgets/poe2/) and its
// schema now follows the widget_<id>_ naming convention its plugin.migrate() owns —
// a plain rename (metadata-only in SQLite, no row copy) rather than grandfathering
// the old names in via an exceptions list, since it's cheap and PoE2 is meant to be
// the reference implementation of the convention it establishes.
renameTableIfSafe('poe2_watchlist', 'widget_poe2_watchlist');
renameTableIfSafe('poe2_rate_history', 'widget_poe2_rate_history');
db.exec('DROP INDEX IF EXISTS idx_poe2_rate_history_watchlist;');
// Stocks and Bookmarks moved into the pluggable-widget system too (backend/src/widgets/
// stocks/, widgets/bookmarks/) — same plain-rename treatment as PoE2 above, now owned by
// each widget's own plugin.migrate() instead of this file's CREATE TABLE block.
renameTableIfSafe('stock_tickers', 'widget_stocks_tickers');
// "bookmarks" is generic enough to collide with something else entirely — check for the
// old bookmarks shape specifically (its distinctive is_private column) before renaming.
const oldBookmarksCols = db.prepare(`PRAGMA table_info(bookmarks)`).all() as { name: string }[];
if (oldBookmarksCols.some((c) => c.name === 'is_private')) {
renameTableIfSafe('bookmarks', 'widget_bookmarks_items');
}
db.exec(`
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
@@ -215,60 +254,32 @@ export function migrate() {
poe2_updated_at TEXT
);
-- Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see
-- stocks/poller.ts). Price/change/poll-state live directly on the row, same as
-- sources.last_polled_at, rather than a separate quote-cache table.
CREATE TABLE IF NOT EXISTS stock_tickers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
symbol TEXT NOT NULL, -- Yahoo symbol syntax, e.g. "^DJI", "AAPL", "BTC-USD"
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
-- Generic config/cache store for pluggable widgets (see widgets/types.ts,
-- storage/db/widgetKv.ts) — lets a widget stay fully prunable by widget_id alone on
-- uninstall without a bespoke table for simple key/value state.
CREATE TABLE IF NOT EXISTS widget_kv (
widget_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL, -- JSON
updated_at TEXT NOT NULL,
PRIMARY KEY (widget_id, key)
);
-- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs
-- (see poe2/poller.ts), always against the current challenge league (auto-detected,
-- no admin config). Rate is "1 base = last_rate quote"; both currencies' names are
-- captured at add-time from the browse picker, not re-resolved. No icon columns —
-- the UI doesn't display them.
CREATE TABLE IF NOT EXISTS poe2_watchlist (
-- Registry of installed sidebar widgets, both built-in (weather/stocks/bookmarks,
-- and poe2 as the pluggable reference implementation) and uploaded (see
-- widgets/registry.ts, widgets/install.ts). Replaces the old closed
-- widgets/widgetOrder unions on global_settings — see storage/db/settings.ts.
CREATE TABLE IF NOT EXISTS installed_widgets (
id TEXT PRIMARY KEY,
base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted"
base_name TEXT NOT NULL,
quote_currency_id TEXT NOT NULL,
quote_name TEXT NOT NULL,
display_name TEXT NOT NULL,
source TEXT NOT NULL, -- 'builtin' | 'uploaded'
code_path TEXT NOT NULL, -- builtin: identifying module path segment; uploaded: on-disk install dir under ./data/
enabled INTEGER NOT NULL DEFAULT 1,
priority_rank INTEGER NOT NULL,
last_rate REAL,
last_change_24h REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
-- Per-poll rate snapshots for the pairs above — poe.ninja doesn't expose a matching
-- 24h change window, so it's computed ourselves from this history (see
-- poe2/poller.ts). Pruned to the last 2 days on every poll.
CREATE TABLE IF NOT EXISTS poe2_rate_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watchlist_id TEXT NOT NULL,
rate REAL NOT NULL,
recorded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_poe2_rate_history_watchlist ON poe2_rate_history(watchlist_id, recorded_at);
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
-- via is_private (same private-access lock feature as categories.is_private).
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
version TEXT NOT NULL DEFAULT '1.0.0',
owned_tables TEXT NOT NULL DEFAULT '[]', -- JSON array, self-reported by the plugin — used by the uninstall safety-net sweep
frontend_entry TEXT, -- relative path to an optional pre-built browser JS bundle, served from /widget-assets/<id>/*
installed_at TEXT NOT NULL
);
-- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and
@@ -389,22 +400,8 @@ export function migrate() {
`ALTER TABLE global_settings ADD COLUMN widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]'`
);
}
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
// fresh install — the admin can remove/replace any of them via the Stocks tab.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^DJI'],
['S&P 500', '^GSPC'],
['Bitcoin', 'BTC-USD']
];
const stmt = db.prepare(
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
if (!hasColumn('installed_widgets', 'frontend_entry')) {
db.exec('ALTER TABLE installed_widgets ADD COLUMN frontend_entry TEXT');
}
if (!hasColumn('global_settings', 'synthesis_style_preset')) {
db.exec("ALTER TABLE global_settings ADD COLUMN synthesis_style_preset TEXT NOT NULL DEFAULT 'default'");
@@ -445,4 +442,94 @@ export function migrate() {
);
}
}
// One-time seed of the installed_widgets registry from whatever the pre-registry
// install had (upgrade path) or sensible defaults (fresh install) — see
// widgets/registry.ts, storage/db/installedWidgets.ts. Raw SQL rather than importing
// installedWidgets.ts here to avoid a circular import (it imports `db` from this
// file).
const widgetCount = db.prepare('SELECT COUNT(*) as c FROM installed_widgets').get() as { c: number };
if (widgetCount.c === 0) {
const settingsRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any;
const order: string[] = settingsRow
? JSON.parse(settingsRow.widget_order ?? '["weather","stocks","poe2","bookmarks"]')
: ['weather', 'stocks', 'poe2', 'bookmarks'];
const displayNames: Record<string, string> = { weather: 'Weather', stocks: 'Stocks', bookmarks: 'Bookmarks', poe2: 'PoE2' };
const codePaths: Record<string, string> = {
weather: 'widgets/weather',
stocks: 'widgets/stocks',
bookmarks: 'widgets/bookmarks',
poe2: 'widgets/poe2'
};
const ownedTables: Record<string, string[]> = { poe2: ['widget_poe2_watchlist', 'widget_poe2_rate_history'] };
const enabledOf = (id: string) => (settingsRow ? !!settingsRow[`widget_${id}_enabled`] : true);
const insertWidget = db.prepare(
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, owned_tables, installed_at)
VALUES (?, ?, 'builtin', ?, ?, ?, ?, ?)`
);
const installedAt = new Date().toISOString();
order.forEach((id, i) => {
insertWidget.run(
id,
displayNames[id] ?? id,
codePaths[id] ?? id,
enabledOf(id) ? 1 : 0,
i + 1,
JSON.stringify(ownedTables[id] ?? []),
installedAt
);
});
}
// PoE2's league cache (previously bare global_settings columns) moves into the
// generic widget_kv store so it's prunable via the same deleteAllKv('poe2') path as
// everything else the widget owns, rather than needing bespoke column-nulling logic
// on uninstall. One-time copy, guarded on the widget_kv row not already existing.
const poe2CacheRow = db.prepare('SELECT poe2_league_id, poe2_league_name, poe2_updated_at FROM global_settings WHERE id = 1').get() as
| { poe2_league_id: string | null; poe2_league_name: string | null; poe2_updated_at: string | null }
| undefined;
const hasPoe2Kv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'poe2' AND key = 'leagueCache'").get();
if (poe2CacheRow?.poe2_league_id && !hasPoe2Kv) {
db.prepare(
`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('poe2', 'leagueCache', ?, ?)`
).run(
JSON.stringify({
leagueId: poe2CacheRow.poe2_league_id,
leagueName: poe2CacheRow.poe2_league_name,
updatedAt: poe2CacheRow.poe2_updated_at
}),
new Date().toISOString()
);
}
// Weather's config (admin-set location/units) and cache (last-polled forecast) move off
// their bare global_settings columns into two widget_kv entries — same "every widget
// owns its own data" consistency as PoE2's league cache above. Old columns left in
// place, unread.
const weatherRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any;
const hasWeatherConfigKv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'weather' AND key = 'config'").get();
if (weatherRow?.weather_location_name && !hasWeatherConfigKv) {
const nowIso = new Date().toISOString();
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'config', ?, ?)`).run(
JSON.stringify({
locationName: weatherRow.weather_location_name,
latitude: weatherRow.weather_latitude,
longitude: weatherRow.weather_longitude,
unit: weatherRow.weather_unit,
windUnit: weatherRow.weather_wind_unit,
pressureUnit: weatherRow.weather_pressure_unit
}),
nowIso
);
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'cache', ?, ?)`).run(
JSON.stringify({
current: weatherRow.weather_current ? JSON.parse(weatherRow.weather_current) : null,
hourly: JSON.parse(weatherRow.weather_hourly ?? '[]'),
daily: JSON.parse(weatherRow.weather_daily ?? '[]'),
alerts: JSON.parse(weatherRow.weather_alerts ?? '[]'),
updatedAt: weatherRow.weather_updated_at
}),
nowIso
);
}
}
@@ -0,0 +1,73 @@
import { db } from './index.js';
import type { InstalledWidget } from './types.js';
function rowToWidget(row: any): InstalledWidget {
return {
id: row.id,
displayName: row.display_name,
source: row.source,
codePath: row.code_path,
enabled: !!row.enabled,
priorityRank: row.priority_rank,
version: row.version,
ownedTables: JSON.parse(row.owned_tables),
frontendEntry: row.frontend_entry ?? null,
installedAt: row.installed_at
};
}
export function listInstalled(): InstalledWidget[] {
return (db.prepare('SELECT * FROM installed_widgets ORDER BY priority_rank').all() as any[]).map(rowToWidget);
}
export function getInstalled(id: string): InstalledWidget | null {
const row = db.prepare('SELECT * FROM installed_widgets WHERE id = ?').get(id);
return row ? rowToWidget(row) : null;
}
export function insertWidget(widget: {
id: string;
displayName: string;
source: 'builtin' | 'uploaded';
codePath: string;
enabled?: boolean;
priorityRank?: number;
version?: string;
ownedTables?: string[];
frontendEntry?: string | null;
}): InstalledWidget {
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM installed_widgets').get() as { m: number };
const priorityRank = widget.priorityRank ?? maxRank.m + 1;
const installedAt = new Date().toISOString();
db.prepare(
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, frontend_entry, installed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
widget.id,
widget.displayName,
widget.source,
widget.codePath,
widget.enabled === false ? 0 : 1,
priorityRank,
widget.version ?? '1.0.0',
JSON.stringify(widget.ownedTables ?? []),
widget.frontendEntry ?? null,
installedAt
);
return getInstalled(widget.id)!;
}
export function setEnabled(id: string, enabled: boolean) {
db.prepare('UPDATE installed_widgets SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id);
}
// Ids in display order — every id must already exist as a row; unlisted ids keep their
// current rank (mirrors the old widget_order JSON array's "admin-sortable" behavior).
export function reorder(ids: string[]) {
const stmt = db.prepare('UPDATE installed_widgets SET priority_rank = ? WHERE id = ?');
ids.forEach((id, i) => stmt.run(i + 1, id));
}
export function deleteInstalled(id: string) {
db.prepare('DELETE FROM installed_widgets WHERE id = ?').run(id);
}
+40 -57
View File
@@ -1,6 +1,33 @@
import { db } from './index.js';
import type { GlobalSettings } from './types.js';
import * as installedWidgetsDb from './installedWidgets.js';
const BUILTIN_WIDGET_IDS = ['weather', 'stocks', 'bookmarks', 'poe2'] as const;
// widgets/widgetOrder are computed from the installed_widgets registry (see
// storage/db/installedWidgets.ts, widgets/registry.ts) rather than stored as their own
// global_settings columns — the registry is the single source of truth for enable state
// and ordering for every widget, built-in or uploaded. Only the four built-in ids are
// reflected here since GlobalSettings.widgets/widgetOrder are closed unions the frontend
// depends on (uploaded widgets have no frontend representation yet).
function widgetsAndOrder(): Pick<GlobalSettings, 'widgets' | 'widgetOrder'> {
const installed = installedWidgetsDb.listInstalled();
const byId = new Map(installed.map((w) => [w.id, w]));
const widgets = {
weather: !!byId.get('weather')?.enabled,
stocks: !!byId.get('stocks')?.enabled,
bookmarks: !!byId.get('bookmarks')?.enabled,
poe2: !!byId.get('poe2')?.enabled
};
const widgetOrder = installed.map((w) => w.id).filter((id): id is (typeof BUILTIN_WIDGET_IDS)[number] =>
(BUILTIN_WIDGET_IDS as readonly string[]).includes(id)
);
return { widgets, widgetOrder };
}
// Every widget's own config/data now lives behind its own /api/widget/<id> routes (see
// widgets/weather/db.ts, widgets/poe2/poll.ts's league cache, etc.) — this settings blob
// is just the scalar pipeline knobs plus the 4 builtins' enable/order flags.
function rowToSettings(row: any): GlobalSettings {
return {
mergeStrictness: row.merge_strictness,
@@ -20,38 +47,13 @@ function rowToSettings(row: any): GlobalSettings {
synthesisCustomInstructions: row.synthesis_custom_instructions,
synthesisNumCtx: row.synthesis_num_ctx,
synthesisNumPredict: row.synthesis_num_predict,
widgets: {
weather: !!row.widget_weather_enabled,
stocks: !!row.widget_stocks_enabled,
bookmarks: !!row.widget_bookmarks_enabled,
poe2: !!row.widget_poe2_enabled
},
widgetOrder: JSON.parse(row.widget_order),
...widgetsAndOrder(),
retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days,
rawItemMaxAgeDays: row.raw_item_max_age_days,
storageCapEnabled: !!row.storage_cap_enabled,
storageCapValue: row.storage_cap_value,
storageCapUnit: row.storage_cap_unit
},
weather: {
locationName: row.weather_location_name,
latitude: row.weather_latitude,
longitude: row.weather_longitude,
unit: row.weather_unit,
windUnit: row.weather_wind_unit,
pressureUnit: row.weather_pressure_unit,
// Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse.
current: row.weather_current ? JSON.parse(row.weather_current) : null,
hourly: JSON.parse(row.weather_hourly),
daily: JSON.parse(row.weather_daily),
alerts: JSON.parse(row.weather_alerts),
updatedAt: row.weather_updated_at
},
poe2: {
leagueId: row.poe2_league_id,
leagueName: row.poe2_league_name,
updatedAt: row.poe2_updated_at
}
};
}
@@ -68,10 +70,18 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
...patch,
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) },
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
};
if (patch.widgets) {
for (const id of BUILTIN_WIDGET_IDS) {
if (patch.widgets[id] !== undefined) installedWidgetsDb.setEnabled(id, patch.widgets[id]);
}
}
if (patch.widgetOrder) {
installedWidgetsDb.reorder(patch.widgetOrder);
}
// Named params (rather than positional `?`) so this list can be reordered or
// extended without the column list and the bound-values list silently drifting
// out of sync — node:sqlite binds each by its `$name` key, not position.
@@ -86,16 +96,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
telegram_media_mode=$telegram_media_mode,
synthesis_style_preset=$synthesis_style_preset, synthesis_custom_instructions=$synthesis_custom_instructions,
synthesis_num_ctx=$synthesis_num_ctx, synthesis_num_predict=$synthesis_num_predict,
widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
widget_order=$widget_order,
published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days,
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit,
weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude,
weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit,
weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily,
weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at,
poe2_league_id=$poe2_league_id, poe2_league_name=$poe2_league_name, poe2_updated_at=$poe2_updated_at
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit
WHERE id = 1`
).run({
$merge_strictness: merged.mergeStrictness,
@@ -115,30 +117,11 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$synthesis_custom_instructions: merged.synthesisCustomInstructions,
$synthesis_num_ctx: merged.synthesisNumCtx,
$synthesis_num_predict: merged.synthesisNumPredict,
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
$widget_order: JSON.stringify(merged.widgetOrder),
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
$storage_cap_value: merged.retention.storageCapValue,
$storage_cap_unit: merged.retention.storageCapUnit,
$weather_location_name: merged.weather.locationName,
$weather_latitude: merged.weather.latitude,
$weather_longitude: merged.weather.longitude,
$weather_unit: merged.weather.unit,
$weather_wind_unit: merged.weather.windUnit,
$weather_pressure_unit: merged.weather.pressureUnit,
$weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null,
$weather_hourly: JSON.stringify(merged.weather.hourly),
$weather_daily: JSON.stringify(merged.weather.daily),
$weather_alerts: JSON.stringify(merged.weather.alerts),
$weather_updated_at: merged.weather.updatedAt,
$poe2_league_id: merged.poe2.leagueId,
$poe2_league_name: merged.poe2.leagueName,
$poe2_updated_at: merged.poe2.updatedAt
$storage_cap_unit: merged.retention.storageCapUnit
});
return getSettings();
}
+17 -67
View File
@@ -214,21 +214,6 @@ export interface Category {
disableAi: boolean;
}
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface StockTicker {
id: string;
label: string;
@@ -262,6 +247,23 @@ export interface Poe2WatchlistEntry {
createdAt: string;
}
/** A row in `installed_widgets` — the registry of both built-in and uploaded sidebar widgets (see widgets/registry.ts). */
export interface InstalledWidget {
id: string;
displayName: string;
source: 'builtin' | 'uploaded';
/** Builtin: the module's identifying path segment (e.g. 'weather', 'widgets/poe2'). Uploaded: the on-disk install directory, e.g. './data/widgets-installed/<id>'. */
codePath: string;
enabled: boolean;
priorityRank: number;
version: string;
/** Self-reported by the plugin (WidgetPlugin.ownedTables) at install/load time — used by the uninstall safety-net sweep. */
ownedTables: string[];
/** Relative path (within the widget's install dir) to an optional pre-built browser JS bundle, served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Null for a widget with no custom frontend (falls back to the generic report card). */
frontendEntry: string | null;
installedAt: string;
}
export interface Bookmark {
id: string;
name: string;
@@ -313,56 +315,4 @@ export interface GlobalSettings {
storageCapValue: number;
storageCapUnit: 'MB' | 'GB';
};
/** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */
weather: {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */
alerts: WeatherAlert[];
updatedAt: string | null;
};
/**
* Sidebar "PoE2" widget cache — see poe2/poller.ts. No admin-set config (unlike weather):
* the league is always auto-detected as the current challenge league, so this is purely
* a cache of what the last poll learned. Watchlist entries themselves live in the
* poe2_watchlist table, not here — same split as stock_tickers vs. this settings row.
*/
poe2: {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
};
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { db } from './index.js';
// Generic config/cache store for pluggable widgets (see widgets/types.ts) — a widget with
// simple needs (weather-shaped: "last fetched value + timestamp") uses this instead of
// getting bolted-on global_settings columns, so it stays fully prunable by widget_id alone
// on uninstall (see widgets/uninstall.ts) without any host-side schema change.
export function getKv<T>(widgetId: string, key: string): T | null {
const row = db.prepare('SELECT value FROM widget_kv WHERE widget_id = ? AND key = ?').get(widgetId, key) as
| { value: string }
| undefined;
return row ? (JSON.parse(row.value) as T) : null;
}
export function setKv(widgetId: string, key: string, value: unknown) {
db.prepare(
`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(widget_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
).run(widgetId, key, JSON.stringify(value), new Date().toISOString());
}
export function deleteAllKv(widgetId: string) {
db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId);
}
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { Bookmark } from './types.js';
import { db } from '../../storage/db/index.js';
import { getKv, setKv } from '../../storage/db/widgetKv.js';
import type { Bookmark } from '../../storage/db/types.js';
function rowToBookmark(row: any): Bookmark {
return {
@@ -14,33 +15,44 @@ function rowToBookmark(row: any): Bookmark {
}
// Always returns every bookmark, private or not — filtering for unauthenticated visitors
// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories().
// happens at the route layer (GET /api/widget/bookmarks), same as categoriesDb.listCategories().
export function listBookmarks(): Bookmark[] {
const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all();
const rows = db.prepare('SELECT * FROM widget_bookmarks_items ORDER BY priority_rank').all();
return rows.map(rowToBookmark);
}
export function createBookmark(name: string, url: string, isPrivate = false): Bookmark {
const id = `bm-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM bookmarks').get() as { m: number };
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_bookmarks_items').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO widget_bookmarks_items (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt);
return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt };
}
export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null {
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id);
const existing = db.prepare('SELECT * FROM widget_bookmarks_items WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToBookmark(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
db.prepare('UPDATE widget_bookmarks_items SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
merged.name, merged.url, merged.isPrivate ? 1 : 0, id
);
return merged;
}
export function deleteBookmark(id: string) {
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id);
db.prepare('DELETE FROM widget_bookmarks_items WHERE id = ?').run(id);
}
// How many columns the sidebar/admin panel lays the bookmark list out in — stored in
// widget_kv rather than a bespoke table since it's a single scalar, same idiom as weather's
// config (see widgets/weather/db.ts).
export function getColumns(): 1 | 2 | 3 {
return getKv<{ columns: 1 | 2 | 3 }>('bookmarks', 'config')?.columns ?? 1;
}
export function setColumns(columns: 1 | 2 | 3) {
setKv('bookmarks', 'config', { columns });
}
+79
View File
@@ -0,0 +1,79 @@
import type { DatabaseSync } from 'node:sqlite';
import type { WidgetPlugin } from '../types.js';
import * as bookmarksDb from './db.js';
import { hasPrivateAccess } from '../../api/privateAccess.js';
const OWNED_TABLES = ['widget_bookmarks_items'];
// Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public via
// is_private (same private-access lock feature as categories.is_private). No poll (purely
// admin-curated, no external fetch). Built-in and non-deletable, but otherwise a full
// WidgetPlugin like an uploaded one.
export const bookmarksPlugin: WidgetPlugin = {
id: 'bookmarks',
displayName: 'Bookmarks',
version: '1.0.0',
ownedTables: OWNED_TABLES,
migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS widget_bookmarks_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
`);
},
registerPublicRoutes(app) {
app.get('/api/widget/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
const items = hasPrivateAccess(req) ? bookmarks : bookmarks.filter((b) => !b.isPrivate);
return { items, columns: bookmarksDb.getColumns() };
});
},
registerAdminRoutes(app) {
app.get('/api/admin/widget/bookmarks', async () => bookmarksDb.listBookmarks());
app.get('/api/admin/widget/bookmarks/config', async () => ({ columns: bookmarksDb.getColumns() }));
app.patch('/api/admin/widget/bookmarks/config', async (req, reply) => {
const { columns } = req.body as { columns?: number };
if (columns !== 1 && columns !== 2 && columns !== 3) {
return reply.code(400).send({ error: 'columns must be 1, 2, or 3' });
}
bookmarksDb.setColumns(columns);
return { columns };
});
app.post('/api/admin/widget/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) {
return reply.code(400).send({ error: 'name and url are required' });
}
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
return reply.code(201).send(created);
});
app.patch('/api/admin/widget/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = bookmarksDb.updateBookmark(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/widget/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
bookmarksDb.deleteBookmark(id);
return reply.code(204).send();
});
},
uninstall(db: DatabaseSync) {
db.exec('DROP TABLE IF EXISTS widget_bookmarks_items;');
}
};
+88
View File
@@ -0,0 +1,88 @@
import fs from 'node:fs';
import path from 'node:path';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { loadUploadedWidget, loadedWidgets } from './registry.js';
import { startWidgetPolling } from '../queue/scheduler.js';
import { validateManifest, type WidgetManifest } from './manifest.js';
import { validateRoutesBuildable } from '../server.js';
import { logger } from '../storage/db/logs.js';
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
export type InstallResult =
| { ok: true; id: string; needsServerSwap: boolean }
| { ok: false; error: string };
// Installs and hot-loads a widget uploaded live to the running backend (see
// api/admin.ts's POST /api/admin/widgets) — writes its files under ./data/, never
// dist/ or src/, so it survives a rebuild/redeploy of the core app. Its migrate()
// runs and its poll interval (if declared) starts immediately. If it declares
// registerPublicRoutes/registerAdminRoutes, `needsServerSwap` comes back true — the
// caller (api/admin.ts's POST route) must call server.ts's swapLiveServer() itself,
// AFTER sending its own response, never inline here: this function runs inside the
// very request handler whose underlying Fastify instance a swap would close, so
// awaiting the swap here would drop the response before the client ever sees it (hit
// this for real in testing). validateRoutesBuildable() is safe to await here — it
// never touches the live server, only a throwaway instance on an ephemeral port — so
// a widget whose routes are actually broken (e.g. a path collision) is still caught
// and rolled back within this same call, before anything user-visible commits.
//
// Either way this is a full HTTP-server rebuild within the running process, not a
// process restart — the DB connection, scheduler intervals, Telegram session, and
// (critically) the admin API key all survive; a process restart would regenerate the
// key and log the admin out.
export async function installUploadedWidget(manifest: unknown, files: unknown): Promise<InstallResult> {
const validationError = validateManifest(manifest, files);
if (validationError) return { ok: false, error: validationError };
const m = manifest as WidgetManifest;
const f = files as Record<string, string>;
if (installedWidgetsDb.getInstalled(m.id)) {
return { ok: false, error: `widget "${m.id}" is already installed` };
}
const dir = path.join(WIDGETS_INSTALLED_DIR, m.id);
fs.mkdirSync(dir, { recursive: true });
for (const [relPath, content] of Object.entries(f)) {
const filePath = path.join(dir, relPath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
}
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2), 'utf8');
const plugin = await loadUploadedWidget(m.id, dir);
if (!plugin) {
fs.rmSync(dir, { recursive: true, force: true });
return { ok: false, error: "widget failed to load — check its entry file's default export satisfies WidgetPlugin" };
}
installedWidgetsDb.insertWidget({
id: m.id,
displayName: m.displayName,
source: 'uploaded',
codePath: dir,
version: m.version,
ownedTables: plugin.ownedTables ?? [],
frontendEntry: m.frontendEntry ?? null
});
const needsServerSwap = !!(plugin.registerPublicRoutes || plugin.registerAdminRoutes);
if (needsServerSwap) {
try {
await validateRoutesBuildable();
} catch (err) {
// A widget whose routes break Fastify's registration (e.g. a path collision)
// isn't a successful install — the site itself was never at risk since this
// only ever touched a throwaway ephemeral-port instance, but this widget still
// needs to be fully rolled back rather than left half-installed.
logger.error('widgets', `Install of "${m.id}" rolled back — its routes failed to register: ${(err as Error).message}`);
loadedWidgets.delete(m.id);
installedWidgetsDb.deleteInstalled(m.id);
fs.rmSync(dir, { recursive: true, force: true });
return { ok: false, error: `widget's routes failed to register: ${(err as Error).message}` };
}
}
startWidgetPolling(plugin);
return { ok: true, id: m.id, needsServerSwap };
}
+47
View File
@@ -0,0 +1,47 @@
const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
const RESERVED_IDS = new Set(['weather', 'stocks', 'bookmarks', 'poe2']);
const MAX_TOTAL_BYTES = 5 * 1024 * 1024;
export interface WidgetManifest {
id: string;
displayName: string;
version: string;
/** Relative path within `files` to the ESM entry point, e.g. "index.mjs" — a default export satisfying WidgetPlugin. */
entry: string;
/** Optional relative path within `files` to a pre-built browser JS bundle (plain vanilla JS, not raw Svelte source — see widgets/registry.ts's frontend-loading notes) exporting a default `{ mount(container, ctx) }`. Served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Omit for a widget with no custom UI — it falls back to the generic report card. */
frontendEntry?: string;
}
// Upload body shape: { manifest, files: { "index.mjs": "<source text>", ... } } — plain
// JSON rather than a literal zip, so no archive/multipart dependency is needed (see
// widgets/install.ts). Returns a human-readable error string, or null if valid.
export function validateManifest(manifest: unknown, files: unknown): string | null {
if (!manifest || typeof manifest !== 'object') return 'manifest is required';
const m = manifest as Record<string, unknown>;
if (typeof m.id !== 'string' || !ID_PATTERN.test(m.id)) return 'manifest.id must match /^[a-z0-9-]{1,40}$/';
if (RESERVED_IDS.has(m.id)) return `id "${m.id}" is reserved for a built-in widget`;
if (typeof m.displayName !== 'string' || !m.displayName.trim()) return 'manifest.displayName is required';
if (typeof m.version !== 'string' || !m.version.trim()) return 'manifest.version is required';
if (typeof m.entry !== 'string' || !m.entry.trim()) return 'manifest.entry is required';
if (m.frontendEntry !== undefined && (typeof m.frontendEntry !== 'string' || !m.frontendEntry.trim())) {
return 'manifest.frontendEntry must be a non-empty string when present';
}
if (!files || typeof files !== 'object' || Array.isArray(files)) return 'files must be a non-empty object';
const entries = Object.entries(files as Record<string, unknown>);
if (entries.length === 0) return 'files must be a non-empty object';
if (!(m.entry in (files as Record<string, unknown>))) return `entry "${m.entry as string}" not found in files`;
if (m.frontendEntry !== undefined && !(m.frontendEntry in (files as Record<string, unknown>))) {
return `frontendEntry "${m.frontendEntry as string}" not found in files`;
}
let totalBytes = 0;
for (const [relPath, content] of entries) {
if (typeof content !== 'string') return `files["${relPath}"] must be a string`;
if (relPath.startsWith('/') || relPath.split('/').some((seg) => seg === '..')) return `invalid file path "${relPath}"`;
totalBytes += Buffer.byteLength(content, 'utf8');
}
if (totalBytes > MAX_TOTAL_BYTES) return `bundle exceeds ${MAX_TOTAL_BYTES}-byte limit`;
return null;
}
@@ -1,5 +1,5 @@
// poe.ninja's public PoE2 economy API — free, no account or API key required. This is the
// only file that talks to it; poller.ts orchestrates when/how results get saved, same
// only file that talks to it; poll.ts orchestrates when/how results get saved, same
// separation as backend/src/telegram/ and backend/src/weather/ keep between the raw client
// and their callers.
//
@@ -13,7 +13,7 @@
// change, which poe.ninja's overview doesn't expose directly. Every line's `primaryValue` is
// expressed in the same (unspecified, and irrelevant) reference currency, so any pair's rate
// is just baseValue / quoteValue with the reference cancelling out — see fetchCurrencyValues
// below and poe2/poller.ts, which self-computes change from its own polling history instead.
// below and poll.ts, which self-computes change from its own polling history instead.
const BASE_URL = 'https://poe.ninja';
export interface LeagueInfo {
@@ -57,8 +57,9 @@ async function fetchCurrencyOverview(leagueId: string): Promise<RawCurrencyOverv
}
// Fetches the whole traded-currency list for the admin's search-and-pick UI (see
// api/admin.ts's GET /api/admin/poe2/browse) — only currencies that actually have a `lines`
// entry (i.e. are currently traded), not every currency poe.ninja has ever known about.
// widgets/poe2/plugin.ts's GET /api/admin/poe2/browse) — only currencies that actually have
// a `lines` entry (i.e. are currently traded), not every currency poe.ninja has ever known
// about.
export async function browseCurrencies(leagueId: string): Promise<CurrencyBrowseEntry[]> {
const { lines, items } = await fetchCurrencyOverview(leagueId);
const nameById = new Map(items.map((item) => [item.id, item.name]));
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { Poe2WatchlistEntry } from './types.js';
import { db } from '../../storage/db/index.js';
import type { Poe2WatchlistEntry } from '../../storage/db/types.js';
interface CurrencyRef {
currencyId: string;
@@ -24,21 +24,21 @@ function rowToEntry(row: any): Poe2WatchlistEntry {
}
export function listWatchlist(): Poe2WatchlistEntry[] {
const rows = db.prepare('SELECT * FROM poe2_watchlist ORDER BY priority_rank').all();
const rows = db.prepare('SELECT * FROM widget_poe2_watchlist ORDER BY priority_rank').all();
return rows.map(rowToEntry);
}
// No update() — currencies are picked from a live browse list (see poe2/client.ts's
// No update() — currencies are picked from a live browse list (see widgets/poe2/client.ts's
// browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the
// rare "picked the wrong one" case.
export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry {
const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}`
.replace(/-+/g, '-')
.replace(/(^-|-$)/g, '');
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM poe2_watchlist').get() as { m: number };
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_poe2_watchlist').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO poe2_watchlist
`INSERT INTO widget_poe2_watchlist
(id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt);
@@ -58,14 +58,14 @@ export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2Wa
}
export function removeWatchlistEntry(id: string) {
db.prepare('DELETE FROM poe2_rate_history WHERE watchlist_id = ?').run(id);
db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id);
db.prepare('DELETE FROM widget_poe2_rate_history WHERE watchlist_id = ?').run(id);
db.prepare('DELETE FROM widget_poe2_watchlist WHERE id = ?').run(id);
}
// One snapshot per poll (see poe2/poller.ts) — the raw material 24h change is computed
// One snapshot per poll (see widgets/poe2/poll.ts) — the raw material 24h change is computed
// from, since poe.ninja itself doesn't expose per-pair rates or a matching change window.
export function recordRate(watchlistId: string, rate: number, recordedAt: string) {
db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
db.prepare('INSERT INTO widget_poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
watchlistId,
rate,
recordedAt
@@ -77,14 +77,14 @@ export function recordRate(watchlistId: string, rate: number, recordedAt: string
// than fabricating a 0% figure.
export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null {
const row = db
.prepare('SELECT rate FROM poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1')
.prepare('SELECT rate FROM widget_poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1')
.get(watchlistId, cutoffIso) as { rate: number } | undefined;
return row ? row.rate : null;
}
export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) {
db.prepare(
`UPDATE poe2_watchlist
`UPDATE widget_poe2_watchlist
SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ?
WHERE id = ?`
).run(rate, change24h, new Date().toISOString(), error, id);
@@ -94,5 +94,5 @@ export function markPolled(id: string, rate: number | null, change24h: number |
// poll to be briefly late without losing the data point it needs.
export function pruneOldHistory() {
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
db.prepare('DELETE FROM poe2_rate_history WHERE recorded_at < ?').run(cutoff);
db.prepare('DELETE FROM widget_poe2_rate_history WHERE recorded_at < ?').run(cutoff);
}
+107
View File
@@ -0,0 +1,107 @@
import type { DatabaseSync } from 'node:sqlite';
import type { WidgetPlugin } from '../types.js';
import { deleteAllKv } from '../../storage/db/widgetKv.js';
import { logger } from '../../storage/db/logs.js';
import * as poe2Db from './db.js';
import { browseCurrencies, fetchCurrentLeague } from './client.js';
import { pollPoe2Now, getLeagueCache } from './poll.js';
const OWNED_TABLES = ['widget_poe2_watchlist', 'widget_poe2_rate_history'];
// Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs, always
// against the current challenge league (auto-detected, no admin config). The pilot
// implementation of the pluggable-widget system (see widgets/types.ts) — every other
// widget still ships in-process, but this one exercises the full migrate/poll/routes/
// uninstall lifecycle a live-uploaded widget would also go through.
export const poe2Plugin: WidgetPlugin = {
id: 'poe2',
displayName: 'PoE2',
version: '1.0.0',
ownedTables: OWNED_TABLES,
migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS widget_poe2_watchlist (
id TEXT PRIMARY KEY,
base_currency_id TEXT NOT NULL,
base_name TEXT NOT NULL,
quote_currency_id TEXT NOT NULL,
quote_name TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
last_rate REAL,
last_change_24h REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS widget_poe2_rate_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watchlist_id TEXT NOT NULL,
rate REAL NOT NULL,
recorded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_widget_poe2_rate_history_watchlist ON widget_poe2_rate_history(watchlist_id, recorded_at);
`);
},
poll: {
// poe.ninja's own overview data doesn't refresh faster than hourly, so polling
// more often than this just re-fetches the same numbers.
intervalMs: 60 * 60_000,
run: pollPoe2Now
},
registerPublicRoutes(app) {
app.get('/api/widget/poe2', async () => {
const { leagueName, updatedAt } = getLeagueCache();
return { leagueName, updatedAt, entries: poe2Db.listWatchlist() };
});
},
registerAdminRoutes(app) {
// League is always auto-detected, never admin-set.
app.get('/api/admin/widget/poe2/browse', async (_req, reply) => {
try {
const league = await fetchCurrentLeague();
return await browseCurrencies(league.id);
} catch (err) {
return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` });
}
});
app.get('/api/admin/widget/poe2/watchlist', async () => poe2Db.listWatchlist());
app.post('/api/admin/widget/poe2/watchlist', async (req, reply) => {
const { base, quote } = req.body as {
base?: { currencyId?: string; name?: string };
quote?: { currencyId?: string; name?: string };
};
if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) {
return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' });
}
if (base.currencyId === quote.currencyId) {
return reply.code(400).send({ error: 'Base and quote currencies must be different' });
}
const created = poe2Db.addWatchlistEntry(
{ currencyId: base.currencyId, name: base.name },
{ currencyId: quote.currencyId, name: quote.name }
);
// Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap,
// and refreshes every existing entry's rate too.
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.delete('/api/admin/widget/poe2/watchlist/:id', async (req, reply) => {
const { id } = req.params as { id: string };
poe2Db.removeWatchlistEntry(id);
return reply.code(204).send();
});
},
uninstall(db: DatabaseSync) {
db.exec('DROP TABLE IF EXISTS widget_poe2_rate_history;');
db.exec('DROP TABLE IF EXISTS widget_poe2_watchlist;');
deleteAllKv('poe2');
}
};
+74
View File
@@ -0,0 +1,74 @@
import * as poe2Db from './db.js';
import { getKv, setKv } from '../../storage/db/widgetKv.js';
import { logger } from '../../storage/db/logs.js';
import { fetchCurrentLeague, fetchCurrencyValues } from './client.js';
const DAY_MS = 24 * 60 * 60_000;
const WIDGET_ID = 'poe2';
interface LeagueCache {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
}
// Cache of what the last poll learned about the current league — no admin-set config
// (unlike weather): the league is always auto-detected, so this is purely a cache. Lives
// in the generic widget_kv store (see storage/db/widgetKv.ts) so it's prunable via the
// same deleteAllKv('poe2') path as everything else this widget owns.
export function getLeagueCache(): LeagueCache {
return getKv<LeagueCache>(WIDGET_ID, 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null };
}
function pctChange(current: number, past: number | null): number | null {
if (past === null || past === 0) return null;
return ((current - past) / past) * 100;
}
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
// admin adds a pair (see plugin.ts's admin routes) — always re-detects the current
// challenge league fresh (cheap, guarantees correctness across league rotations with no
// separate staleness logic), then one overview request covers the whole watchlist. A pair
// whose base or quote currency is no longer traded this league gets its own lastError, it
// never aborts the rest of the batch.
export async function pollPoe2Now(): Promise<void> {
let league;
try {
league = await fetchCurrentLeague();
} catch (err) {
logger.error('poe2', `League lookup failed: ${(err as Error).message}`);
return;
}
const entries = poe2Db.listWatchlist();
if (entries.length === 0) {
setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() });
return;
}
try {
const valuesById = await fetchCurrencyValues(league.id);
const now = new Date();
const nowIso = now.toISOString();
const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString();
for (const entry of entries) {
const baseValue = valuesById.get(entry.baseCurrencyId);
const quoteValue = valuesById.get(entry.quoteCurrencyId);
if (baseValue === undefined || quoteValue === undefined) {
poe2Db.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league');
continue;
}
const rate = baseValue / quoteValue;
const change24h = pctChange(rate, poe2Db.rateAtOrBefore(entry.id, cutoff24h));
poe2Db.recordRate(entry.id, rate, nowIso);
poe2Db.markPolled(entry.id, rate, change24h, null);
}
poe2Db.pruneOldHistory();
setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: nowIso });
} catch (err) {
logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`);
}
}
+69
View File
@@ -0,0 +1,69 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { db } from '../storage/db/index.js';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { logger } from '../storage/db/logs.js';
import type { WidgetPlugin } from './types.js';
import { sweepOrphanedWidgetData } from './sweep.js';
import { weatherPlugin } from './weather/plugin.js';
import { stocksPlugin } from './stocks/plugin.js';
import { bookmarksPlugin } from './bookmarks/plugin.js';
import { poe2Plugin } from './poe2/plugin.js';
// Both built-in and uploaded widgets funnel into this one map — scheduler dispatch and
// route registration (see index.ts, queue/scheduler.ts) iterate it generically instead of
// hardcoding a block per widget, mirroring ingestion/poller.ts's
// Record<Source['type'], SourceAdapter> dispatch, just keyed by a dynamic string id.
export const loadedWidgets = new Map<string, WidgetPlugin>();
function registerBuiltinWidget(plugin: WidgetPlugin) {
plugin.migrate?.(db);
loadedWidgets.set(plugin.id, plugin);
}
// Called once at startup (see index.ts, after migrate() and before route registration /
// startScheduler()) and again after a live upload (see widgets/install.ts) to pick up just
// the newly-installed one without reloading everything else.
export async function loadAllWidgets(): Promise<void> {
loadedWidgets.clear();
registerBuiltinWidget(weatherPlugin);
registerBuiltinWidget(stocksPlugin);
registerBuiltinWidget(bookmarksPlugin);
registerBuiltinWidget(poe2Plugin);
for (const row of installedWidgetsDb.listInstalled().filter((w) => w.source === 'uploaded')) {
await loadUploadedWidget(row.id, row.codePath);
}
sweepOrphanedWidgetData(
db,
installedWidgetsDb.listInstalled().map((w) => w.id)
);
}
// A widget whose code fails to load logs an error and is simply absent from
// loadedWidgets — scheduler/route registration skip an id with no entry. Its
// installed_widgets row stays, so it's still visible and deletable from the admin side
// (see widgets/uninstall.ts, which doesn't require the widget's own code to be loadable).
export async function loadUploadedWidget(id: string, codePath: string): Promise<WidgetPlugin | null> {
try {
const manifest = JSON.parse(fs.readFileSync(path.join(codePath, 'manifest.json'), 'utf8')) as { entry: string };
const entryUrl = pathToFileURL(path.join(codePath, manifest.entry)).href;
const mod = await import(entryUrl);
const plugin: WidgetPlugin = mod.default;
if (!plugin || typeof plugin.id !== 'string') {
throw new Error('module has no default-exported WidgetPlugin');
}
if (plugin.id !== id) {
throw new Error(`plugin id "${plugin.id}" does not match installed id "${id}"`);
}
plugin.migrate?.(db);
loadedWidgets.set(id, plugin);
return plugin;
} catch (err) {
logger.error('widgets', `Failed to load uploaded widget "${id}": ${(err as Error).message}`);
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Generic live-data shape a widget's poll.run() can publish via
* setKv(id, 'report', report) (see storage/db/widgetKv.ts) for the sidebar's generic
* report card to render — see api/public.ts's GET /api/widget/:id/report, registered once
* at startup so it works for any widget id including ones uploaded after boot, with zero
* per-widget route registration (sidesteps Fastify's "no routes after listen()" limit).
*/
export interface WidgetReport {
title: string;
headline?: { value: string; delta?: string } | null;
rows?: { label: string; value: string }[];
updatedAt: string | null;
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { db } from '../../storage/db/index.js';
import type { StockTicker } from '../../storage/db/types.js';
function rowToTicker(row: any): StockTicker {
return {
id: row.id,
label: row.label,
symbol: row.symbol,
priorityRank: row.priority_rank,
lastPrice: row.last_price,
lastChangePercent: row.last_change_percent,
lastPolledAt: row.last_polled_at,
lastError: row.last_error,
createdAt: row.created_at
};
}
export function listStockTickers(): StockTicker[] {
const rows = db.prepare('SELECT * FROM widget_stocks_tickers ORDER BY priority_rank').all();
return rows.map(rowToTicker);
}
export function createStockTicker(label: string, symbol: string): StockTicker {
const id = `stk-${symbol.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_stocks_tickers').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
).run(id, label, symbol, maxRank.m + 1, createdAt);
return {
id, label, symbol, priorityRank: maxRank.m + 1,
lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt
};
}
export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null {
const existing = db.prepare('SELECT * FROM widget_stocks_tickers WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToTicker(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE widget_stocks_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id);
return { ...merged };
}
export function deleteStockTicker(id: string) {
db.prepare('DELETE FROM widget_stocks_tickers WHERE id = ?').run(id);
}
export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) {
db.prepare(
'UPDATE widget_stocks_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?'
).run(price, changePercent, new Date().toISOString(), error, id);
}
+94
View File
@@ -0,0 +1,94 @@
import type { DatabaseSync } from 'node:sqlite';
import type { WidgetPlugin } from '../types.js';
import { logger } from '../../storage/db/logs.js';
import * as stocksDb from './db.js';
import { pollStocksNow } from './poll.js';
const OWNED_TABLES = ['widget_stocks_tickers'];
// Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see poll.ts).
// Price/change/poll-state live directly on its own table, same as sources.last_polled_at,
// rather than a separate quote-cache table. Built-in and non-deletable, but otherwise a
// full WidgetPlugin like an uploaded one.
export const stocksPlugin: WidgetPlugin = {
id: 'stocks',
displayName: 'Stocks',
version: '1.0.0',
ownedTables: OWNED_TABLES,
migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS widget_stocks_tickers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
symbol TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
`);
// Seed a handful of sensible default tickers so the widget isn't empty on a fresh
// install — the admin can remove/replace any of them.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM widget_stocks_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^DJI'],
['S&P 500', '^GSPC'],
['Bitcoin', 'BTC-USD']
];
const stmt = db.prepare(
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
}
},
poll: {
// Per admin spec — stock prices move faster than weather.
intervalMs: 15 * 60_000,
run: pollStocksNow
},
registerPublicRoutes(app) {
app.get('/api/widget/stocks', async () => stocksDb.listStockTickers());
},
registerAdminRoutes(app) {
app.get('/api/admin/widget/stocks', async () => stocksDb.listStockTickers());
app.post('/api/admin/widget/stocks', async (req, reply) => {
const { label, symbol } = req.body as { label?: string; symbol?: string };
if (!label || !label.trim() || !symbol || !symbol.trim()) {
return reply.code(400).send({ error: 'label and symbol are required' });
}
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
// and refreshes every existing ticker's price too.
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/widget/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = stocksDb.updateStockTicker(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/widget/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
stocksDb.deleteStockTicker(id);
return reply.code(204).send();
});
},
uninstall(db: DatabaseSync) {
db.exec('DROP TABLE IF EXISTS widget_stocks_tickers;');
}
};
@@ -1,11 +1,11 @@
import * as stocksDb from '../storage/db/stocks.js';
import { logger } from '../storage/db/logs.js';
import * as stocksDb from './db.js';
import { logger } from '../../storage/db/logs.js';
import { fetchQuotes } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a
// ticker (see api/admin.ts) — one request per configured ticker (see client.ts for why
// there's no batch endpoint here). A symbol Yahoo can't resolve gets its own lastError,
// it never aborts the rest of the batch.
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
// admin adds a ticker (see plugin.ts's admin routes) — one request per configured ticker
// (see client.ts for why there's no batch endpoint here). A symbol Yahoo can't resolve
// gets its own lastError, it never aborts the rest of the batch.
export async function pollStocksNow(): Promise<void> {
const tickers = stocksDb.listStockTickers();
if (tickers.length === 0) return;
+48
View File
@@ -0,0 +1,48 @@
import type { DatabaseSync } from 'node:sqlite';
// Host-side safety net for widget data pruning (see widgets/types.ts's uninstall contract
// and the plan's "data isolation strategy"). This alone is sufficient to fully clean a
// widget's data even with zero cooperation from its own code — a widget's own uninstall()
// hook (if present) is an optimization/extension point, not a requirement for correctness.
function ownedTablesForId(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []): string[] {
const prefix = `widget_${widgetId}_`;
const rows = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '\\'`)
.all(`${prefix.replace(/_/g, '\\_')}%`) as { name: string }[];
return Array.from(new Set([...rows.map((r) => r.name), ...extraOwnedTables]));
}
// Drops every table owned by a single widget (by the widget_<id>_ naming convention, plus
// any self-reported extraOwnedTables for a grandfathered name), and clears its widget_kv
// rows. Safe to call even if the widget's own code is missing/broken/never uninstall()ed.
export function sweepWidgetData(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []) {
for (const table of ownedTablesForId(db, widgetId, extraOwnedTables)) {
db.exec(`DROP TABLE IF EXISTS "${table}";`);
}
db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId);
}
// Full-registry sweep, run once at every startup after the registry loads (see
// widgets/registry.ts) — catches anything left behind by a process crash mid-uninstall, or
// a manually-edited installed_widgets table, that the per-widget sweep above never got a
// chance to run for.
export function sweepOrphanedWidgetData(db: DatabaseSync, knownWidgetIds: string[]) {
const known = new Set(knownWidgetIds);
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'widget\\_%' ESCAPE '\\'`).all() as {
name: string;
}[];
for (const { name } of tables) {
if (name === 'widget_kv') continue; // the shared kv table itself, not a per-widget owned table
const id = name.slice('widget_'.length).split('_')[0];
if (!known.has(id)) {
db.exec(`DROP TABLE IF EXISTS "${name}";`);
}
}
const kvWidgetIds = db.prepare('SELECT DISTINCT widget_id FROM widget_kv').all() as { widget_id: string }[];
const orphanedKvIds = kvWidgetIds.map((r) => r.widget_id).filter((id) => !known.has(id));
if (orphanedKvIds.length > 0) {
const placeholders = orphanedKvIds.map(() => '?').join(',');
db.prepare(`DELETE FROM widget_kv WHERE widget_id IN (${placeholders})`).run(...orphanedKvIds);
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { DatabaseSync } from 'node:sqlite';
import type { FastifyInstance } from 'fastify';
/**
* A widget's own lifecycle hooks — deliberately all-optional (unlike
* ingestion/adapters/base.ts's SourceAdapter, which has one mandatory fetch()) because
* unlike source adapters, widgets don't share a common downstream table or data shape
* to normalize into; each is a self-contained module that opts into only what it needs.
*/
export interface WidgetPlugin {
/** Stable id — also the table-name (`widget_<id>_*`) and directory-name namespace. Validated on install: /^[a-z0-9-]{1,40}$/ */
id: string;
displayName: string;
version: string;
/** Widget-owned schema. Must be idempotent (CREATE TABLE IF NOT EXISTS style), same contract as the host's own migrate(). */
migrate?(db: DatabaseSync): void;
/** Self-reported table names, for the uninstall safety-net sweep — belt-and-suspenders alongside the widget_<id>_ naming convention (see widgets/sweep.ts). */
ownedTables?: string[];
/** Host owns the setInterval/enable-gating (see queue/scheduler.ts); the plugin just does the fetch-and-persist work. */
poll?: { intervalMs: number; run(): Promise<void> };
/** Called once at load time (startup, or immediately after a live upload). Admin routes registered here are auto-gated by the existing X-Api-Key preHandler (see api/auth.ts), since it matches on any /api/admin/* path. */
registerPublicRoutes?(app: FastifyInstance): void;
registerAdminRoutes?(app: FastifyInstance): void;
/** Best-effort cleanup, called before the host's safety-net sweep on delete. Should not assume its own migrate() succeeded or that external APIs are reachable — the sweep is the real guarantee, this is just an extension point. */
uninstall?(db: DatabaseSync): void;
}
+48
View File
@@ -0,0 +1,48 @@
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../storage/db/index.js';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { logger } from '../storage/db/logs.js';
import { loadedWidgets } from './registry.js';
import { sweepWidgetData } from './sweep.js';
import { stopWidgetPolling } from '../queue/scheduler.js';
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
const WIDGETS_DATA_DIR = process.env.WIDGETS_DATA_DIR || './data/widgets-data';
// Fully removes an uploaded widget — safe to call even if its code is already
// broken/missing (the plugin.uninstall() step is best-effort; sweepWidgetData is the real
// guarantee, matching every table/kv row/on-disk file regardless of the widget's own
// cooperation). Callers (see api/admin.ts's DELETE /api/admin/widgets/:id) are
// responsible for rejecting built-in widgets before calling this.
//
// Returns whether the deleted widget had declared routes, i.e. whether the caller needs
// to swap the live server afterward (see server.ts's swapLiveServer()) — deliberately NOT
// done inline here: this runs inside the DELETE route's own request handler, and that
// handler is served by the very Fastify instance a swap would close, which drops the
// response before the client ever sees it (hit this for real in testing). The caller must
// send its response first, then swap — see api/admin.ts.
export async function uninstallWidget(id: string): Promise<boolean> {
stopWidgetPolling(id);
const plugin = loadedWidgets.get(id);
const hadRoutes = !!(plugin?.registerPublicRoutes || plugin?.registerAdminRoutes);
if (plugin?.uninstall) {
try {
plugin.uninstall(db);
} catch (err) {
logger.error('widgets', `uninstall() hook failed for "${id}": ${(err as Error).message}`);
}
}
const row = installedWidgetsDb.getInstalled(id);
sweepWidgetData(db, id, row?.ownedTables ?? []);
fs.rmSync(path.join(WIDGETS_INSTALLED_DIR, id), { recursive: true, force: true });
fs.rmSync(path.join(WIDGETS_DATA_DIR, id), { recursive: true, force: true });
loadedWidgets.delete(id);
installedWidgetsDb.deleteInstalled(id);
return hadRoutes;
}
+103
View File
@@ -0,0 +1,103 @@
import { getKv, setKv } from '../../storage/db/widgetKv.js';
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
export interface WeatherConfig {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
}
export interface WeatherCache {
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See client.ts's fetchActiveAlerts. */
alerts: WeatherAlert[];
updatedAt: string | null;
}
const WIDGET_ID = 'weather';
const DEFAULT_CONFIG: WeatherConfig = {
locationName: null,
latitude: null,
longitude: null,
unit: 'fahrenheit',
windUnit: 'mph',
pressureUnit: 'inHg'
};
const DEFAULT_CACHE: WeatherCache = {
current: null,
hourly: [],
daily: [],
alerts: [],
updatedAt: null
};
// Config (admin-settable: location/units) and cache (poll-computed forecast) are stored as
// two separate widget_kv keys — mirrors the singleton-row split stock_tickers/bookmarks
// tables already had from global_settings, just via the generic kv store instead of a
// bespoke table (this widget has no data shaped like rows, so no dedicated table is needed).
export function getConfig(): WeatherConfig {
return getKv<WeatherConfig>(WIDGET_ID, 'config') ?? DEFAULT_CONFIG;
}
export function setConfig(patch: Partial<WeatherConfig>): WeatherConfig {
const merged = { ...getConfig(), ...patch };
setKv(WIDGET_ID, 'config', merged);
return merged;
}
export function getCache(): WeatherCache {
return getKv<WeatherCache>(WIDGET_ID, 'cache') ?? DEFAULT_CACHE;
}
export function setCache(patch: Partial<WeatherCache>): WeatherCache {
const merged = { ...getCache(), ...patch };
setKv(WIDGET_ID, 'cache', merged);
return merged;
}
+47
View File
@@ -0,0 +1,47 @@
import type { WidgetPlugin } from '../types.js';
import { logger } from '../../storage/db/logs.js';
import { geocodeLocation } from './client.js';
import * as weatherDb from './db.js';
import { pollWeatherNow } from './poll.js';
// Sidebar "Weather" widget — config (location/units) and cache (forecast) live entirely in
// widget_kv (see db.ts); no bespoke table needed. Built-in and non-deletable (source:
// 'builtin' in installed_widgets), but otherwise a full WidgetPlugin like an uploaded one.
export const weatherPlugin: WidgetPlugin = {
id: 'weather',
displayName: 'Weather',
version: '1.0.0',
poll: {
intervalMs: 45 * 60_000,
run: pollWeatherNow
},
registerPublicRoutes(app) {
app.get('/api/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
},
registerAdminRoutes(app) {
// Returns config + cache together (same shape as the public route) so the admin
// tab can show "currently showing X" status alongside the location/unit form.
app.get('/api/admin/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
app.patch('/api/admin/widget/weather', async (req) => {
weatherDb.setConfig(req.body as any);
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
return { ...weatherDb.getConfig(), ...weatherDb.getCache() };
});
app.get('/api/admin/widget/weather/geocode', async (req, reply) => {
const { query } = req.query as { query?: string };
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
try {
return await geocodeLocation(query.trim());
} catch (err) {
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
}
});
}
};
@@ -1,26 +1,29 @@
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { logger } from '../../storage/db/logs.js';
import { fetchForecast, fetchActiveAlerts } from './client.js';
import * as weatherDb from './db.js';
import type { WeatherCache } from './db.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
// the weather location/units (see api/admin.ts) — writes straight into global_settings'
// weather_* columns via settingsDb, same singleton-row approach as retention.
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
// admin changes the weather location/units (see plugin.ts's admin routes) — writes into
// the widget's own widget_kv cache entry via weatherDb, same singleton-cache approach as
// before, just no longer riding on global_settings.
export async function pollWeatherNow(): Promise<void> {
const { weather } = settingsDb.getSettings();
if (weather.latitude === null || weather.longitude === null) {
const config = weatherDb.getConfig();
if (config.latitude === null || config.longitude === null) {
// No location configured yet — not an error, just nothing to do.
return;
}
let forecastUpdate: Partial<typeof weather> = {};
const cache = weatherDb.getCache();
let forecastUpdate: Partial<WeatherCache> = {};
let forecastSucceeded = false;
try {
const { current, hourly, daily } = await fetchForecast(
weather.latitude,
weather.longitude,
weather.unit,
weather.windUnit,
weather.pressureUnit
config.latitude,
config.longitude,
config.unit,
config.windUnit,
config.pressureUnit
);
forecastUpdate = { current, hourly, daily };
forecastSucceeded = true;
@@ -33,20 +36,17 @@ export async function pollWeatherNow(): Promise<void> {
// reliably (and expectedly) for every non-US location. A failure here shouldn't
// touch the forecast update above, and unlike a stale forecast, a stale alert that's
// since expired is worse to keep showing than none at all — clear to empty on failure.
let alerts = weather.alerts;
let alerts = cache.alerts;
try {
alerts = await fetchActiveAlerts(weather.latitude, weather.longitude);
alerts = await fetchActiveAlerts(config.latitude, config.longitude);
} catch (err) {
alerts = [];
logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`);
}
settingsDb.updateSettings({
weather: {
...weather,
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt
}
weatherDb.setCache({
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : cache.updatedAt
});
}
+10 -10
View File
@@ -915,9 +915,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.69.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.2.tgz",
"integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==",
"version": "2.70.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
"integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1578,9 +1578,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{
@@ -1638,9 +1638,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -1658,7 +1658,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+50 -16
View File
@@ -12,8 +12,12 @@ import type {
GeocodeResult,
AdminStockTicker,
AdminBookmark,
AdminBookmarksConfig,
Poe2BrowseEntry,
AdminPoe2Entry,
AdminWeatherSettings,
InstalledWidget,
WidgetUploadManifest,
PipelineStats,
ModelContextInfo,
ForceRecapResult
@@ -195,47 +199,63 @@ export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: nu
export const getPipelineStats = (fetchFn?: typeof fetch) => request<PipelineStats>('/api/admin/pipeline-stats', {}, fetchFn);
// Weather — config/cache lives on AdminSettings.weather (see updateSettings above); this
// is just the geocoding lookup used to resolve a typed city name to lat/lon.
// Weather — config/cache now live behind the widget's own dedicated admin route (see
// backend/src/widgets/weather/plugin.ts) rather than riding along on AdminSettings.
export const getWeatherConfig = (fetchFn?: typeof fetch) =>
request<AdminWeatherSettings>('/api/admin/widget/weather', {}, fetchFn);
export const updateWeatherConfig = (patch: Partial<AdminWeatherSettings>, fetchFn?: typeof fetch) =>
request<AdminWeatherSettings>('/api/admin/widget/weather', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const geocodeLocation = (query: string, fetchFn?: typeof fetch) =>
request<GeocodeResult[]>(`/api/admin/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn);
request<GeocodeResult[]>(`/api/admin/widget/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn);
// Stocks
export const getStockTickers = (fetchFn?: typeof fetch) =>
request<AdminStockTicker[]>('/api/admin/stocks', {}, fetchFn);
request<AdminStockTicker[]>('/api/admin/widget/stocks', {}, fetchFn);
export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) =>
request<AdminStockTicker>('/api/admin/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn);
request<AdminStockTicker>('/api/admin/widget/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn);
export const updateStockTicker = (id: string, patch: { label?: string; symbol?: string }, fetchFn?: typeof fetch) =>
request<AdminStockTicker>(`/api/admin/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
request<AdminStockTicker>(`/api/admin/widget/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/stocks/${id}`, { method: 'DELETE' }, fetchFn);
request<void>(`/api/admin/widget/stocks/${id}`, { method: 'DELETE' }, fetchFn);
// Bookmarks
export const getAdminBookmarks = (fetchFn?: typeof fetch) =>
request<AdminBookmark[]>('/api/admin/bookmarks', {}, fetchFn);
request<AdminBookmark[]>('/api/admin/widget/bookmarks', {}, fetchFn);
export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) =>
request<AdminBookmark>(
'/api/admin/bookmarks',
'/api/admin/widget/bookmarks',
{ method: 'POST', body: JSON.stringify({ name, url, isPrivate }) },
fetchFn
);
export const updateBookmark = (id: string, patch: { name?: string; url?: string; isPrivate?: boolean }, fetchFn?: typeof fetch) =>
request<AdminBookmark>(`/api/admin/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
request<AdminBookmark>(`/api/admin/widget/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
request<void>(`/api/admin/widget/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
// PoE2 — league is always auto-detected, never admin-set (see poe2/poller.ts).
export const getBookmarksConfig = (fetchFn?: typeof fetch) =>
request<AdminBookmarksConfig>('/api/admin/widget/bookmarks/config', {}, fetchFn);
export const updateBookmarksConfig = (columns: 1 | 2 | 3, fetchFn?: typeof fetch) =>
request<AdminBookmarksConfig>(
'/api/admin/widget/bookmarks/config',
{ method: 'PATCH', body: JSON.stringify({ columns }) },
fetchFn
);
// PoE2 — league is always auto-detected, never admin-set (see widgets/poe2/poll.ts).
export const browsePoe2Currencies = (fetchFn?: typeof fetch) =>
request<Poe2BrowseEntry[]>('/api/admin/poe2/browse', {}, fetchFn);
request<Poe2BrowseEntry[]>('/api/admin/widget/poe2/browse', {}, fetchFn);
export const getPoe2Watchlist = (fetchFn?: typeof fetch) =>
request<AdminPoe2Entry[]>('/api/admin/poe2/watchlist', {}, fetchFn);
request<AdminPoe2Entry[]>('/api/admin/widget/poe2/watchlist', {}, fetchFn);
export const addPoe2WatchlistEntry = (
base: { currencyId: string; name: string },
@@ -243,10 +263,24 @@ export const addPoe2WatchlistEntry = (
fetchFn?: typeof fetch
) =>
request<AdminPoe2Entry>(
'/api/admin/poe2/watchlist',
'/api/admin/widget/poe2/watchlist',
{ method: 'POST', body: JSON.stringify({ base, quote }) },
fetchFn
);
export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn);
request<void>(`/api/admin/widget/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn);
// Pluggable widgets (upload/list/enable/delete) — see backend/src/widgets/install.ts,
// uninstall.ts. Built-in widgets (source: 'builtin') 400 on deleteWidget.
export const listWidgets = (fetchFn?: typeof fetch) =>
request<InstalledWidget[]>('/api/admin/widgets', {}, fetchFn);
export const installWidget = (manifest: WidgetUploadManifest, files: Record<string, string>, fetchFn?: typeof fetch) =>
request<{ id: string }>('/api/admin/widgets', { method: 'POST', body: JSON.stringify({ manifest, files }) }, fetchFn);
export const setWidgetEnabled = (id: string, enabled: boolean, fetchFn?: typeof fetch) =>
request<InstalledWidget>(`/api/admin/widgets/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled }) }, fetchFn);
export const deleteWidget = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/widgets/${id}`, { method: 'DELETE' }, fetchFn);
+26 -8
View File
@@ -95,6 +95,11 @@ export interface AdminBookmark {
isPrivate: boolean;
}
/** Response from GET/PATCH /api/admin/widget/bookmarks/config — how many columns the sidebar/widget layout uses. */
export interface AdminBookmarksConfig {
columns: 1 | 2 | 3;
}
export interface Poe2BrowseEntry {
id: string;
name: string;
@@ -113,12 +118,6 @@ export interface AdminPoe2Entry {
lastError: string | null;
}
export interface AdminPoe2Settings {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
}
export interface AdminWidgetsEnabled {
weather: boolean;
stocks: boolean;
@@ -126,6 +125,27 @@ export interface AdminWidgetsEnabled {
poe2: boolean;
}
/** A row from the installed_widgets registry — see backend/src/storage/db/installedWidgets.ts. */
export interface InstalledWidget {
id: string;
displayName: string;
source: 'builtin' | 'uploaded';
enabled: boolean;
priorityRank: number;
version: string;
frontendEntry: string | null;
installedAt: string;
}
/** Body for POST /api/admin/widgets — see backend/src/widgets/manifest.ts. */
export interface WidgetUploadManifest {
id: string;
displayName: string;
version: string;
entry: string;
frontendEntry?: string;
}
export interface AdminSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
holdBeforePublishMinutes: number;
@@ -150,8 +170,6 @@ export interface AdminSettings {
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
poe2: AdminPoe2Settings;
}
export interface AdminSource {
+6 -6
View File
@@ -1,5 +1,5 @@
import { getBackendUrl } from './config';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from './types';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, BookmarksFeed, Poe2Data, WidgetsEnabled } from './types';
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
@@ -47,19 +47,19 @@ export function getCategories(fetchFn?: typeof fetch): Promise<Category[]> {
}
export function getWeather(fetchFn?: typeof fetch): Promise<Weather> {
return get<Weather>('/api/weather', fetchFn);
return get<Weather>('/api/widget/weather', fetchFn);
}
export function getStocks(fetchFn?: typeof fetch): Promise<StockTicker[]> {
return get<StockTicker[]>('/api/stocks', fetchFn);
return get<StockTicker[]>('/api/widget/stocks', fetchFn);
}
export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
return get<Bookmark[]>('/api/bookmarks', fetchFn);
export function getBookmarks(fetchFn?: typeof fetch): Promise<BookmarksFeed> {
return get<BookmarksFeed>('/api/widget/bookmarks', fetchFn);
}
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
return get<Poe2Data>('/api/poe2', fetchFn);
return get<Poe2Data>('/api/widget/poe2', fetchFn);
}
export function getWidgetsEnabled(fetchFn?: typeof fetch): Promise<WidgetsEnabled> {
@@ -1,12 +1,20 @@
<script lang="ts">
import type { AdminBookmark } from '$lib/adminTypes';
import { addBookmark, updateBookmark, deleteBookmark } from '$lib/adminApi';
import type { AdminBookmark, AdminBookmarksConfig } from '$lib/adminTypes';
import { addBookmark, updateBookmark, deleteBookmark, updateBookmarksConfig } from '$lib/adminApi';
let { bookmarks: initial }: { bookmarks: AdminBookmark[] } = $props();
let { bookmarks: initial, config }: { bookmarks: AdminBookmark[]; config: AdminBookmarksConfig } = $props();
let bookmarks = $state([...initial]);
let columns = $state(config.columns);
let showAdd = $state(false);
let newBookmark = $state({ name: '', url: '', isPrivate: false });
async function setColumns(n: 1 | 2 | 3) {
columns = n;
await updateBookmarksConfig(n);
}
const columnOptions: (1 | 2 | 3)[] = [1, 2, 3];
let editingId = $state<string | null>(null);
let editForm = $state({ name: '', url: '' });
@@ -47,6 +55,14 @@
<div class="toolbar">
<span class="count">{bookmarks.length} bookmarks</span>
<div class="columns-picker">
<span class="field-label">Columns</span>
<div class="pill-row">
{#each columnOptions as n}
<button class="pill" class:active={columns === n} onclick={() => setColumns(n)}>{n}</button>
{/each}
</div>
</div>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New bookmark</button>
</div>
@@ -102,12 +118,40 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.count {
font-size: 12px;
color: var(--text-muted);
}
.columns-picker {
display: flex;
align-items: center;
gap: 8px;
}
.field-label {
font-size: 11px;
color: var(--text-muted);
}
.pill-row {
display: flex;
gap: 6px;
}
.pill {
font-size: 12px;
padding: 4px 10px;
border-radius: var(--radius);
border: 0.5px solid var(--border);
background: var(--surface-2);
color: var(--text-secondary);
}
.pill.active {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.add-btn {
font-size: 12px;
padding: 6px 12px;
@@ -1,9 +1,10 @@
<script lang="ts">
import type { AdminSettings, AdminPoe2Entry, Poe2BrowseEntry } from '$lib/adminTypes';
import type { AdminPoe2Entry, Poe2BrowseEntry } from '$lib/adminTypes';
import type { Poe2Data } from '$lib/types';
import { browsePoe2Currencies, addPoe2WatchlistEntry, removePoe2WatchlistEntry } from '$lib/adminApi';
import { formatPoeValue, invertChangePercent } from '$lib/format';
let { settings, watchlist: initial }: { settings: AdminSettings; watchlist: AdminPoe2Entry[] } = $props();
let { poe2, watchlist: initial }: { poe2: Poe2Data; watchlist: AdminPoe2Entry[] } = $props();
let watchlist = $state([...initial]);
let showAdd = $state(false);
@@ -79,8 +80,8 @@
<button class="add-btn" onclick={openAdd}>+ Add pair</button>
</div>
<p class="hint" style="margin: -6px 0 12px;">
{#if settings.poe2.leagueName}
Tracking {settings.poe2.leagueName} · change is over the last 24h.
{#if poe2.leagueName}
Tracking {poe2.leagueName} · change is over the last 24h.
{:else}
League not detected yet — check back after the next poll (every hour).
{/if}
@@ -1,12 +1,12 @@
<script lang="ts">
import type { AdminSettings } from '$lib/adminTypes';
import { updateSettings, geocodeLocation } from '$lib/adminApi';
import type { AdminWeatherSettings } from '$lib/adminTypes';
import { updateWeatherConfig, geocodeLocation } from '$lib/adminApi';
import { timeAgo } from '$lib/format';
import SaveStatus from './SaveStatus.svelte';
let { settings }: { settings: AdminSettings } = $props();
let { config }: { config: AdminWeatherSettings } = $props();
let weather = $state({ ...settings.weather });
let weather = $state({ ...config });
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let saveTimer: ReturnType<typeof setTimeout>;
@@ -20,7 +20,14 @@
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await updateSettings({ weather });
weather = await updateWeatherConfig({
locationName: weather.locationName,
latitude: weather.latitude,
longitude: weather.longitude,
unit: weather.unit,
windUnit: weather.windUnit,
pressureUnit: weather.pressureUnit
});
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
} catch {
@@ -1,6 +1,16 @@
<script lang="ts">
import type { AdminSettings, AdminStockTicker, AdminBookmark, AdminPoe2Entry } from '$lib/adminTypes';
import { updateSettings } from '$lib/adminApi';
import type {
AdminSettings,
AdminStockTicker,
AdminBookmark,
AdminBookmarksConfig,
AdminPoe2Entry,
AdminWeatherSettings,
InstalledWidget,
WidgetUploadManifest
} from '$lib/adminTypes';
import type { Poe2Data } from '$lib/types';
import { updateSettings, listWidgets, installWidget, setWidgetEnabled, deleteWidget } from '$lib/adminApi';
import WidgetSection from './WidgetSection.svelte';
import WeatherTab from './WeatherTab.svelte';
import StocksTab from './StocksTab.svelte';
@@ -11,12 +21,20 @@
settings,
stockTickers,
bookmarks,
poe2Watchlist
bookmarksConfig,
poe2Watchlist,
weatherConfig,
poe2,
installedWidgets
}: {
settings: AdminSettings;
stockTickers: AdminStockTicker[];
bookmarks: AdminBookmark[];
bookmarksConfig: AdminBookmarksConfig;
poe2Watchlist: AdminPoe2Entry[];
weatherConfig: AdminWeatherSettings;
poe2: Poe2Data;
installedWidgets: InstalledWidget[];
} = $props();
// Local copies so each toggle/reorder reflects immediately — same idiom as
@@ -44,6 +62,68 @@
widgetOrder = arr;
await updateSettings({ widgetOrder });
}
// --- Pluggable (uploaded) widgets — see backend/src/widgets/install.ts, uninstall.ts.
// Built-ins never appear here (source: 'builtin'); the delete route rejects them anyway.
let pluggable = $state(installedWidgets.filter((w) => w.source === 'uploaded'));
let showUpload = $state(false);
let uploadId = $state('');
let uploadName = $state('');
let uploadVersion = $state('1.0.0');
let backendFile = $state<File | null>(null);
let frontendFile = $state<File | null>(null);
let uploading = $state(false);
let uploadError = $state<string | null>(null);
async function refreshPluggable() {
pluggable = (await listWidgets()).filter((w) => w.source === 'uploaded');
}
async function handleUpload() {
if (!uploadId.trim() || !uploadName.trim() || !backendFile) {
uploadError = 'id, display name, and a backend .mjs file are required';
return;
}
uploading = true;
uploadError = null;
try {
const files: Record<string, string> = { 'index.mjs': await backendFile.text() };
const manifest: WidgetUploadManifest = {
id: uploadId.trim(),
displayName: uploadName.trim(),
version: uploadVersion.trim() || '1.0.0',
entry: 'index.mjs'
};
if (frontendFile) {
files['frontend.mjs'] = await frontendFile.text();
manifest.frontendEntry = 'frontend.mjs';
}
await installWidget(manifest, files);
await refreshPluggable();
showUpload = false;
uploadId = '';
uploadName = '';
uploadVersion = '1.0.0';
backendFile = null;
frontendFile = null;
} catch (err) {
uploadError = (err as Error).message;
} finally {
uploading = false;
}
}
async function togglePluggable(w: InstalledWidget) {
const updated = await setWidgetEnabled(w.id, !w.enabled);
pluggable = pluggable.map((x) => (x.id === w.id ? updated : x));
}
async function handleDeletePluggable(id: string) {
if (!confirm('Delete this widget? This removes all of its data and cannot be undone.')) return;
await deleteWidget(id);
pluggable = pluggable.filter((w) => w.id !== id);
}
</script>
{#each widgetOrder as key, i (key)}
@@ -57,13 +137,139 @@
onMoveDown={() => move(i, 1)}
>
{#if key === 'weather'}
<WeatherTab {settings} />
<WeatherTab config={weatherConfig} />
{:else if key === 'stocks'}
<StocksTab tickers={stockTickers} />
{:else if key === 'bookmarks'}
<BookmarksTab {bookmarks} />
<BookmarksTab {bookmarks} config={bookmarksConfig} />
{:else if key === 'poe2'}
<Poe2Tab {settings} watchlist={poe2Watchlist} />
<Poe2Tab {poe2} watchlist={poe2Watchlist} />
{/if}
</WidgetSection>
{/each}
<div class="pluggable">
<div class="pluggable-head">
<span class="section-title">Pluggable widgets</span>
<button class="upload-toggle" onclick={() => (showUpload = !showUpload)}>{showUpload ? 'Cancel' : '+ Upload'}</button>
</div>
{#if showUpload}
<div class="upload-form">
<input type="text" placeholder="id (a-z0-9-)" bind:value={uploadId} />
<input type="text" placeholder="Display name" bind:value={uploadName} />
<input type="text" placeholder="Version" bind:value={uploadVersion} />
<label class="file-field">
<span>Backend entry (.mjs, required)</span>
<input type="file" accept=".mjs,.js" onchange={(e) => (backendFile = e.currentTarget.files?.[0] ?? null)} />
</label>
<label class="file-field">
<span>Frontend entry (.mjs, optional)</span>
<input type="file" accept=".mjs,.js" onchange={(e) => (frontendFile = e.currentTarget.files?.[0] ?? null)} />
</label>
{#if uploadError}<p class="hint" style="color: var(--text-danger);">{uploadError}</p>{/if}
<button onclick={handleUpload} disabled={uploading}>{uploading ? 'Uploading…' : 'Install'}</button>
</div>
{/if}
{#if pluggable.length === 0}
<p class="hint">No uploaded widgets installed.</p>
{:else}
<div class="list">
{#each pluggable as w (w.id)}
<div class="row">
<span class="row-name">{w.displayName}</span>
<span class="badge" class:active={w.enabled} onclick={() => togglePluggable(w)} role="button" tabindex="0">
{w.enabled ? 'Active' : 'Disabled'}
</span>
<button class="icon-btn danger" onclick={() => handleDeletePluggable(w.id)} title="Delete"></button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.pluggable {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
margin-top: 4px;
}
.pluggable-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-title {
font-size: 13px;
font-weight: 500;
}
.upload-toggle {
font-size: 12px;
padding: 4px 10px;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 12px;
}
.file-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
}
.hint {
font-size: 12px;
color: var(--text-muted);
margin: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 8px 12px;
}
.row-name {
font-size: 13px;
flex: 1;
min-width: 0;
}
.badge {
font-size: 11px;
padding: 2px 10px;
border-radius: var(--radius);
background: var(--surface-1);
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.badge.active {
background: var(--bg-accent);
color: var(--text-accent);
}
.icon-btn {
font-size: 12px;
padding: 3px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn.danger:hover {
color: var(--text-danger);
}
</style>
@@ -1,13 +1,13 @@
<script lang="ts">
import type { Bookmark } from '$lib/types';
let { bookmarks }: { bookmarks: Bookmark[] } = $props();
let { bookmarks, columns = 1 }: { bookmarks: Bookmark[]; columns?: 1 | 2 | 3 } = $props();
</script>
<div class="widget">
<span class="title">Bookmarks</span>
{#if bookmarks.length > 0}
<div class="list">
<div class="list" class:grid={columns > 1} style:grid-template-columns={columns > 1 ? `repeat(${columns}, 1fr)` : undefined}>
{#each bookmarks as bookmark (bookmark.id)}
<a class="row" href={bookmark.url} target="_blank" rel="noopener noreferrer">{bookmark.name}</a>
{/each}
@@ -33,6 +33,10 @@
flex-direction: column;
margin-top: 8px;
}
.list.grid {
display: grid;
gap: 6px;
}
.row {
font-size: 13px;
padding: 6px 0;
@@ -42,6 +46,15 @@
.row:first-child {
border-top: none;
}
.list.grid .row {
border-top: none;
padding: 6px 8px;
background: var(--surface-2);
border-radius: var(--radius);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row:hover {
color: var(--text-accent);
}
@@ -0,0 +1,67 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getBackendUrl } from '$lib/config';
// Renders an uploaded widget's custom UI — a pre-built, framework-agnostic JS bundle
// (not raw Svelte source, which can't be compiled in the browser without shipping a
// compiler) served from /widget-assets/<id>/* and loaded via an ordinary dynamic
// import(), which is why this works live for a widget uploaded after the page's own
// bundle was built — no Fastify route registration is involved at all, unlike the
// widget's own custom *API* routes, which do need a backend restart (see
// widgets/install.ts's notes on that).
let { id, displayName, frontendEntry }: { id: string; displayName: string; frontendEntry: string } = $props();
let container: HTMLDivElement | undefined = $state();
let error = $state<string | null>(null);
let cleanup: (() => void) | void;
onMount(async () => {
try {
const url = `${getBackendUrl()}/widget-assets/${id}/${frontendEntry}`;
// @vite-ignore — the URL is only known at runtime (a widget uploaded after this
// page's own bundle was built), so Vite can't statically analyze this import.
const mod = await import(/* @vite-ignore */ url);
if (typeof mod.default?.mount !== 'function') {
throw new Error('module has no default-exported mount(container, ctx)');
}
if (container) {
cleanup = mod.default.mount(container, { id, displayName, apiBase: getBackendUrl() });
}
} catch (err) {
error = (err as Error).message;
}
});
onDestroy(() => cleanup?.());
</script>
<div class="widget">
{#if error}
<div class="head"><span class="title">{displayName}</span></div>
<p class="empty">Failed to load: {error}</p>
{/if}
<div bind:this={container}></div>
</div>
<style>
.widget {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.empty {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getBackendUrl } from '$lib/config';
import type { WidgetReport } from '$lib/types';
// Renders any uploaded widget that has no custom frontend bundle — fetches whatever its
// own poll.run() published via setKv(id, 'report', ...) (see GET /api/widget/:id/report,
// registered once generically on the backend so this works for a widget uploaded after
// the server started, with no restart). Self-contained refresh interval since this
// component isn't wired into +layout.ts's load-based invalidation — matches the layout's
// own 5-minute sidebar refresh cadence.
let { id, displayName }: { id: string; displayName: string } = $props();
let report = $state<WidgetReport | null>(null);
let loaded = $state(false);
let timer: ReturnType<typeof setInterval>;
async function fetchReport() {
try {
const res = await fetch(`${getBackendUrl()}/api/widget/${id}/report`);
if (res.ok) {
const body = (await res.json()) as { data: WidgetReport | null };
report = body.data;
}
} catch {
// Leave the last-known report showing — a stale card beats a blank one.
} finally {
loaded = true;
}
}
onMount(() => {
fetchReport();
timer = setInterval(fetchReport, 5 * 60_000);
});
onDestroy(() => clearInterval(timer));
</script>
<div class="widget">
<div class="head">
<span class="title">{report?.title ?? displayName}</span>
</div>
{#if report?.headline}
<div class="headline">
<span class="value">{report.headline.value}</span>
{#if report.headline.delta}<span class="delta">{report.headline.delta}</span>{/if}
</div>
{/if}
{#if report?.rows && report.rows.length > 0}
<div class="list">
{#each report.rows as row, i (i)}
<div class="row">
<span class="label">{row.label}</span>
<span class="value">{row.value}</span>
</div>
{/each}
</div>
{:else if !report?.headline}
<p class="empty">{loaded ? 'No data yet' : 'Loading…'}</p>
{/if}
</div>
<style>
.widget {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.headline {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 8px;
}
.headline .value {
font-size: 18px;
font-weight: 500;
}
.delta {
font-size: 12px;
color: var(--text-secondary);
}
.list {
display: flex;
flex-direction: column;
margin-top: 8px;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 6px 0;
border-top: 0.5px solid var(--border);
}
.row:first-child {
border-top: none;
}
.label {
font-size: 13px;
}
.value {
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text-secondary);
}
.empty {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
</style>
@@ -1,10 +1,12 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from '$lib/types';
import type { Weather, StockTicker, BookmarksFeed, Poe2Data, WidgetsEnabled } from '$lib/types';
import WeatherWidget from './WeatherWidget.svelte';
import StocksWidget from './StocksWidget.svelte';
import BookmarksWidget from './BookmarksWidget.svelte';
import Poe2Widget from './Poe2Widget.svelte';
import GenericWidgetCard from './GenericWidgetCard.svelte';
import DynamicWidgetSlot from './DynamicWidgetSlot.svelte';
let {
weather,
@@ -15,7 +17,7 @@
}: {
weather: Weather;
stocks: StockTicker[];
bookmarks: Bookmark[];
bookmarks: BookmarksFeed;
poe2: Poe2Data;
widgetsEnabled: WidgetsEnabled;
} = $props();
@@ -98,7 +100,14 @@
{:else if key === 'poe2' && widgetsEnabled.poe2}
<Poe2Widget {poe2} />
{:else if key === 'bookmarks' && widgetsEnabled.bookmarks}
<BookmarksWidget {bookmarks} />
<BookmarksWidget bookmarks={bookmarks.items} columns={bookmarks.columns} />
{/if}
{/each}
{#each widgetsEnabled.pluggable as w (w.id)}
{#if w.frontendEntry}
<DynamicWidgetSlot id={w.id} displayName={w.displayName} frontendEntry={w.frontendEntry} />
{:else}
<GenericWidgetCard id={w.id} displayName={w.displayName} />
{/if}
{/each}
</div>
+24
View File
@@ -159,6 +159,12 @@ export interface Bookmark {
isPrivate: boolean;
}
/** Response from GET /api/widget/bookmarks — items plus the admin-configured sidebar layout width. */
export interface BookmarksFeed {
items: Bookmark[];
columns: 1 | 2 | 3;
}
export interface Poe2WatchlistEntry {
id: string;
baseName: string;
@@ -175,6 +181,22 @@ export interface Poe2Data {
entries: Poe2WatchlistEntry[];
}
/** An uploaded (non-core) widget the sidebar renders generically — see GenericWidgetCard.svelte / DynamicWidgetSlot.svelte. */
export interface PluggableWidgetSummary {
id: string;
displayName: string;
/** Relative path under /widget-assets/<id>/ to a custom mount() bundle — null means render the generic report card instead. */
frontendEntry: string | null;
}
/** Generic live-data shape a widget publishes via its own poll — see GET /api/widget/:id/report. */
export interface WidgetReport {
title: string;
headline?: { value: string; delta?: string } | null;
rows?: { label: string; value: string }[];
updatedAt: string | null;
}
/** Per-widget sidebar visibility + display order, admin-set from the consolidated "Widgets" tab. */
export interface WidgetsEnabled {
weather: boolean;
@@ -182,4 +204,6 @@ export interface WidgetsEnabled {
bookmarks: boolean;
poe2: boolean;
order: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
/** Enabled uploaded widgets, in their own priority order — rendered after the 4 built-ins (see Sidebar.svelte). */
pluggable: PluggableWidgetSummary[];
}
@@ -44,7 +44,16 @@
{:else if active === 'events'}
<EventsTab events={data.events} sources={data.sources} />
{:else if active === 'widgets'}
<WidgetsTab settings={data.settings} stockTickers={data.stockTickers} bookmarks={data.bookmarks} poe2Watchlist={data.poe2Watchlist} />
<WidgetsTab
settings={data.settings}
stockTickers={data.stockTickers}
bookmarks={data.bookmarks}
bookmarksConfig={data.bookmarksConfig}
poe2Watchlist={data.poe2Watchlist}
weatherConfig={data.weatherConfig}
poe2={data.poe2}
installedWidgets={data.installedWidgets}
/>
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
{:else if active === 'logs'}
+39 -4
View File
@@ -10,22 +10,42 @@ import {
getLogs,
getStockTickers,
getAdminBookmarks,
getPoe2Watchlist
getBookmarksConfig,
getPoe2Watchlist,
getWeatherConfig,
listWidgets
} from '$lib/adminApi';
import { getPoe2 } from '$lib/api';
import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes';
const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] };
export const load: PageLoad = async ({ fetch }) => {
try {
const [settings, sources, events, logs, stockTickers, bookmarks, poe2Watchlist] = await Promise.all([
const [
settings,
sources,
events,
logs,
stockTickers,
bookmarks,
bookmarksConfig,
poe2Watchlist,
weatherConfig,
poe2,
installedWidgets
] = await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
getLogs({}, fetch),
getStockTickers(fetch),
getAdminBookmarks(fetch),
getPoe2Watchlist(fetch)
getBookmarksConfig(fetch),
getPoe2Watchlist(fetch),
getWeatherConfig(fetch),
getPoe2(fetch),
listWidgets(fetch)
]);
// The AI service (Ollama) may not be running yet — that shouldn't take down the
@@ -42,7 +62,22 @@ export const load: PageLoad = async ({ fetch }) => {
() => ({ credentialsConfigured: false, connected: false, phone: null })
);
return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks, poe2Watchlist };
return {
settings,
sources,
events,
models,
aiStatus,
telegramStatus,
logs,
stockTickers,
bookmarks,
bookmarksConfig,
poe2Watchlist,
weatherConfig,
poe2,
installedWidgets
};
} catch (err) {
if ((err as { status?: number }).status === 401) {
throw redirect(302, '/admin/login?redirectTo=/admin/settings');