Merge pull request #23 from Salastil/claude/source-management-content-1w2yno
Widget rework
This commit is contained in:
Generated
+15
-15
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;');
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
Generated
+10
-10
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user