commit 503b397dff48977058abf3d5b84ddd4c0e63f5f8 Author: jtricerolph Date: Tue Jul 28 21:16:02 2026 +0000 Scaffold plant app - MQTT monitoring for boiler-room equipment Read-only monitoring/alerting for boilers, water softener, calorifiers and pumps via a generic MQTT-topic-prefix asset model, so new equipment can be onboarded without new ingestion code. Threshold and stale-data alert rules with email + in-app notification. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c878b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +uploads/ +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b4cc893 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json . +RUN npm install --omit=dev +COPY src ./src +RUN mkdir -p /app/uploads +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..11969cb --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "hnf-plant-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "@fastify/multipart": "^8.3.0", + "@fastify/static": "^7.0.4", + "fastify": "^4.28.1", + "jose": "^5.9.6", + "mqtt": "^5.10.3", + "nodemailer": "^6.9.16", + "pg": "^8.13.1", + "sharp": "^0.33.0" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..31a87dd --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,57 @@ +import { jwtVerify } from 'jose' +import { isOnsite } from './ip-check.js' + +const APP_SLUG = process.env.APP_SLUG || 'plant' +const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') + +export async function requireAuth(request, reply) { + const token = request.cookies?.hnf_session + if (!token) return reply.status(401).send({ error: 'Not authenticated' }) + + let payload + try { + const { payload: p } = await jwtVerify(token, secret) + payload = p + } catch { + return reply.status(401).send({ error: 'Invalid session' }) + } + + if (!payload.apps?.includes(APP_SLUG)) { + return reply.status(403).send({ error: 'No permission for this app' }) + } + + if (!payload.offsite_allowed) { + const clientIP = request.headers['x-real-ip'] || request.ip + if (!(await isOnsite(clientIP))) { + return reply.status(403).send({ error: 'Access restricted to site network' }) + } + } + + const prefix = `${APP_SLUG}:` + let caps + if (Array.isArray(payload.caps)) { + caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length)) + } else { + // Legacy token — grant all non-settings caps until re-login + caps = ['view', 'guest_details', 'rate_details', 'view_all_notes', 'complete_tasks', 'update_status'] + } + + request.user = { + email: payload.sub, + name: payload.name, + is_admin: payload.is_admin ?? false, + caps, + } +} + +export function hasCap(request, cap) { + return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true +} + +export function requireCap(cap) { + return async (request, reply) => { + if (!hasCap(request, cap)) { + return reply.status(403).send({ error: `Missing capability: ${cap}` }) + } + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..4378b90 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,145 @@ +import pg from 'pg' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function initDb() { + await pool.query(` + -- Plant-room equipment register — boilers, water softeners, calorifiers, + -- pump/pressurisation sets. Read-only monitoring only: no target/setpoint + -- columns here and no driver abstraction, unlike hvac's zone_devices. + CREATE TABLE IF NOT EXISTS plant_assets ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + asset_type TEXT NOT NULL, -- boiler | water_softener | calorifier | pump + location TEXT, + make_model TEXT, + serial_no TEXT, + install_date DATE, + notes TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + mqtt_topic_prefix TEXT, -- e.g. 'plant/water-softener' — nullable until wired to a device + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS plant_assets_type_idx ON plant_assets (asset_type); + CREATE UNIQUE INDEX IF NOT EXISTS plant_assets_topic_prefix_idx ON plant_assets (mqtt_topic_prefix) WHERE mqtt_topic_prefix IS NOT NULL; + + -- Raw telemetry history, one row per MQTT message. field_key is whatever + -- topic segment(s) follow the asset's mqtt_topic_prefix (see lib/mqtt.js) — + -- generic across any asset type, never a fixed column set. Both a numeric + -- and text value are kept: numeric fields (salt_level_pct) populate + -- value_numeric, boolean/enum fields (regeneration_active, salt_level_status) + -- populate value_text (and value_numeric too when the text is exactly + -- "true"/"false", so booleans can still be charted). + CREATE TABLE IF NOT EXISTS plant_telemetry_raw ( + id SERIAL PRIMARY KEY, + asset_id INT NOT NULL REFERENCES plant_assets(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + value_numeric NUMERIC, + value_text TEXT, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS plant_telemetry_raw_asset_field_idx ON plant_telemetry_raw (asset_id, field_key, recorded_at DESC); + + -- Latest known value per asset+field — what the Dashboard and stale-minutes + -- alert sweep read from, upserted on every telemetry insert. + CREATE TABLE IF NOT EXISTS plant_asset_latest ( + asset_id INT NOT NULL REFERENCES plant_assets(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + value_numeric NUMERIC, + value_text TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (asset_id, field_key) + ); + + -- Alert rules — either scoped to one asset (asset_id set) or to every asset + -- of a type (asset_type set, asset_id NULL). 'stale_minutes' rules are swept + -- periodically by lib/scheduler.js; lt/gt/eq rules are evaluated inline as + -- telemetry arrives (lib/mqtt.js -> lib/alert-engine.js). + CREATE TABLE IF NOT EXISTS plant_alert_rules ( + id SERIAL PRIMARY KEY, + asset_id INT REFERENCES plant_assets(id) ON DELETE CASCADE, + asset_type TEXT, + field_key TEXT NOT NULL, + condition TEXT NOT NULL, -- lt | gt | eq | stale_minutes + threshold NUMERIC NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', -- warning | critical + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (asset_id IS NOT NULL OR asset_type IS NOT NULL) + ); + CREATE INDEX IF NOT EXISTS plant_alert_rules_lookup_idx ON plant_alert_rules (field_key, condition) WHERE active = TRUE; + + -- Triggered alerts. Auto-resolved by the same check that raised them once + -- the underlying condition clears (see lib/alert-engine.js) — "acknowledge" + -- is a separate, purely human action (someone has seen it) from "resolved" + -- (the condition itself is no longer breaching). + CREATE TABLE IF NOT EXISTS plant_alerts ( + id SERIAL PRIMARY KEY, + rule_id INT NOT NULL REFERENCES plant_alert_rules(id) ON DELETE CASCADE, + asset_id INT NOT NULL REFERENCES plant_assets(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + value_at_trigger TEXT, + status TEXT NOT NULL DEFAULT 'open', -- open | acknowledged | resolved + triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + acknowledged_at TIMESTAMPTZ, + acknowledged_by TEXT, + resolved_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS plant_alerts_status_idx ON plant_alerts (status, triggered_at DESC); + CREATE INDEX IF NOT EXISTS plant_alerts_open_lookup_idx ON plant_alerts (rule_id, asset_id, field_key) WHERE status = 'open'; + + -- Asset photos — same shape as hvac's device_photos (multipart + sharp, + -- shared uploads_data volume). photo_type 'asset' (general shot) or + -- 'serial_plate' (model/serial plate close-up). + CREATE TABLE IF NOT EXISTS asset_photos ( + id SERIAL PRIMARY KEY, + asset_id INT NOT NULL REFERENCES plant_assets(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL, + mime_type TEXT, + file_size INT, + photo_type TEXT NOT NULL DEFAULT 'asset', -- asset | serial_plate + uploaded_by TEXT, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS asset_photos_asset_idx ON asset_photos (asset_id); + + -- Generic app settings (cross-app-integration pattern, see conventions doc) — + -- plant has no other app to integrate with yet, just an alert-notification + -- recipient, but the table/route/page shape is kept standard so a future + -- integration is a one-row addition, not a new pattern. + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `) + + await seedDefaultSettings() +} + +async function seedDefaultSettings() { + const defaults = { + alert_notify_email: '', + } + for (const [key, value] of Object.entries(defaults)) { + await pool.query( + `INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + [key, value] + ) + } +} + +export async function getSetting(key) { + const { rows } = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]) + return rows.length ? rows[0].value : null +} + +export async function setSetting(key, value) { + await pool.query( + `INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, value] + ) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..ab9ead0 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,55 @@ +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import multipart from '@fastify/multipart' +import staticFiles from '@fastify/static' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import { initDb } from './db.js' +import { connect as connectMqtt } from './lib/mqtt.js' +import { startScheduler } from './lib/scheduler.js' +import { assetRoutes } from './routes/assets.js' +import { statusRoutes } from './routes/status.js' +import { alertRoutes } from './routes/alerts.js' +import { alertRuleRoutes } from './routes/alert-rules.js' +import { settingsRoutes } from './routes/settings.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const UPLOADS_DIR = join(__dirname, '..', 'uploads') + +const app = Fastify({ logger: true, trustProxy: true }) +const startedAt = Date.now() + +await app.register(cookie) +await app.register(cors, { + origin: process.env.CORS_ORIGIN || false, + credentials: true, +}) +await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } }) +await app.register(staticFiles, { + root: UPLOADS_DIR, + prefix: '/api/uploads/', + decorateReply: false, +}) + +app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) })) + +await app.register(assetRoutes, { uploadsDir: UPLOADS_DIR }) +await app.register(statusRoutes) +await app.register(alertRoutes) +await app.register(alertRuleRoutes) +await app.register(settingsRoutes) + +try { + await initDb() + + // MQTT broker (shared infra, LXC 104) may not exist or be reachable yet — + // connect() handles that gracefully with its own backoff and never blocks startup. + connectMqtt().catch(err => app.log.warn(`MQTT connect failed at startup: ${err.message}`)) + + startScheduler() + await app.listen({ port: 3001, host: '0.0.0.0' }) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 0000000..4d8cb19 --- /dev/null +++ b/backend/src/ip-check.js @@ -0,0 +1,80 @@ +import dns from 'dns/promises' + +const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim() +const matchers = raw.split(',').map(s => s.trim()).filter(Boolean) + +const TTL = 5 * 60 * 1000 +const cache = new Map() + +const PUBLIC_IP_URLS = [ + 'https://api.ipify.org', + 'https://ifconfig.co/ip', + 'https://icanhazip.com', +] + +function normalizeIP(ip) { + return ip?.startsWith('::ffff:') ? ip.slice(7) : ip +} + +function isIPv4(s) { + return /^\d{1,3}(\.\d{1,3}){3}$/.test(s) +} + +function ipInCidr(ip, cidr) { + const [range, bits] = cidr.split('/') + if (!isIPv4(ip) || !isIPv4(range)) return false + const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 + const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0 + return (toInt(ip) & mask) === (toInt(range) & mask) +} + +async function fetchPublicIP() { + for (const url of PUBLIC_IP_URLS) { + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 4000) + const res = await fetch(url, { signal: ctrl.signal }) + clearTimeout(timer) + if (!res.ok) continue + const ip = (await res.text()).trim() + if (isIPv4(ip)) return ip + } catch { + // try next + } + } + return null +} + +async function resolveDynamic(key, resolver) { + const hit = cache.get(key) + if (hit && Date.now() < hit.expiry) return hit.ip + const ip = await resolver() + if (ip) { + cache.set(key, { ip, expiry: Date.now() + TTL }) + return ip + } + return hit ? hit.ip : null +} + +export async function isOnsite(requestIP) { + if (matchers.length === 0 || matchers.includes('disabled')) return true + const ip = normalizeIP(requestIP) + if (!ip) return false + + for (const m of matchers) { + if (m === 'auto') { + const pub = await resolveDynamic('auto', fetchPublicIP) + if (pub && ip === pub) return true + } else if (m.includes('/')) { + if (ipInCidr(ip, m)) return true + } else if (/[a-zA-Z]/.test(m)) { + const resolved = await resolveDynamic(m, async () => { + try { return (await dns.resolve4(m))[0] } catch { return null } + }) + if (resolved && ip === resolved) return true + } else { + if (ip === m) return true + } + } + return false +} diff --git a/backend/src/lib/alert-engine.js b/backend/src/lib/alert-engine.js new file mode 100644 index 0000000..8913843 --- /dev/null +++ b/backend/src/lib/alert-engine.js @@ -0,0 +1,108 @@ +// Shared alert evaluation for both ingestion paths: lib/mqtt.js calls +// checkThresholdRules() inline on every message (lt/gt/eq), and lib/scheduler.js +// calls checkStaleRules() on its periodic sweep (stale_minutes only). Both +// funnel through the same trigger/auto-resolve helpers so an alert is never +// raised or cleared differently depending on which path noticed it. +import { pool } from '../db.js' +import { notifyPlantAlert } from './mailer.js' + +function breaches(condition, threshold, numericValue) { + if (numericValue === null || numericValue === undefined) return false + const v = Number(numericValue) + if (!Number.isFinite(v)) return false + const t = Number(threshold) + if (condition === 'lt') return v < t + if (condition === 'gt') return v > t + if (condition === 'eq') return v === t + return false +} + +async function findRules(fieldKey, conditions, assetId, assetType) { + const { rows } = await pool.query( + `SELECT * FROM plant_alert_rules + WHERE active = TRUE AND field_key = $1 AND condition = ANY($2) + AND (asset_id = $3 OR (asset_id IS NULL AND asset_type = $4))`, + [fieldKey, conditions, assetId, assetType] + ) + return rows +} + +async function triggerAlert(rule, assetId, fieldKey, valueAtTrigger) { + const { rows: openRows } = await pool.query( + `SELECT id FROM plant_alerts WHERE rule_id = $1 AND asset_id = $2 AND field_key = $3 AND status = 'open'`, + [rule.id, assetId, fieldKey] + ) + if (openRows.length) return // already open — don't re-notify on every message/sweep tick + + const { rows: inserted } = await pool.query( + `INSERT INTO plant_alerts (rule_id, asset_id, field_key, value_at_trigger) + VALUES ($1, $2, $3, $4) RETURNING *`, + [rule.id, assetId, fieldKey, String(valueAtTrigger)] + ) + const { rows: assetRows } = await pool.query('SELECT * FROM plant_assets WHERE id = $1', [assetId]) + if (assetRows.length) { + notifyPlantAlert(inserted[0], assetRows[0], rule).catch(err => + console.error('[alert-engine] notify failed:', err.message) + ) + } +} + +async function autoResolveIfOpen(ruleId, assetId, fieldKey) { + await pool.query( + `UPDATE plant_alerts SET status = 'resolved', resolved_at = NOW() + WHERE rule_id = $1 AND asset_id = $2 AND field_key = $3 AND status = 'open'`, + [ruleId, assetId, fieldKey] + ) +} + +// Called by lib/mqtt.js on every message with a parsed numeric value (may be +// null for pure-text fields, in which case lt/gt/eq rules simply never match). +export async function checkThresholdRules(assetId, fieldKey, numericValue) { + const { rows: assetRows } = await pool.query('SELECT asset_type FROM plant_assets WHERE id = $1', [assetId]) + if (!assetRows.length) return + const assetType = assetRows[0].asset_type + + const rules = await findRules(fieldKey, ['lt', 'gt', 'eq'], assetId, assetType) + for (const rule of rules) { + if (breaches(rule.condition, rule.threshold, numericValue)) { + await triggerAlert(rule, assetId, fieldKey, numericValue) + } else { + await autoResolveIfOpen(rule.id, assetId, fieldKey) + } + } +} + +// Called by lib/scheduler.js's periodic sweep — every active stale_minutes +// rule, against every asset it applies to (its own asset_id, or every active +// asset of its asset_type). +export async function checkStaleRules() { + const { rows: rules } = await pool.query( + `SELECT * FROM plant_alert_rules WHERE active = TRUE AND condition = 'stale_minutes'` + ) + if (!rules.length) return + + for (const rule of rules) { + const { rows: assets } = await pool.query( + `SELECT id FROM plant_assets + WHERE active = TRUE + AND ($1::int IS NULL OR id = $1) + AND ($2::text IS NULL OR asset_type = $2)`, + [rule.asset_id, rule.asset_type] + ) + + for (const asset of assets) { + const { rows: latest } = await pool.query( + `SELECT updated_at FROM plant_asset_latest WHERE asset_id = $1 AND field_key = $2`, + [asset.id, rule.field_key] + ) + const lastUpdate = latest[0]?.updated_at + const ageMinutes = lastUpdate ? (Date.now() - new Date(lastUpdate).getTime()) / 60000 : Infinity + + if (ageMinutes >= Number(rule.threshold)) { + await triggerAlert(rule, asset.id, rule.field_key, `stale for ${Math.round(ageMinutes)}m`) + } else { + await autoResolveIfOpen(rule.id, asset.id, rule.field_key) + } + } + } +} diff --git a/backend/src/lib/mailer.js b/backend/src/lib/mailer.js new file mode 100644 index 0000000..075e188 --- /dev/null +++ b/backend/src/lib/mailer.js @@ -0,0 +1,77 @@ +import nodemailer from 'nodemailer' +import { getSetting } from '../db.js' + +const SETTINGS_URL = process.env.SETTINGS_URL || '' +const SETTINGS_SECRET = process.env.SETTINGS_SECRET || '' + +let _smtpCache = null // { config, expires_at } +let _transporter = null + +async function getSmtpConfig() { + if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config + const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, { + headers: { Authorization: `Bearer ${SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`) + const config = await res.json() + if (!config.host) throw new Error('SMTP not configured in settings') + _smtpCache = { config, expires_at: Date.now() + 5 * 60_000 } + _transporter = null + return config +} + +async function getTransporter() { + if (_transporter) return _transporter + const config = await getSmtpConfig() + const port = parseInt(config.port || '587') + _transporter = nodemailer.createTransport({ + host: config.host, + port, + secure: port === 465, + auth: config.user ? { user: config.user, pass: config.pass } : undefined, + }) + return _transporter +} + +const HOTEL_NAME = process.env.VITE_HOTEL_NAME || 'Hotel' + +// Fire-and-forget: email failure must never block telemetry ingestion or the alert write. +async function send(to, subject, text) { + if (!to) return + try { + const config = await getSmtpConfig() + const transport = await getTransporter() + await transport.sendMail({ + from: config.from || `"${HOTEL_NAME} Plant Room" `, + to, + subject, + text, + }) + } catch (err) { + console.error(`Plant mail to ${to} failed: ${err.message}`) + } +} + +const CONDITION_LABEL = { lt: 'below', gt: 'above', eq: 'equal to', stale_minutes: 'stale beyond (minutes)' } + +export async function notifyPlantAlert(alert, asset, rule) { + const to = await getSetting('alert_notify_email') + if (!to) return + + const lines = [ + `Asset: ${asset.name} (${asset.asset_type})`, + `Location: ${asset.location || 'n/a'}`, + `Field: ${rule.field_key}`, + `Condition: ${rule.field_key} ${CONDITION_LABEL[rule.condition] || rule.condition} ${rule.threshold}`, + `Value: ${alert.value_at_trigger}`, + `Severity: ${rule.severity}`, + `Triggered: ${new Date(alert.triggered_at).toLocaleString('en-GB')}`, + ] + + return send( + to, + `[Plant] ${rule.severity.toUpperCase()}: ${asset.name} — ${rule.field_key}`, + lines.join('\n') + ) +} diff --git a/backend/src/lib/mqtt.js b/backend/src/lib/mqtt.js new file mode 100644 index 0000000..19b4829 --- /dev/null +++ b/backend/src/lib/mqtt.js @@ -0,0 +1,157 @@ +// MQTT client for plant-room asset telemetry. Connection/backoff skeleton +// follows utilities/backend/src/lib/mqtt.js's shape (own reconnect/backoff, +// credentials fetched at runtime from settings). Unlike utilities' single +// exact-topic -> meter_id map, this subscribes to a whole prefix per asset +// (`/#`) since one plant asset publishes many fields under +// its own topic tree (e.g. the water softener's salt_level_pct, +// tank_a_capacity_remaining, regeneration_active, ...). The topic segment(s) +// after the prefix become the field_key — generic for any asset type, never +// hardcoded per device. +import mqtt from 'mqtt' +import { pool } from '../db.js' +import { checkThresholdRules } from './alert-engine.js' + +const CLIENT_NAME = 'plant-backend' +const BROKER_RECONNECT_BACKOFF_MS = [30, 60, 120, 300, 600, 1800].map(s => s * 1000) // 30s -> 30min + +let client = null +let connecting = false +let reconnectAttempt = 0 +let prefixMap = [] // [{ prefix, assetId }], longest prefix first so overlapping prefixes resolve to the most specific asset +let subscribedTopics = new Set() + +async function getCredentials() { + const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/mqtt` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching MQTT credentials`) + const s = await res.json() + if (!s.host || !s.username || !s.password) throw new Error('MQTT broker credentials not configured in settings') + return { host: s.host, port: s.port || 1883, username: s.username, password: s.password } +} + +export async function refreshAssetMap() { + const { rows } = await pool.query( + `SELECT id, mqtt_topic_prefix FROM plant_assets WHERE mqtt_topic_prefix IS NOT NULL AND active = TRUE` + ) + prefixMap = rows + .map(r => ({ prefix: r.mqtt_topic_prefix.replace(/\/+$/, ''), assetId: r.id })) + .sort((a, b) => b.prefix.length - a.prefix.length) + if (client?.connected) subscribeToKnownTopics() +} + +function subscribeToKnownTopics() { + const nextTopics = new Set(prefixMap.map(p => `${p.prefix}/#`)) + const stale = [...subscribedTopics].filter(t => !nextTopics.has(t)) + const fresh = [...nextTopics].filter(t => !subscribedTopics.has(t)) + if (stale.length) client.unsubscribe(stale) + if (fresh.length) client.subscribe(fresh) + subscribedTopics = nextTopics +} + +function resolveAsset(topic) { + for (const p of prefixMap) { + if (topic === p.prefix || topic.startsWith(p.prefix + '/')) return p + } + return null +} + +// Booleans (e.g. regeneration_active: "true"/"false") get both a text value +// and a 0/1 numeric value so they can still be charted or used in lt/gt rules; +// enums (e.g. salt_level_status: "ok"/"low") only ever populate value_text. +function parseValue(raw) { + const text = raw.trim() + const lower = text.toLowerCase() + if (lower === 'true' || lower === 'false') return { numeric: lower === 'true' ? 1 : 0, text } + const num = Number(text) + return { numeric: Number.isFinite(num) && text !== '' ? num : null, text } +} + +async function handleMessage(topic, message) { + const match = resolveAsset(topic) + if (!match) return + + const remainder = topic.slice(match.prefix.length).replace(/^\/+/, '') + const fieldKey = (remainder ? remainder.replace(/\//g, '_') : topic) + const { numeric, text } = parseValue(message.toString()) + + try { + await pool.query( + `INSERT INTO plant_telemetry_raw (asset_id, field_key, value_numeric, value_text) VALUES ($1, $2, $3, $4)`, + [match.assetId, fieldKey, numeric, text] + ) + await pool.query( + `INSERT INTO plant_asset_latest (asset_id, field_key, value_numeric, value_text, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (asset_id, field_key) DO UPDATE + SET value_numeric = EXCLUDED.value_numeric, value_text = EXCLUDED.value_text, updated_at = NOW()`, + [match.assetId, fieldKey, numeric, text] + ) + await checkThresholdRules(match.assetId, fieldKey, numeric) + } catch (err) { + console.error('[mqtt] telemetry insert/alert-check failed:', err.message) + } +} + +function scheduleReconnect() { + if (connecting) return + const delay = BROKER_RECONNECT_BACKOFF_MS[Math.min(reconnectAttempt, BROKER_RECONNECT_BACKOFF_MS.length - 1)] + reconnectAttempt++ + console.warn(`[mqtt] broker unreachable — retrying in ${delay / 1000}s`) + setTimeout(() => { connect().catch(() => {}) }, delay) +} + +export async function connect() { + if (connecting || client?.connected) return + connecting = true + try { + const creds = await getCredentials() + await refreshAssetMap() + + await new Promise((resolve, reject) => { + const c = mqtt.connect(`mqtt://${creds.host}:${creds.port}`, { + username: creds.username, + password: creds.password, + clientId: `${CLIENT_NAME}-${Math.random().toString(16).slice(2, 8)}`, + connectTimeout: 10000, + reconnectPeriod: 0, // we own reconnect/backoff ourselves + }) + + c.on('connect', () => { + client = c + reconnectAttempt = 0 + connecting = false + console.log('[mqtt] connected to broker') + subscribedTopics = new Set() + subscribeToKnownTopics() + resolve() + }) + + c.on('message', handleMessage) + + c.on('error', (err) => { + console.error('[mqtt] connection error:', err.message) + }) + + c.on('close', () => { + if (client === c) client = null + connecting = false + scheduleReconnect() + }) + + setTimeout(() => { + if (!client) { c.end(true); connecting = false; reject(new Error('MQTT connect timed out')) } + }, 12000) + }) + } catch (err) { + connecting = false + console.error('[mqtt] could not connect (broker/credentials unavailable):', err.message) + scheduleReconnect() + } +} + +export function isConnected() { + return !!client?.connected +} diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js new file mode 100644 index 0000000..38ca598 --- /dev/null +++ b/backend/src/lib/scheduler.js @@ -0,0 +1,26 @@ +// Periodic sweep for 'stale_minutes' alert rules only — breach-on-message +// (lt/gt/eq) is handled inline as telemetry arrives, in lib/mqtt.js. This +// stack has no cron library anywhere (see utilities/backend/src/lib/scheduler.js); +// a 1-minute setInterval wall-clock poll is plenty granular for a rule whose +// own threshold is expressed in minutes. +import { checkStaleRules } from './alert-engine.js' + +const SWEEP_MS = 60 * 1000 + +let running = false + +export function startScheduler() { + const tick = async () => { + if (running) return + running = true + try { + await checkStaleRules() + } catch (err) { + console.error('[scheduler] stale-rule sweep failed:', err.message) + } finally { + running = false + } + } + setTimeout(tick, 10_000) + setInterval(tick, SWEEP_MS) +} diff --git a/backend/src/routes/alert-rules.js b/backend/src/routes/alert-rules.js new file mode 100644 index 0000000..c957b29 --- /dev/null +++ b/backend/src/routes/alert-rules.js @@ -0,0 +1,93 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' + +const CONDITIONS = ['lt', 'gt', 'eq', 'stale_minutes'] +const SEVERITIES = ['warning', 'critical'] +const ASSET_TYPES = ['boiler', 'water_softener', 'calorifier', 'pump'] + +export async function alertRuleRoutes(app) { + app.addHook('preHandler', requireAuth) + // All of alert-rules CRUD (including read) is gated on manage_assets — this + // is an admin/maintenance-lead configuration surface, not something every + // 'view' user needs to see; open alerts themselves are visible via /api/alerts. + app.addHook('preHandler', requireCap('manage_assets')) + + app.get('/api/alert-rules', async () => { + const { rows } = await pool.query(` + SELECT r.*, a.name AS asset_name + FROM plant_alert_rules r + LEFT JOIN plant_assets a ON a.id = r.asset_id + ORDER BY r.created_at DESC + `) + return rows + }) + + app.post('/api/alert-rules', async (req, reply) => { + const b = req.body || {} + if (!b.field_key || !String(b.field_key).trim()) return reply.status(400).send({ error: 'field_key is required' }) + if (!CONDITIONS.includes(b.condition)) return reply.status(400).send({ error: `condition must be one of: ${CONDITIONS.join(', ')}` }) + if (b.threshold === undefined || b.threshold === null || isNaN(Number(b.threshold))) { + return reply.status(400).send({ error: 'threshold must be a number' }) + } + if (b.severity && !SEVERITIES.includes(b.severity)) return reply.status(400).send({ error: `severity must be one of: ${SEVERITIES.join(', ')}` }) + if (!b.asset_id && !b.asset_type) return reply.status(400).send({ error: 'Either asset_id or asset_type must be set' }) + if (b.asset_type && !ASSET_TYPES.includes(b.asset_type)) return reply.status(400).send({ error: `asset_type must be one of: ${ASSET_TYPES.join(', ')}` }) + + const { rows } = await pool.query( + `INSERT INTO plant_alert_rules (asset_id, asset_type, field_key, condition, threshold, severity, active) + VALUES ($1,$2,$3,$4,$5,$6,$7) + RETURNING *`, + [ + b.asset_id || null, b.asset_id ? null : b.asset_type, b.field_key.trim(), b.condition, + Number(b.threshold), b.severity || 'warning', b.active !== undefined ? b.active : true, + ] + ) + return reply.status(201).send(rows[0]) + }) + + app.patch('/api/alert-rules/:id', async (req, reply) => { + const { rows: existing } = await pool.query('SELECT * FROM plant_alert_rules WHERE id = $1', [req.params.id]) + if (!existing.length) return reply.status(404).send({ error: 'Rule not found' }) + const cur = existing[0] + const b = req.body || {} + + if (b.condition !== undefined && !CONDITIONS.includes(b.condition)) { + return reply.status(400).send({ error: `condition must be one of: ${CONDITIONS.join(', ')}` }) + } + if (b.severity !== undefined && !SEVERITIES.includes(b.severity)) { + return reply.status(400).send({ error: `severity must be one of: ${SEVERITIES.join(', ')}` }) + } + if (b.asset_type !== undefined && b.asset_type !== null && !ASSET_TYPES.includes(b.asset_type)) { + return reply.status(400).send({ error: `asset_type must be one of: ${ASSET_TYPES.join(', ')}` }) + } + + const next = { + asset_id: b.asset_id !== undefined ? b.asset_id : cur.asset_id, + asset_type: b.asset_type !== undefined ? b.asset_type : cur.asset_type, + field_key: b.field_key !== undefined ? b.field_key : cur.field_key, + condition: b.condition !== undefined ? b.condition : cur.condition, + threshold: b.threshold !== undefined ? Number(b.threshold) : cur.threshold, + severity: b.severity !== undefined ? b.severity : cur.severity, + active: b.active !== undefined ? b.active : cur.active, + } + if (next.asset_id) next.asset_type = null + if (!next.asset_id && !next.asset_type) { + return reply.status(400).send({ error: 'Either asset_id or asset_type must be set' }) + } + + const { rows } = await pool.query( + `UPDATE plant_alert_rules SET + asset_id = $1, asset_type = $2, field_key = $3, condition = $4, threshold = $5, severity = $6, active = $7 + WHERE id = $8 + RETURNING *`, + [next.asset_id, next.asset_type, next.field_key, next.condition, next.threshold, next.severity, next.active, req.params.id] + ) + return rows[0] + }) + + app.delete('/api/alert-rules/:id', async (req, reply) => { + const { rows } = await pool.query('DELETE FROM plant_alert_rules WHERE id = $1 RETURNING id', [req.params.id]) + if (!rows.length) return reply.status(404).send({ error: 'Rule not found' }) + return { ok: true } + }) +} diff --git a/backend/src/routes/alerts.js b/backend/src/routes/alerts.js new file mode 100644 index 0000000..7ca3f97 --- /dev/null +++ b/backend/src/routes/alerts.js @@ -0,0 +1,64 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' + +const STATUSES = ['open', 'acknowledged', 'resolved'] +const SEVERITIES = ['warning', 'critical'] + +export async function alertRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/alerts?status=open&severity=critical — joined with asset + rule detail + app.get('/api/alerts', { preHandler: requireCap('view') }, async (req) => { + const { status, severity } = req.query || {} + const conditions = [] + const params = [] + + if (status && STATUSES.includes(status)) { + params.push(status) + conditions.push(`al.status = $${params.length}`) + } + if (severity && SEVERITIES.includes(severity)) { + params.push(severity) + conditions.push(`r.severity = $${params.length}`) + } + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' + + const { rows } = await pool.query( + `SELECT al.*, a.name AS asset_name, a.asset_type, r.condition, r.threshold, r.severity + FROM plant_alerts al + JOIN plant_assets a ON a.id = al.asset_id + JOIN plant_alert_rules r ON r.id = al.rule_id + ${where} + ORDER BY al.triggered_at DESC + LIMIT 500`, + params + ) + return rows + }) + + // PATCH /api/alerts/:id — { action: 'acknowledge' | 'resolve' } + app.patch('/api/alerts/:id', { preHandler: requireCap('acknowledge_alerts') }, async (req, reply) => { + const { rows: existing } = await pool.query('SELECT * FROM plant_alerts WHERE id = $1', [req.params.id]) + if (!existing.length) return reply.status(404).send({ error: 'Alert not found' }) + + const { action } = req.body || {} + if (!['acknowledge', 'resolve'].includes(action)) { + return reply.status(400).send({ error: "action must be 'acknowledge' or 'resolve'" }) + } + + let rows + if (action === 'acknowledge') { + ;({ rows } = await pool.query( + `UPDATE plant_alerts SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = $1 + WHERE id = $2 RETURNING *`, + [req.user.email, req.params.id] + )) + } else { + ;({ rows } = await pool.query( + `UPDATE plant_alerts SET status = 'resolved', resolved_at = NOW() WHERE id = $1 RETURNING *`, + [req.params.id] + )) + } + return rows[0] + }) +} diff --git a/backend/src/routes/assets.js b/backend/src/routes/assets.js new file mode 100644 index 0000000..18bbe98 --- /dev/null +++ b/backend/src/routes/assets.js @@ -0,0 +1,152 @@ +import { mkdir, unlink, writeFile } from 'fs/promises' +import { randomUUID } from 'crypto' +import { join } from 'path' +import sharp from 'sharp' +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' +import { refreshAssetMap } from '../lib/mqtt.js' + +const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] +const PHOTO_TYPES = ['asset', 'serial_plate'] +const ASSET_TYPES = ['boiler', 'water_softener', 'calorifier', 'pump'] + +const WRITABLE_FIELDS = [ + 'name', 'asset_type', 'location', 'make_model', 'serial_no', + 'install_date', 'notes', 'active', 'mqtt_topic_prefix', +] + +export async function assetRoutes(app, opts) { + const UPLOADS_DIR = opts.uploadsDir + app.addHook('preHandler', requireAuth) + + // GET /api/assets — every asset, with photo count + app.get('/api/assets', { preHandler: requireCap('view') }, async () => { + const { rows } = await pool.query(` + SELECT a.*, (SELECT COUNT(*)::int FROM asset_photos p WHERE p.asset_id = a.id) AS photo_count + FROM plant_assets a + ORDER BY a.asset_type, a.name + `) + return rows + }) + + app.get('/api/assets/:id', { preHandler: requireCap('view') }, async (req, reply) => { + const { rows } = await pool.query('SELECT * FROM plant_assets WHERE id = $1', [req.params.id]) + if (!rows.length) return reply.status(404).send({ error: 'Asset not found' }) + return rows[0] + }) + + // POST /api/assets — create + app.post('/api/assets', { preHandler: requireCap('manage_assets') }, async (req, reply) => { + const b = req.body || {} + if (!b.name || !String(b.name).trim()) return reply.status(400).send({ error: 'name is required' }) + if (!ASSET_TYPES.includes(b.asset_type)) { + return reply.status(400).send({ error: `asset_type must be one of: ${ASSET_TYPES.join(', ')}` }) + } + + const { rows } = await pool.query( + `INSERT INTO plant_assets + (name, asset_type, location, make_model, serial_no, install_date, notes, active, mqtt_topic_prefix) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [ + b.name.trim(), b.asset_type, b.location || null, b.make_model || null, b.serial_no || null, + b.install_date || null, b.notes || null, b.active !== undefined ? b.active : true, + b.mqtt_topic_prefix || null, + ] + ) + await refreshAssetMap().catch(err => app.log.warn(`mqtt asset map refresh failed: ${err.message}`)) + return reply.status(201).send(rows[0]) + }) + + // PATCH /api/assets/:id — partial update + app.patch('/api/assets/:id', { preHandler: requireCap('manage_assets') }, async (req, reply) => { + const { rows: existing } = await pool.query('SELECT * FROM plant_assets WHERE id = $1', [req.params.id]) + if (!existing.length) return reply.status(404).send({ error: 'Asset not found' }) + const current = existing[0] + const b = req.body || {} + + if (b.asset_type !== undefined && !ASSET_TYPES.includes(b.asset_type)) { + return reply.status(400).send({ error: `asset_type must be one of: ${ASSET_TYPES.join(', ')}` }) + } + + const next = { ...current } + for (const field of WRITABLE_FIELDS) { + if (b[field] !== undefined) next[field] = b[field] + } + + const { rows } = await pool.query( + `UPDATE plant_assets SET + name = $1, asset_type = $2, location = $3, make_model = $4, serial_no = $5, + install_date = $6, notes = $7, active = $8, mqtt_topic_prefix = $9 + WHERE id = $10 + RETURNING *`, + [ + next.name, next.asset_type, next.location, next.make_model, next.serial_no, + next.install_date, next.notes, next.active, next.mqtt_topic_prefix, req.params.id, + ] + ) + await refreshAssetMap().catch(err => app.log.warn(`mqtt asset map refresh failed: ${err.message}`)) + return rows[0] + }) + + // POST /api/assets/:id/photos — multipart: file + photo_type (asset | serial_plate) + app.post('/api/assets/:id/photos', { preHandler: requireCap('manage_assets') }, async (req, reply) => { + const assetId = parseInt(req.params.id) + const { rows } = await pool.query('SELECT id FROM plant_assets WHERE id = $1', [assetId]) + if (!rows.length) return reply.status(404).send({ error: 'Asset not found' }) + + let fileData = null, photoType = 'asset' + for await (const part of req.parts()) { + if (part.type === 'file') { + if (!ALLOWED_IMAGES.includes(part.mimetype)) { + return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' }) + } + const chunks = [] + for await (const chunk of part.file) chunks.push(chunk) + const raw = Buffer.concat(chunks) + + // Auto-rotate (phone EXIF), resize to 1800px max, re-encode as JPEG — same + // pattern as hvac's device_photos / maintenance's task_photos. + const processed = await sharp(raw) + .rotate() + .resize(1800, 1800, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 82, progressive: true }) + .toBuffer() + + const filename = randomUUID() + '.jpg' + const dir = join(UPLOADS_DIR, 'assets', String(assetId)) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, filename), processed) + fileData = { originalName: part.filename, savedAs: filename, size: processed.length } + } else { + const val = await part.value + if (part.fieldname === 'photo_type' && PHOTO_TYPES.includes(String(val))) photoType = String(val) + } + } + if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' }) + + const filePath = `/assets/${assetId}/${fileData.savedAs}` + const { rows: ins } = await pool.query( + `INSERT INTO asset_photos (asset_id, file_name, file_path, mime_type, file_size, photo_type, uploaded_by) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [assetId, fileData.originalName, filePath, 'image/jpeg', fileData.size, photoType, req.user.email] + ) + return ins[0] + }) + + app.get('/api/assets/:id/photos', { preHandler: requireCap('view') }, async (req) => { + const { rows } = await pool.query( + 'SELECT * FROM asset_photos WHERE asset_id = $1 ORDER BY uploaded_at DESC', + [req.params.id] + ) + return rows + }) + + app.delete('/api/photos/:id', { preHandler: requireCap('manage_assets') }, async (req, reply) => { + const { rows } = await pool.query('SELECT * FROM asset_photos WHERE id = $1', [req.params.id]) + if (!rows.length) return reply.status(404).send({ error: 'Not found' }) + await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {}) + await pool.query('DELETE FROM asset_photos WHERE id = $1', [req.params.id]) + return { ok: true } + }) +} diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..242c1a6 --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,42 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' +import { isConnected as mqttConnected } from '../lib/mqtt.js' + +// Standard app_settings pattern (see conventions doc's "Cross-app integration +// credentials" section) — plant only has one setting today (who gets alert +// emails), but the shape is kept generic so a future integration is a one-row +// addition to ALLOWED_KEYS + the seed default in db.js, not a new pattern. +const ALLOWED_KEYS = new Set(['alert_notify_email']) + +export async function settingsRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.get('/api/settings', { preHandler: requireCap('settings') }, async () => { + const { rows } = await pool.query('SELECT key, value, updated_at FROM app_settings ORDER BY key') + return { settings: rows } + }) + + app.put('/api/settings', { preHandler: requireCap('settings') }, async (req, reply) => { + const { settings } = req.body || {} + if (!Array.isArray(settings)) return reply.status(400).send({ error: 'settings must be an array' }) + + for (const { key, value } of settings) { + if (!ALLOWED_KEYS.has(key)) continue + await pool.query( + `INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, value ?? ''] + ) + } + return { ok: true } + }) + + app.get('/api/settings/mqtt-status', { preHandler: requireCap('settings') }, async () => { + return { + connected: mqttConnected(), + note: mqttConnected() + ? 'Connected to the shared MQTT broker.' + : 'Not connected — the shared MQTT broker (LXC 104) may be unreachable, or plant-backend has no credentials in settings.', + } + }) +} diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js new file mode 100644 index 0000000..506fa63 --- /dev/null +++ b/backend/src/routes/status.js @@ -0,0 +1,54 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' +import { isConnected as mqttConnected } from '../lib/mqtt.js' + +const SEVERITY_RANK = { critical: 2, warning: 1 } + +export async function statusRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/status — every active asset, its latest telemetry fields, and a + // worst-open-alert severity for the Dashboard's per-card colour + top banner. + app.get('/api/status', { preHandler: requireCap('view') }, async () => { + const { rows: assets } = await pool.query(` + SELECT * FROM plant_assets WHERE active = TRUE ORDER BY asset_type, name + `) + + const { rows: latest } = await pool.query(` + SELECT asset_id, field_key, value_numeric, value_text, updated_at + FROM plant_asset_latest + `) + const latestByAsset = new Map() + for (const l of latest) { + if (!latestByAsset.has(l.asset_id)) latestByAsset.set(l.asset_id, []) + latestByAsset.get(l.asset_id).push(l) + } + + const { rows: openAlerts } = await pool.query(` + SELECT al.asset_id, r.severity + FROM plant_alerts al + JOIN plant_alert_rules r ON r.id = al.rule_id + WHERE al.status = 'open' + `) + const alertSummaryByAsset = new Map() + for (const a of openAlerts) { + const cur = alertSummaryByAsset.get(a.asset_id) || { count: 0, max_severity: null } + cur.count++ + if (!cur.max_severity || SEVERITY_RANK[a.severity] > SEVERITY_RANK[cur.max_severity]) { + cur.max_severity = a.severity + } + alertSummaryByAsset.set(a.asset_id, cur) + } + + return { + open_alerts: openAlerts.length, + assets: assets.map(a => ({ + ...a, + latest: latestByAsset.get(a.id) || [], + open_alert_count: alertSummaryByAsset.get(a.id)?.count || 0, + max_severity: alertSummaryByAsset.get(a.id)?.max_severity || null, + })), + mqtt_connected: mqttConnected(), + } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f7de74b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + backend: + build: ./backend + security_opt: + - apparmor=unconfined + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - SETTINGS_URL=${SETTINGS_URL} + - SETTINGS_SECRET=${SETTINGS_SECRET} + - APP_SLUG=plant + - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} + volumes: + - uploads_data:/app/uploads + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"] + interval: 10s + retries: 5 + start_period: 20s + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + VITE_HOTEL_NAME: ${VITE_HOTEL_NAME} + security_opt: + - apparmor=unconfined + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + driver: bridge + +volumes: + uploads_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3c2103a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +ARG VITE_HOTEL_NAME +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html/plant +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1b6a9f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Plant Room + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..648022f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + client_max_body_size 12m; + + location /plant/api/auth/ { + proxy_pass http://10.10.10.101:3001/api/auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /plant/api/ { + proxy_pass http://backend:3001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Cache-Control "no-store"; + } + + location /plant/health { + proxy_pass http://backend:3001/health; + } + + location ~* /plant/.*\.(js|css|png|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /plant/ { + add_header Cache-Control "no-cache" always; + try_files $uri /plant/index.html; + } + + location = / { + return 301 /plant/; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..82da3de --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1901 @@ +{ + "name": "hnf-plant-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hnf-plant-frontend", + "version": "1.0.0", + "dependencies": { + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz", + "integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.397", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.397.tgz", + "integrity": "sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6b5c2a0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "hnf-plant-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..646d2ea --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,35 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import AuthGate, { useAuth } from './components/AuthGate' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Assets from './pages/Assets' +import Alerts from './pages/Alerts' +import Settings from './pages/Settings' +import { can } from './types' + +function Home() { + const { user } = useAuth() + if (can(user, 'view')) return + if (can(user, 'manage_assets')) return + if (can(user, 'settings')) return + return

You don't have access to any Plant Room pages yet.

+} + +export default function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..10b6310 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,112 @@ +import type { + PlantAsset, AssetStatus, AssetPhoto, AlertRule, PlantAlert, AppSetting, PhotoType, +} from './types' + +const BASE = '/plant/api' + +async function request(path: string, opts: RequestInit = {}): Promise { + const res = await fetch(`${BASE}${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...opts.headers }, + ...opts, + }) + if (res.status === 401) { + ;(window.top ?? window).location.href = '/login' + throw new Error('Unauthenticated') + } + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || `Request failed: ${res.status}`) + } + return res.json() +} + +// Assets +export function fetchAssets(): Promise { + return request('/assets') +} +export function createAsset(body: Partial): Promise { + return request('/assets', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateAsset(id: number, body: Partial): Promise { + return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} + +// Asset photos — multipart, so no JSON content-type header +export async function uploadAssetPhoto(assetId: number, file: File, photoType: PhotoType): Promise { + const form = new FormData() + form.append('photo_type', photoType) + form.append('file', file) + const res = await fetch(`${BASE}/assets/${assetId}/photos`, { method: 'POST', credentials: 'include', body: form }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || `Upload failed: ${res.status}`) + } + return res.json() +} +export function fetchAssetPhotos(assetId: number): Promise { + return request(`/assets/${assetId}/photos`) +} +export function deleteAssetPhoto(id: number): Promise<{ ok: boolean }> { + return request(`/photos/${id}`, { method: 'DELETE' }) +} +export function photoUrl(filePath: string): string { + return `${BASE}/uploads${filePath}` +} + +// Status (dashboard) +export function fetchStatus(): Promise<{ open_alerts: number; mqtt_connected: boolean; assets: AssetStatus[] }> { + return request('/status') +} + +// Alerts +export function fetchAlerts(filters: { status?: string; severity?: string } = {}): Promise { + const params = new URLSearchParams() + if (filters.status) params.set('status', filters.status) + if (filters.severity) params.set('severity', filters.severity) + const qs = params.toString() + return request(`/alerts${qs ? `?${qs}` : ''}`) +} +export function acknowledgeAlert(id: number): Promise { + return request(`/alerts/${id}`, { method: 'PATCH', body: JSON.stringify({ action: 'acknowledge' }) }) +} +export function resolveAlert(id: number): Promise { + return request(`/alerts/${id}`, { method: 'PATCH', body: JSON.stringify({ action: 'resolve' }) }) +} + +// Alert rules — threshold/asset_id/asset_type are write-side numbers/nulls, +// distinct enough from AlertRule's read-side (string threshold) shape that a +// plain object type is simpler than fighting Partial here. +export interface AlertRuleInput { + asset_id?: number | null + asset_type?: AlertRule['asset_type'] + field_key?: string + condition?: AlertRule['condition'] + threshold?: number + severity?: AlertRule['severity'] + active?: boolean +} + +export function fetchAlertRules(): Promise { + return request('/alert-rules') +} +export function createAlertRule(body: AlertRuleInput): Promise { + return request('/alert-rules', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateAlertRule(id: number, body: AlertRuleInput): Promise { + return request(`/alert-rules/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function deleteAlertRule(id: number): Promise<{ ok: boolean }> { + return request(`/alert-rules/${id}`, { method: 'DELETE' }) +} + +// Settings +export function getSettings(): Promise<{ settings: AppSetting[] }> { + return request('/settings') +} +export function saveSettings(settings: { key: string; value: string }[]): Promise<{ ok: boolean }> { + return request('/settings', { method: 'PUT', body: JSON.stringify({ settings }) }) +} +export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> { + return request('/settings/mqtt-status') +} diff --git a/frontend/src/components/AssetPhotoUpload.tsx b/frontend/src/components/AssetPhotoUpload.tsx new file mode 100644 index 0000000..e3ffc9e --- /dev/null +++ b/frontend/src/components/AssetPhotoUpload.tsx @@ -0,0 +1,93 @@ +import { useRef, useState } from 'react' +import { Camera, Image as ImageIcon, Loader2, X } from 'lucide-react' +import type { AssetPhoto, PhotoType } from '../types' +import { uploadAssetPhoto, deleteAssetPhoto, photoUrl } from '../api' + +// Camera/library capture split: one input with capture="environment" (opens the +// device camera directly on mobile), one plain file input (opens the photo +// library/file picker) — same split as hvac's DevicePhotoUpload. +export default function AssetPhotoUpload({ assetId, photoType, photos, onChanged, canDelete }: { + assetId: number + photoType: PhotoType + photos: AssetPhoto[] + onChanged: () => void + canDelete: boolean +}) { + const [uploading, setUploading] = useState(false) + const [error, setError] = useState('') + const [lightbox, setLightbox] = useState(null) + const cameraInput = useRef(null) + const libraryInput = useRef(null) + + const slotPhotos = photos.filter(p => p.photo_type === photoType) + + async function handleFile(file: File | undefined) { + if (!file) return + setUploading(true); setError('') + try { + await uploadAssetPhoto(assetId, file, photoType) + onChanged() + } catch (e) { + setError(e instanceof Error ? e.message : 'Upload failed') + } finally { + setUploading(false) + } + } + + async function remove(id: number) { + await deleteAssetPhoto(id).catch(() => {}) + onChanged() + } + + return ( +
+
+ {slotPhotos.map(p => ( +
+ setLightbox(photoUrl(p.file_path))} /> + {canDelete && ( + + )} +
+ ))} + {uploading && ( +
+
+ +
+
+ )} +
+ + {error &&
{error}
} + +
+ + { handleFile(e.target.files?.[0]); e.target.value = '' }} + /> + + { handleFile(e.target.files?.[0]); e.target.value = '' }} + /> +
+ + {lightbox && ( +
setLightbox(null)}> + + +
+ )} +
+ ) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..f9bca19 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState, createContext, useContext } from 'react' +import type { User } from '../types' + +function getInactivityMs(): number | null { + if (window.matchMedia('(display-mode: standalone)').matches) return null + const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins=')) + if (!c) return null + const mins = parseInt(c.split('=')[1]) + return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000 +} + +// Only bounce to the central login when actually embedded in the portal shell. +// A directly-opened browser tab must never navigate away from its own scope. +function isEmbedded() { + return window.top !== window +} + +interface AuthCtx { user: User } +const Ctx = createContext(null) + +export function useAuth() { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useAuth must be used inside AuthGate') + return ctx +} + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') + const [user, setUser] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const timerRef = useRef | null>(null) + + useEffect(() => { + fetch('/api/auth/verify?app=plant', { credentials: 'include' }) + .then(async r => { + if (r.ok) { + setUser(await r.json()) + setState('authed') + } else if (isEmbedded()) { + window.top!.location.href = `/login?from=${encodeURIComponent('/app/plant')}` + } else { + setState('login') + } + }) + .catch(() => { if (!isEmbedded()) setState('login') }) + }, []) + + // Inactivity auto-logout — configurable per device (Admin Settings → Device). + useEffect(() => { + const ms = getInactivityMs() + if (state !== 'authed' || !ms) return + const timeoutMs: number = ms + + async function forceLogout() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) + setUser(null) + setState('login') + } + + function reset() { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(forceLogout, timeoutMs) + } + + const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const + events.forEach(e => window.addEventListener(e, reset, { passive: true })) + reset() + + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + events.forEach(e => window.removeEventListener(e, reset)) + } + }, [state]) + + async function login(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/login', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { setError('Invalid email or password'); return } + const verify = await fetch('/api/auth/verify?app=plant', { credentials: 'include' }) + if (verify.ok) { + setUser(await verify.json()) + setState('authed') + } else { + setError("You don't have access to this app.") + } + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { + return ( +
+ Loading… +
+ ) + } + + if (state === 'login') { + return ( +
+
+

+ Plant Room +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" + style={inputStyle} + /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" + style={inputStyle} + /> + {error &&

{error}

} + +
+
+
+ ) + } + + return ( + + {children} + + ) +} + +const inputStyle: React.CSSProperties = { + background: 'var(--navy-dark)', border: '1px solid var(--surface-2)', + borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..bb90a91 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react' +import { NavLink, useLocation } from 'react-router-dom' +import { Gauge, LayoutGrid, Wrench, Bell, Settings, Menu, LogOut } from 'lucide-react' +import { useAuth } from './AuthGate' +import { can } from '../types' + +const ICON_PROPS = { size: 16, strokeWidth: 1.75 } + +const NAV = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutGrid, cap: 'view' }, + { to: '/assets', label: 'Assets', icon: Wrench, cap: 'manage_assets' }, + { to: '/alerts', label: 'Alerts', icon: Bell, cap: 'view' }, + { to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' }, +] + +export default function Layout({ children }: { children: React.ReactNode }) { + const { user } = useAuth() + const items = NAV.filter(n => can(user, n.cap)) + const location = useLocation() + const [menuOpen, setMenuOpen] = useState(false) + + async function logout() { + await fetch('/plant/api/auth/logout', { method: 'POST', credentials: 'include' }) + window.location.reload() + } + + useEffect(() => { setMenuOpen(false) }, [location.pathname]) + + return ( +
+ + + {menuOpen &&
setMenuOpen(false)} />} + +
+ + + Plant Room +
+ +
+ {children} +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..9aa4f16 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,374 @@ +/* Stack design system tokens — include verbatim in every app */ +:root { + --navy: #1a1a2e; + --navy-dark: #0f0f20; + --gold: #c9a84c; + --gold-light: #e8c96d; + --surface: rgba(255,255,255,0.07); + --surface-2: rgba(255,255,255,0.08); + --text: rgba(255,255,255,0.88); + --text-muted: rgba(255,255,255,0.48); + --body-bg: #f4f5f7; + --card-bg: #ffffff; + --card-border: #e4e8ee; + --text-dark: #1e293b; + --text-mid: #64748b; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08); + --danger: #dc2626; + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} +body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); } + +/* App theme + semantic tokens */ +:root { + --app-primary: #0e7490; + --app-primary-light: #0891b2; + + --sev-warning: #d97706; + --sev-critical: #dc2626; + --sev-ok: #16a34a; + + --danger-bg: #fef2f2; + --warn-bg: #fffbeb; + --ok-bg: #f0fdf4; + + --sidebar-w: 240px; + --topbar-h: 56px; +} + +*, *::before, *::after { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; font-size: 14px; } + +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; } + +/* ── App shell ─────────────────────────────────────────────── */ +.app-shell { display: flex; height: 100vh; overflow: hidden; } + +.sidebar { + width: var(--sidebar-w); + background: var(--navy); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow-y: auto; +} +.sidebar-logo { + padding: 20px 16px 12px; + color: var(--gold); + font-size: 13px; + font-weight: 600; + letter-spacing: .05em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 8px; +} +.sidebar-logo svg { opacity: .8; } +.sidebar-nav { flex: 1; padding: 8px 0; } +.sidebar-nav a { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + color: var(--text-muted); + text-decoration: none; + font-size: 13.5px; + transition: background .15s, color .15s; +} +.sidebar-nav a:hover { background: var(--surface); color: var(--text); } +.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); } +.sidebar-user { + padding: 12px 16px; + border-top: 1px solid var(--surface-2); + color: var(--text-muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.top-bar { + display: none; + height: var(--topbar-h); + background: var(--navy); + color: var(--text); + align-items: center; + padding: 0 12px; + gap: 10px; + flex-shrink: 0; +} +.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); } + +.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } + +@media (max-width: 768px) { + .sidebar { + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 200; + transform: translateX(calc(-1 * var(--sidebar-w))); + transition: transform 0.25s ease; + } + .app-shell.menu-open .sidebar { transform: translateX(0); } + .top-bar { display: flex; } + .app-shell { flex-direction: column; } + .field-row { flex-direction: column; } +} + +.menu-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 199; +} + +.top-bar-burger { + background: none; + border: none; + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + padding: 4px; + flex-shrink: 0; +} + +/* ── Page chrome ───────────────────────────────────────────── */ +.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; } +.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.page-header h1 { font-size: 18px; margin: 0; flex: 1; } + +/* ── Buttons ───────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--card-border); + background: var(--card-bg); + color: var(--text-dark); + border-radius: var(--radius); + padding: 7px 14px; + font-size: 13px; + cursor: pointer; + font-family: var(--font); + transition: background .12s, border-color .12s; +} +.btn:hover { border-color: var(--text-mid); } +.btn:disabled { opacity: .5; cursor: default; } +.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; } +.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); } +.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; } +.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; } + +/* ── Forms ─────────────────────────────────────────────────── */ +.field { margin-bottom: 12px; } +.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; } +.field input[type="text"], .field input[type="email"], .field input[type="date"], +.field input[type="number"], .field select, .field textarea { + width: 100%; + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 8px 10px; + font-size: 13.5px; + font-family: var(--font); + color: var(--text-dark); + background: var(--card-bg); +} +.field textarea { min-height: 72px; resize: vertical; } +.field-row { display: flex; gap: 12px; } +.field-row > .field { flex: 1; } +.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; } +.field-check input { width: 16px; height: 16px; accent-color: var(--gold); } +.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; } + +/* ── Cards ─────────────────────────────────────────────────── */ +.card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + padding: 14px 16px; + margin-bottom: 10px; +} + +/* ── Asset grid / cards ────────────────────────────────────── */ +.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } +.asset-card { + border-left: 4px solid var(--sev-ok); +} +.asset-card.sev-warning { border-left-color: var(--sev-warning); } +.asset-card.sev-critical { border-left-color: var(--sev-critical); } +.asset-card-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; justify-content: space-between; } +.asset-card-meta { font-size: 12px; color: var(--text-mid); margin-bottom: 8px; } +.asset-field-list { display: flex; flex-direction: column; gap: 4px; } +.asset-field-row { display: flex; justify-content: space-between; gap: 8px; font-size: 12.5px; } +.asset-field-key { color: var(--text-mid); } +.asset-field-value { font-weight: 600; color: var(--text-dark); } + +/* ── Badges ────────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 20px; + padding: 2px 9px; + font-size: 11px; + font-weight: 600; + color: #fff; + white-space: nowrap; +} +.badge-sev-warning { background: var(--sev-warning); } +.badge-sev-critical { background: var(--sev-critical); } +.badge-sev-ok { background: var(--sev-ok); } + +.badge-status-open { background: var(--sev-critical); } +.badge-status-acknowledged { background: var(--sev-warning); } +.badge-status-resolved { background: var(--sev-ok); } + +.badge-outline { + background: transparent; + border: 1px solid var(--card-border); + color: var(--text-mid); + font-weight: 500; +} + +/* ── Modal ─────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15,15,32,.55); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 24px 12px; + z-index: 100; + overflow-y: auto; +} +.modal { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + width: 100%; + max-width: 680px; + padding: 20px; + margin: auto 0; +} +.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; } +.modal-header h2 { font-size: 16px; margin: 0; flex: 1; } +.modal-close { + background: none; + border: none; + cursor: pointer; + color: var(--text-mid); + padding: 2px; + display: flex; +} +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; } + +/* ── Photos ────────────────────────────────────────────────── */ +.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } +.photo-thumb { + width: 84px; + height: 84px; + border-radius: 8px; + object-fit: cover; + border: 1px solid var(--card-border); + cursor: pointer; +} +.photo-thumb-uploading { + display: flex; align-items: center; justify-content: center; + background: var(--card-border); color: var(--text-mid); +} +@keyframes spin { to { transform: rotate(360deg); } } +.spin { animation: spin .75s linear infinite; } +.photo-thumb-wrap { position: relative; } +.photo-del { + position: absolute; + top: -6px; + right: -6px; + background: var(--danger); + color: #fff; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.lightbox-overlay { + position: fixed; inset: 0; z-index: 200; + background: rgba(0,0,0,.92); + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.lightbox-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; } +.lightbox-close { + position: absolute; top: 16px; right: 16px; + background: rgba(255,255,255,.15); border: none; border-radius: 50%; + width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; + cursor: pointer; color: #fff; transition: background .12s; +} +.lightbox-close:hover { background: rgba(255,255,255,.28); } + +/* ── Tables ────────────────────────────────────────────────── */ +.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } +table.data { width: 100%; border-collapse: collapse; font-size: 13px; } +table.data th { + text-align: left; + padding: 9px 12px; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-mid); + border-bottom: 1px solid var(--card-border); + white-space: nowrap; +} +table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; } +table.data tr:last-child td { border-bottom: none; } +table.data tr.clickable { cursor: pointer; } + +/* ── Misc ──────────────────────────────────────────────────── */ +.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; } +.error-banner { + background: var(--danger-bg); + border: 1px solid var(--danger); + color: var(--danger); + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; +} +.ok-banner { + background: var(--ok-bg); + border: 1px solid var(--sev-ok); + color: var(--sev-ok); + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; +} +.warn-banner { + background: var(--warn-bg); + border: 1px solid var(--sev-warning); + color: var(--sev-warning); + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; +} +.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; } +.muted { color: var(--text-mid); } + +/* Sidebar scrollbar */ +.sidebar::-webkit-scrollbar, .sidebar-nav::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-track, .sidebar-nav::-webkit-scrollbar-track { background: transparent; } +.sidebar::-webkit-scrollbar-thumb, .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; } +.sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); } +.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4a1b150 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/Alerts.tsx b/frontend/src/pages/Alerts.tsx new file mode 100644 index 0000000..7e883a4 --- /dev/null +++ b/frontend/src/pages/Alerts.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState, useCallback } from 'react' +import { Check, CheckCheck } from 'lucide-react' +import { fetchAlerts, acknowledgeAlert, resolveAlert } from '../api' +import type { PlantAlert, AlertStatus, AlertSeverity } from '../types' +import { CONDITION_LABELS } from '../types' +import { useAuth } from '../components/AuthGate' +import { can } from '../types' + +const STATUS_OPTIONS: { value: AlertStatus | ''; label: string }[] = [ + { value: '', label: 'All statuses' }, + { value: 'open', label: 'Open' }, + { value: 'acknowledged', label: 'Acknowledged' }, + { value: 'resolved', label: 'Resolved' }, +] +const SEVERITY_OPTIONS: { value: AlertSeverity | ''; label: string }[] = [ + { value: '', label: 'All severities' }, + { value: 'warning', label: 'Warning' }, + { value: 'critical', label: 'Critical' }, +] + +export default function Alerts() { + const { user } = useAuth() + const canAck = can(user, 'acknowledge_alerts') + + const [alerts, setAlerts] = useState([]) + const [status, setStatus] = useState('open') + const [severity, setSeverity] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [busyId, setBusyId] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchAlerts({ status: status || undefined, severity: severity || undefined }) + .then(setAlerts) + .catch(e => setError(e instanceof Error ? e.message : 'Failed to load alerts')) + .finally(() => setLoading(false)) + }, [status, severity]) + + useEffect(() => { load() }, [load]) + + async function ack(id: number) { + setBusyId(id); setError('') + try { + await acknowledgeAlert(id) + load() + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to acknowledge') + } finally { + setBusyId(null) + } + } + + async function resolve(id: number) { + setBusyId(id); setError('') + try { + await resolveAlert(id) + load() + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to resolve') + } finally { + setBusyId(null) + } + } + + return ( +
+
+

Alerts

+
+ + {error &&
{error}
} + +
+
+ + +
+
+ + +
+
+ + {loading ? ( +

Loading…

+ ) : alerts.length === 0 ? ( +
No alerts match this filter.
+ ) : ( +
+ + + + + + + + + + + {canAck && } + + + + {alerts.map(a => ( + + + + + + + + + {canAck && ( + + )} + + ))} + +
AssetFieldConditionValue at triggerSeverityStatusTriggeredActions
{a.asset_name}{a.field_key.replace(/_/g, ' ')}{CONDITION_LABELS[a.condition]} {a.threshold}{a.value_at_trigger ?? '—'}{a.severity}{a.status}{new Date(a.triggered_at).toLocaleString('en-GB')} + {a.status === 'open' && ( +
+ + +
+ )} + {a.status === 'acknowledged' && ( + + )} + {a.status === 'resolved' && } +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx new file mode 100644 index 0000000..d57f8d0 --- /dev/null +++ b/frontend/src/pages/Assets.tsx @@ -0,0 +1,291 @@ +import { Fragment, useEffect, useState } from 'react' +import { ChevronDown, ChevronRight, Plus } from 'lucide-react' +import { fetchAssets, createAsset, updateAsset, fetchAssetPhotos } from '../api' +import type { PlantAsset, AssetPhoto } from '../types' +import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types' +import AssetPhotoUpload from '../components/AssetPhotoUpload' +import { useAuth } from '../components/AuthGate' +import { can } from '../types' + +const BLANK_FORM = { + name: '', asset_type: 'boiler' as PlantAsset['asset_type'], location: '', make_model: '', + serial_no: '', install_date: '', notes: '', mqtt_topic_prefix: '', +} + +export default function Assets() { + const { user } = useAuth() + const canManage = can(user, 'manage_assets') + + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [msg, setMsg] = useState('') + const [expanded, setExpanded] = useState(null) + const [photosByAsset, setPhotosByAsset] = useState>({}) + const [editForm, setEditForm] = useState>({}) + + const [showNew, setShowNew] = useState(false) + const [newForm, setNewForm] = useState(BLANK_FORM) + const [creating, setCreating] = useState(false) + + function load() { + fetchAssets() + .then(setAssets) + .catch(e => setError(e instanceof Error ? e.message : 'Failed to load')) + .finally(() => setLoading(false)) + } + useEffect(load, []) + + async function toggleExpand(a: PlantAsset) { + if (expanded === a.id) { setExpanded(null); return } + setExpanded(a.id) + setEditForm({ + name: a.name, asset_type: a.asset_type, location: a.location || '', make_model: a.make_model || '', + serial_no: a.serial_no || '', install_date: a.install_date ? a.install_date.slice(0, 10) : '', + notes: a.notes || '', mqtt_topic_prefix: a.mqtt_topic_prefix || '', active: String(a.active), + }) + if (!photosByAsset[a.id]) { + const photos = await fetchAssetPhotos(a.id).catch(() => []) + setPhotosByAsset(prev => ({ ...prev, [a.id]: photos })) + } + } + + async function reloadPhotos(id: number) { + const photos = await fetchAssetPhotos(id).catch(() => []) + setPhotosByAsset(prev => ({ ...prev, [id]: photos })) + } + + async function saveEdit(id: number) { + setError(''); setMsg('') + try { + await updateAsset(id, { + name: editForm.name, + asset_type: editForm.asset_type as PlantAsset['asset_type'], + location: editForm.location || null, + make_model: editForm.make_model || null, + serial_no: editForm.serial_no || null, + install_date: editForm.install_date || null, + notes: editForm.notes || null, + mqtt_topic_prefix: editForm.mqtt_topic_prefix || null, + active: editForm.active === 'true', + }) + setMsg('Saved') + setTimeout(() => setMsg(''), 2000) + load() + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + if (!newForm.name.trim()) return + setCreating(true); setError(''); setMsg('') + try { + await createAsset({ + name: newForm.name.trim(), + asset_type: newForm.asset_type, + location: newForm.location || null, + make_model: newForm.make_model || null, + serial_no: newForm.serial_no || null, + install_date: newForm.install_date || null, + notes: newForm.notes || null, + mqtt_topic_prefix: newForm.mqtt_topic_prefix || null, + }) + setNewForm(BLANK_FORM) + setShowNew(false) + setMsg('Asset created') + load() + } catch (e) { + setError(e instanceof Error ? e.message : 'Create failed') + } finally { + setCreating(false) + } + } + + if (loading) return

Loading…

+ + return ( +
+
+

Assets

+ {canManage && ( + + )} +
+ + {error &&
{error}
} + {msg &&
{msg}
} + + {showNew && canManage && ( +
+
+
+ + setNewForm({ ...newForm, name: e.target.value })} required /> +
+
+ + +
+
+
+
+ + setNewForm({ ...newForm, location: e.target.value })} placeholder="Plant room, roof, ..." /> +
+
+ + setNewForm({ ...newForm, mqtt_topic_prefix: e.target.value })} placeholder="plant/water-softener" /> +
+
+
+
+ + setNewForm({ ...newForm, make_model: e.target.value })} /> +
+
+ + setNewForm({ ...newForm, serial_no: e.target.value })} /> +
+
+ + setNewForm({ ...newForm, install_date: e.target.value })} /> +
+
+
+ +