feat: Add new API endpoints and HTML pages for ML model management
- Implemented HTML pages for datasets, models, training, testing, and results. - Created API endpoints for managing repositories, results, tests, and training sessions. - Added functionality for streaming training progress via Server-Sent Events (SSE). - Introduced a Dockerfile for the ML runner with necessary dependencies. - Developed an SDK for user code execution within the runner container. - Enhanced CSS styles for improved UI layout and navigation. - Established a layout template for consistent HTML structure across pages. - Added JavaScript for dynamic interactions on the models page. - Implemented WebSocket handling for real-time communication with kiosk devices and controllers. - Implemented model registration and management API at /api/models - Added Gitea proxy API for repository interactions at /api/repos - Created results API for listing and comparing training results at /api/results - Developed training management API for enqueueing and retrieving training jobs at /api/trainings - Introduced SSE endpoint for live training progress updates - Added HTML pages for models, datasets, and training management - Created a Dockerfile for the ML runner with necessary dependencies - Developed SDK for user code execution within the runner container - Enhanced CSS styles for improved UI/UX - Implemented WebSocket communication for real-time device and controller interactions in the kiosk system
This commit is contained in:
193
api/package-lock.json
generated
193
api/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"minio": "^8.0.7",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.20.0"
|
||||
}
|
||||
},
|
||||
@@ -63,6 +64,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -123,6 +130,23 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -170,6 +194,51 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"engines": [
|
||||
"node >= 0.8"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^2.2.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -229,6 +298,12 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -663,6 +738,12 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -821,6 +902,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minio": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
|
||||
@@ -875,12 +965,86 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -890,6 +1054,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -1085,6 +1258,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1424,6 +1603,14 @@
|
||||
"stream-chain": "^2.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-uri-encode": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||
@@ -1486,6 +1673,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"minio": "^8.0.7",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.20.0"
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,12 @@ app.get('/health', async (req, res) => {
|
||||
const paramsSensorRoutes = require('./routes/params.sensor');
|
||||
app.use('/params/sensor', paramsSensorRoutes);
|
||||
|
||||
const kioskSensorRoutes = require('./routes/kiosk.sensor');
|
||||
app.use('/kiosk/sensor', kioskSensorRoutes);
|
||||
|
||||
const kioskPublicRoutes = require('./routes/kiosk.public');
|
||||
app.use('/kiosk', kioskPublicRoutes);
|
||||
|
||||
// Middleware di autenticazione per tutte le API protette
|
||||
app.use(requireAuth);
|
||||
|
||||
@@ -75,6 +81,27 @@ app.use('/settings', settingsRoutes)
|
||||
const sessionsRoutes = require('./routes/sessions')
|
||||
app.use('/sessions', sessionsRoutes)
|
||||
|
||||
const docsRoutes = require('./routes/docs')
|
||||
app.use('/docs', docsRoutes)
|
||||
|
||||
const marineDatasetsRoutes = require('./routes/marine.datasets')
|
||||
app.use('/marine/datasets', marineDatasetsRoutes)
|
||||
|
||||
const jobsRoutes = require('./routes/jobs')
|
||||
app.use('/jobs', jobsRoutes)
|
||||
|
||||
const queueRoutes = require('./routes/queue')
|
||||
app.use('/queue', queueRoutes)
|
||||
|
||||
const pageconnectionsRoutes = require('./routes/pageconnections')
|
||||
app.use('/pageconnections', pageconnectionsRoutes)
|
||||
|
||||
const kioskRoutes = require('./routes/kiosk')
|
||||
app.use('/kiosk', kioskRoutes)
|
||||
|
||||
const rulesRoutes = require('./routes/rules')
|
||||
app.use('/rules', rulesRoutes)
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Started on port ${PORT}`);
|
||||
});
|
||||
|
||||
71
api/src/migrations/001_ml_datasets.sql
Normal file
71
api/src/migrations/001_ml_datasets.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- -- Database: ml
|
||||
-- -- Eseguire con: psql -U meb -d ml -f 001_ml_datasets.sql
|
||||
-- --
|
||||
-- -- Tabella dei metadati dei dataset salvati su MinIO.
|
||||
-- -- Ogni riga è associata ad un file nel bucket "ml.datasets" (o altri bucket future)
|
||||
-- -- tramite minio_key (= nome oggetto in MinIO, che è anche il suo "id" nativo).
|
||||
|
||||
-- CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS datasets (
|
||||
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- -- Storage MinIO
|
||||
-- minio_key TEXT NOT NULL UNIQUE, -- es. "2026-04-22_currents_med.csv"
|
||||
-- bucket TEXT NOT NULL DEFAULT 'ml.datasets',
|
||||
|
||||
-- -- Identità dataset
|
||||
-- nome TEXT NOT NULL,
|
||||
-- description TEXT,
|
||||
-- tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
-- type TEXT NOT NULL DEFAULT 'copernicus', -- copernicus | custom | imported
|
||||
-- format TEXT NOT NULL, -- csv | json | netcdf
|
||||
-- notes TEXT,
|
||||
|
||||
-- -- Provenienza / audit
|
||||
-- created_by TEXT NOT NULL, -- username
|
||||
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- last_used_at TIMESTAMPTZ,
|
||||
|
||||
-- -- Misure del file
|
||||
-- size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
-- row_count BIGINT, -- numero righe (se noto)
|
||||
-- columns TEXT[], -- nomi colonne finali (dopo rename)
|
||||
|
||||
-- -- Specifico Copernicus (nullable per altri type)
|
||||
-- copernicus_dataset_id TEXT,
|
||||
-- variables TEXT[], -- variabili richieste
|
||||
-- variable_renames JSONB, -- {original: custom}
|
||||
-- bbox JSONB, -- [min_lon, min_lat, max_lon, max_lat]
|
||||
-- start_date DATE,
|
||||
-- end_date DATE,
|
||||
|
||||
-- -- Estensibile per type futuri senza migration
|
||||
-- params JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
|
||||
-- -- Versioning semplice
|
||||
-- version INT NOT NULL DEFAULT 1,
|
||||
|
||||
-- CONSTRAINT datasets_format_ok CHECK (format IN ('csv','json','netcdf')),
|
||||
-- CONSTRAINT datasets_type_ok CHECK (type IN ('copernicus','custom','imported'))
|
||||
-- );
|
||||
|
||||
-- CREATE INDEX IF NOT EXISTS idx_datasets_created_by ON datasets(created_by);
|
||||
-- CREATE INDEX IF NOT EXISTS idx_datasets_type ON datasets(type);
|
||||
-- CREATE INDEX IF NOT EXISTS idx_datasets_tags ON datasets USING gin(tags);
|
||||
-- CREATE INDEX IF NOT EXISTS idx_datasets_created_at ON datasets(created_at DESC);
|
||||
-- CREATE INDEX IF NOT EXISTS idx_datasets_minio_key ON datasets(minio_key);
|
||||
|
||||
-- -- Trigger per aggiornare updated_at automaticamente
|
||||
-- CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
|
||||
-- BEGIN
|
||||
-- NEW.updated_at = NOW();
|
||||
-- RETURN NEW;
|
||||
-- END;
|
||||
-- $$ LANGUAGE plpgsql;
|
||||
|
||||
-- DROP TRIGGER IF EXISTS trg_datasets_updated_at ON datasets;
|
||||
-- CREATE TRIGGER trg_datasets_updated_at
|
||||
-- BEFORE UPDATE ON datasets
|
||||
-- FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
5
api/src/migrations/002_ml_datasets_bucket_default.sql
Normal file
5
api/src/migrations/002_ml_datasets_bucket_default.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Database: ml
|
||||
-- DEPRECATED: la colonna `bucket` e' stata rimossa dalla tabella `datasets`.
|
||||
-- Il bucket e' ora fisso a 'ml.datasets' lato applicazione (vedi
|
||||
-- api/src/routes/marine.datasets.js e ml/routers/datasets.py).
|
||||
-- Questo file e' lasciato vuoto per non rompere lo storico delle migration.
|
||||
35
api/src/migrations/003_ml_models.sql
Normal file
35
api/src/migrations/003_ml_models.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Database: ml
|
||||
-- Registro modelli ML: ogni riga punta a una repo Gitea con codice del modello.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'xgboost'|'lstm'|'sklearn'|...
|
||||
gitea_repo TEXT NOT NULL, -- "owner/repo"
|
||||
default_branch TEXT NOT NULL DEFAULT 'main',
|
||||
spec JSONB, -- copia cached di model.yml @ tip del default_branch
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_created_by ON models(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_type ON models(type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||
author TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_notes_model ON model_notes(model_id, created_at DESC);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_models_updated_at ON models;
|
||||
CREATE TRIGGER trg_models_updated_at
|
||||
BEFORE UPDATE ON models
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- La colonna `bucket` su datasets e' stata rimossa: il bucket e' fisso a
|
||||
-- 'ml.datasets' (vedi codice). Lasciato come no-op per coerenza storica.
|
||||
26
api/src/migrations/004_ml_trainings.sql
Normal file
26
api/src/migrations/004_ml_trainings.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Database: ml
|
||||
-- Storico training di modelli. Le time-series (cpu/mem/loss) vivono su InfluxDB;
|
||||
-- qui salviamo solo anagrafica + risultati finali e riepilogo risorse.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trainings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||
version TEXT NOT NULL,
|
||||
patch TEXT NOT NULL, -- git commit sha (short o full)
|
||||
dataset_id UUID NOT NULL,
|
||||
started_by TEXT NOT NULL,
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
duration_ms BIGINT,
|
||||
status TEXT NOT NULL DEFAULT 'queued', -- queued|running|succeeded|failed|cancelled
|
||||
artifacts_prefix TEXT, -- es. "models/<id>/<version>/<patch>"
|
||||
results JSONB, -- final metrics + plots (arrays puri)
|
||||
resource_summary JSONB, -- {cpu_peak,cpu_avg,mem_peak_mb,mem_avg_mb,samples}
|
||||
error TEXT,
|
||||
CONSTRAINT trainings_status_ok CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
|
||||
UNIQUE(model_id, version, patch)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trainings_model ON trainings(model_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_trainings_status ON trainings(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_trainings_user ON trainings(started_by);
|
||||
17
api/src/migrations/005_ml_tests.sql
Normal file
17
api/src/migrations/005_ml_tests.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Database: ml
|
||||
-- Sessioni di test: una sessione contiene 1..N run (set di input → output).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
training_id UUID NOT NULL REFERENCES trainings(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
runs JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
-- ogni elemento: {inputs, outputs, duration_ms, cpu_peak, mem_peak_mb, ts}
|
||||
model_size_bytes BIGINT,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tests_training ON tests(training_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tests_user ON tests(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tests_started ON tests(started_at DESC);
|
||||
27
api/src/migrations/006_jobs.sql
Normal file
27
api/src/migrations/006_jobs.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Database: ml
|
||||
-- Tabella jobs: ciclo di vita di un lavoro asincrono (training oggi, domani altro).
|
||||
-- L'api-service espone /jobs /queue /pageconnections per coordinare accessi e coda.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type TEXT NOT NULL, -- 'train' | 'test' | ...
|
||||
status TEXT NOT NULL DEFAULT 'queued',-- queued|running|succeeded|failed|cancelled
|
||||
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
result JSONB,
|
||||
error TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
CONSTRAINT jobs_status_ok CHECK (status IN ('queued','running','succeeded','failed','cancelled'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at DESC);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||
CREATE TRIGGER trg_jobs_updated_at
|
||||
BEFORE UPDATE ON jobs
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
79
api/src/migrations/007_kiosktemplates.sql
Normal file
79
api/src/migrations/007_kiosktemplates.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- ============================================================
|
||||
-- Kiosk schema (DB: sensors)
|
||||
-- ------------------------------------------------------------
|
||||
-- SOLO struttura: PK, FK, NOT NULL, sequence, indice.
|
||||
-- Tutta la logica (id char(8), updated_at, defaults, CHECK,
|
||||
-- "un solo active") e' gestita lato applicazione (Node/JS).
|
||||
-- ============================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- Templates
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS kiosktemplates (
|
||||
id char(8) PRIMARY KEY,
|
||||
name varchar(50) NOT NULL,
|
||||
tags text[],
|
||||
active boolean,
|
||||
archived boolean,
|
||||
created_at timestamp,
|
||||
updated_at timestamp
|
||||
);
|
||||
|
||||
-- Idempotente: se la tabella esiste gia', garantisci PK + NOT NULL critici.
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'kiosktemplates'::regclass AND contype = 'p'
|
||||
) THEN
|
||||
ALTER TABLE kiosktemplates ADD PRIMARY KEY (id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE kiosktemplates ALTER COLUMN name SET NOT NULL;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- Elements (un template -> N elementi)
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS kioskelements (
|
||||
id bigint PRIMARY KEY,
|
||||
template_id char(8) NOT NULL,
|
||||
font integer,
|
||||
label varchar(100),
|
||||
x integer,
|
||||
y integer,
|
||||
width integer,
|
||||
height integer,
|
||||
color varchar(20)
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'kioskelements'::regclass AND contype = 'p'
|
||||
) THEN
|
||||
ALTER TABLE kioskelements ADD PRIMARY KEY (id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE kioskelements ALTER COLUMN template_id SET NOT NULL;
|
||||
|
||||
-- Auto-increment per kioskelements.id (l'app fa INSERT senza specificare id).
|
||||
CREATE SEQUENCE IF NOT EXISTS kioskelements_id_seq OWNED BY kioskelements.id;
|
||||
SELECT setval(
|
||||
'kioskelements_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM kioskelements), 0) + 1,
|
||||
false
|
||||
);
|
||||
ALTER TABLE kioskelements ALTER COLUMN id SET DEFAULT nextval('kioskelements_id_seq');
|
||||
|
||||
-- Foreign Key con CASCADE.
|
||||
ALTER TABLE kioskelements DROP CONSTRAINT IF EXISTS fk_kioskelements_template;
|
||||
ALTER TABLE kioskelements
|
||||
ADD CONSTRAINT fk_kioskelements_template
|
||||
FOREIGN KEY (template_id)
|
||||
REFERENCES kiosktemplates(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS kioskelements_template_idx
|
||||
ON kioskelements (template_id);
|
||||
124
api/src/migrations/008_rulesets.sql
Normal file
124
api/src/migrations/008_rulesets.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
-- ============================================================
|
||||
-- Rulesets schema (DB: rules)
|
||||
-- ------------------------------------------------------------
|
||||
-- Un ruleset e' una collezione versionata di "items" (paths
|
||||
-- Signal K per i logs, codici openmeteo per i forecasts, ...).
|
||||
-- Modello:
|
||||
-- * 5 tipi fissi: logs | forecast_current | forecast_hourly
|
||||
-- | marine_current | marine_hourly
|
||||
-- * Un solo ruleset puo' essere "active" per ciascun tipo.
|
||||
-- * Le versioni sono triple di interi 1..100 (major.build.patch).
|
||||
-- * Gli items sono JSONB per massima flessibilita'.
|
||||
-- * Ogni item ha un "ref" stabile scelto dall'utente: e' la
|
||||
-- chiave logica che garantisce continuita' su InfluxDB anche
|
||||
-- se il path del sensore cambia.
|
||||
-- * Le deployments tracciano quale ruleset-version e' stato
|
||||
-- pushato ad ogni sensore.
|
||||
-- ============================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- RULESETS
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS rulesets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type TEXT NOT NULL
|
||||
CHECK (type IN ('logs','forecast_current','forecast_hourly','marine_current','marine_hourly')),
|
||||
version_major SMALLINT NOT NULL DEFAULT 1 CHECK (version_major BETWEEN 1 AND 100),
|
||||
version_build SMALLINT NOT NULL DEFAULT 0 CHECK (version_build BETWEEN 0 AND 100),
|
||||
version_patch SMALLINT NOT NULL DEFAULT 0 CHECK (version_patch BETWEEN 0 AND 100),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
-- items: [{ ref, path, enabled, meta: {...} }, ...]
|
||||
-- ref: identificatore logico stabile (chiave su Influx)
|
||||
-- path: SK path (logs) | codice openmeteo (forecast/marine)
|
||||
-- meta: { name, unit, measurement, sk_path, group_name, category, ... }
|
||||
items JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
active BOOLEAN NOT NULL DEFAULT false,
|
||||
archived BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (type, version_major, version_build, version_patch)
|
||||
);
|
||||
|
||||
-- solo UN ruleset active per tipo (archiviati esclusi)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS rulesets_one_active_per_type
|
||||
ON rulesets (type)
|
||||
WHERE active = true AND archived = false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS rulesets_type_idx ON rulesets (type);
|
||||
CREATE INDEX IF NOT EXISTS rulesets_active_idx ON rulesets (type) WHERE active = true;
|
||||
CREATE INDEX IF NOT EXISTS rulesets_archived_idx ON rulesets (archived);
|
||||
CREATE INDEX IF NOT EXISTS rulesets_items_gin_idx ON rulesets USING GIN (items);
|
||||
|
||||
-- Validazione items: array di oggetti con almeno ref+path
|
||||
CREATE OR REPLACE FUNCTION rulesets_validate_items() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
refs TEXT[];
|
||||
BEGIN
|
||||
IF jsonb_typeof(NEW.items) <> 'array' THEN
|
||||
RAISE EXCEPTION 'items must be a JSON array';
|
||||
END IF;
|
||||
|
||||
-- tutti gli item devono avere ref non vuoto e path (anche vuoto ammesso)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(NEW.items) it
|
||||
WHERE jsonb_typeof(it) <> 'object'
|
||||
OR NULLIF(it->>'ref','') IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'every item must be an object with a non-empty "ref"';
|
||||
END IF;
|
||||
|
||||
-- unicita' ref all'interno dello stesso ruleset
|
||||
SELECT array_agg(it->>'ref') INTO refs
|
||||
FROM jsonb_array_elements(NEW.items) it;
|
||||
IF (SELECT count(DISTINCT x) FROM unnest(refs) x) <> COALESCE(array_length(refs,1),0) THEN
|
||||
RAISE EXCEPTION 'item refs must be unique within the ruleset';
|
||||
END IF;
|
||||
|
||||
NEW.updated_at := NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS rulesets_validate_trigger ON rulesets;
|
||||
CREATE TRIGGER rulesets_validate_trigger
|
||||
BEFORE INSERT OR UPDATE ON rulesets
|
||||
FOR EACH ROW EXECUTE FUNCTION rulesets_validate_items();
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- DEPLOYMENTS
|
||||
-- Traccia quale ruleset-version e' stato pushato ad ogni
|
||||
-- sensore (per tipo). Un solo ruleset per (sensor,type).
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS ruleset_deployments (
|
||||
sensor_name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
ruleset_id UUID NOT NULL REFERENCES rulesets(id) ON DELETE CASCADE,
|
||||
deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
acked_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (sensor_name, type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ruleset_deployments_ruleset_idx
|
||||
ON ruleset_deployments (ruleset_id);
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
-- AUDIT LOG (opzionale ma utile)
|
||||
-- ─────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS ruleset_changes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ruleset_id UUID,
|
||||
type TEXT,
|
||||
action TEXT NOT NULL, -- created | updated | activated | archived | deleted | deployed
|
||||
user_id TEXT,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ruleset_changes_ruleset_idx ON ruleset_changes (ruleset_id);
|
||||
CREATE INDEX IF NOT EXISTS ruleset_changes_created_idx ON ruleset_changes (created_at DESC);
|
||||
80
api/src/routes/docs.js
Normal file
80
api/src/routes/docs.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Gestione file Markdown su MinIO nel bucket "documentation".
|
||||
*
|
||||
* GET /docs → lista file (name, size, lastModified)
|
||||
* GET /docs/:name → contenuto markdown raw
|
||||
* POST /docs → crea nuovo documento body {name, content}
|
||||
* PUT /docs/:name → sovrascrive contenuto body {content}
|
||||
* DELETE /docs/:name → elimina
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { listObjects, readText, writeText, removeObject } = require('../storage/minio');
|
||||
|
||||
const router = express.Router();
|
||||
const BUCKET = 'documentation';
|
||||
|
||||
const sanitize = (name) => {
|
||||
// Solo caratteri safe per oggetti MinIO; forza estensione .md
|
||||
const clean = String(name || '').trim().replace(/[^a-zA-Z0-9 _\-./]/g, '').replace(/\.+/g, '.');
|
||||
if (!clean) return null;
|
||||
return clean.endsWith('.md') ? clean : `${clean}.md`;
|
||||
};
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const files = await listObjects(BUCKET);
|
||||
res.json(files.filter(f => f.name.endsWith('.md')));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:name', async (req, res) => {
|
||||
const name = sanitize(req.params.name);
|
||||
if (!name) return res.status(400).json({ error: 'invalid name' });
|
||||
try {
|
||||
const content = await readText(BUCKET, name);
|
||||
res.type('text/markdown').send(content);
|
||||
} catch (e) {
|
||||
if (e.code === 'NoSuchKey') return res.status(404).json({ error: 'not found' });
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const name = sanitize(req.body?.name);
|
||||
const content = req.body?.content ?? '';
|
||||
if (!name) return res.status(400).json({ error: 'name required' });
|
||||
try {
|
||||
const r = await writeText(BUCKET, name, content);
|
||||
res.status(201).json(r);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:name', async (req, res) => {
|
||||
const name = sanitize(req.params.name);
|
||||
if (!name) return res.status(400).json({ error: 'invalid name' });
|
||||
const content = req.body?.content ?? '';
|
||||
try {
|
||||
const r = await writeText(BUCKET, name, content);
|
||||
res.json(r);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:name', async (req, res) => {
|
||||
const name = sanitize(req.params.name);
|
||||
if (!name) return res.status(400).json({ error: 'invalid name' });
|
||||
try {
|
||||
await removeObject(BUCKET, name);
|
||||
res.status(204).end();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
120
api/src/routes/jobs.js
Normal file
120
api/src/routes/jobs.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* /jobs — ciclo di vita dei job asincroni (es. training).
|
||||
*
|
||||
* Tabella: jobs (db "ml") — vedi migrations/006_jobs.sql
|
||||
*/
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { query } = require('../storage/postgres');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// In assenza dei trigger/funzioni DB (`gen_random_uuid` default,
|
||||
// `set_updated_at` trigger, `jobs_status_ok` CHECK) gestiamo tutto qui.
|
||||
const VALID_STATUSES = ['queued', 'running', 'succeeded', 'failed', 'cancelled'];
|
||||
function genUUID() { return crypto.randomUUID(); }
|
||||
|
||||
function rowToJob(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
status: r.status,
|
||||
payload: r.payload,
|
||||
result: r.result,
|
||||
error: r.error,
|
||||
created_by: r.created_by,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
started_at: r.started_at,
|
||||
finished_at: r.finished_at,
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { type, created_by, payload } = req.body || {};
|
||||
if (!type) return res.status(400).json({ error: 'type required' });
|
||||
const newId = genUUID();
|
||||
const r = await query(
|
||||
`INSERT INTO jobs (id, type, created_by, payload, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'queued', NOW(), NOW()) RETURNING *`,
|
||||
[newId, type, created_by || req.user?.username || 'unknown', JSON.stringify(payload || {})],
|
||||
'ml'
|
||||
);
|
||||
res.status(201).json(rowToJob(r.rows[0]));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const filters = [];
|
||||
const params = [];
|
||||
if (req.query.type) { params.push(req.query.type); filters.push(`type = $${params.length}`); }
|
||||
if (req.query.status) { params.push(req.query.status); filters.push(`status = $${params.length}`); }
|
||||
if (req.query.user === 'me' && req.user?.username) {
|
||||
params.push(req.user.username);
|
||||
filters.push(`created_by = $${params.length}`);
|
||||
}
|
||||
const limit = Math.min(parseInt(req.query.limit || '100', 10), 500);
|
||||
params.push(limit);
|
||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
const r = await query(
|
||||
`SELECT * FROM jobs ${where} ORDER BY created_at DESC LIMIT $${params.length}`,
|
||||
params, 'ml'
|
||||
);
|
||||
res.json(r.rows.map(rowToJob));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const r = await query('SELECT * FROM jobs WHERE id = $1', [req.params.id], 'ml');
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(rowToJob(r.rows[0]));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
const allowed = ['status', 'payload', 'result', 'error', 'started_at', 'finished_at'];
|
||||
const sets = [];
|
||||
const params = [];
|
||||
|
||||
// CHECK status sostituito da whitelist applicativa
|
||||
if ('status' in req.body && !VALID_STATUSES.includes(req.body.status)) {
|
||||
return res.status(400).json({
|
||||
error: `invalid status, must be one of: ${VALID_STATUSES.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
for (const k of allowed) {
|
||||
if (k in req.body) {
|
||||
params.push(k === 'payload' || k === 'result' ? JSON.stringify(req.body[k]) : req.body[k]);
|
||||
const cast = (k === 'payload' || k === 'result') ? '::jsonb' : '';
|
||||
sets.push(`${k} = $${params.length}${cast}`);
|
||||
}
|
||||
}
|
||||
if (!sets.length) return res.status(400).json({ error: 'no fields' });
|
||||
|
||||
// Trigger trg_jobs_updated_at non presente: lo facciamo manualmente.
|
||||
sets.push('updated_at = NOW()');
|
||||
|
||||
params.push(req.params.id);
|
||||
const r = await query(
|
||||
`UPDATE jobs SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||
params, 'ml'
|
||||
);
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(rowToJob(r.rows[0]));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
443
api/src/routes/kiosk.js
Normal file
443
api/src/routes/kiosk.js
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Kiosk templates API
|
||||
* Base: /kiosk
|
||||
*
|
||||
* Schema reale (DB `sensors`):
|
||||
* kiosktemplates(id char(8) PK, name varchar(50) NOT NULL,
|
||||
* tags text[], active bool, archived bool,
|
||||
* created_at timestamp, updated_at timestamp)
|
||||
* kioskelements(id bigint PK auto-seq, template_id char(8) NOT NULL FK CASCADE,
|
||||
* font, label varchar(100), x, y, width, height, color varchar(20))
|
||||
*
|
||||
* NOTA: il DB contiene SOLO i constraint strutturali (PK, FK, NOT NULL).
|
||||
* Tutta la logica (id char(8), updated_at, "un solo active per tabella",
|
||||
* validazione, CHECK su x/y/w/h, prevenzione active+archived) e' gestita qui.
|
||||
*
|
||||
* Le route restituiscono il template arricchito con `elements`:
|
||||
* { id, name, tags, active, archived, created_at, updated_at, elements: [...] }
|
||||
*/
|
||||
|
||||
const router = require('express').Router();
|
||||
const crypto = require('crypto');
|
||||
const { query, getClient } = require('../storage/postgres');
|
||||
|
||||
const DB = 'kiosk'; // pool puntato al DB `sensors` (vedi postgres.js)
|
||||
|
||||
// Sostituisce il default DB `gen_short_id8()` (funzione SQL non presente nel DB).
|
||||
function genShortId8() {
|
||||
return crypto.randomBytes(4).toString('hex'); // 8 char hex [0-9a-f]
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const ELEMENT_FIELDS = ['font', 'label', 'x', 'y', 'width', 'height', 'color'];
|
||||
|
||||
function sanitizeElement(e) {
|
||||
const out = {};
|
||||
if (e.font !== undefined) out.font = parseInt(e.font, 10);
|
||||
if (e.label !== undefined) out.label = String(e.label).slice(0, 100);
|
||||
if (e.x !== undefined) out.x = parseInt(e.x, 10);
|
||||
if (e.y !== undefined) out.y = parseInt(e.y, 10);
|
||||
if (e.width !== undefined) out.width = parseInt(e.width, 10);
|
||||
if (e.height !== undefined) out.height = parseInt(e.height, 10);
|
||||
if (e.color !== undefined) out.color = String(e.color).slice(0, 20);
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateElementForInsert(e) {
|
||||
const errs = [];
|
||||
if (typeof e.x !== 'number' || e.x < 0) errs.push('x must be >= 0');
|
||||
if (typeof e.y !== 'number' || e.y < 0) errs.push('y must be >= 0');
|
||||
if (typeof e.width !== 'number' || e.width <= 0) errs.push('width must be > 0');
|
||||
if (typeof e.height !== 'number' || e.height <= 0) errs.push('height must be > 0');
|
||||
return errs.length ? errs.join(', ') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserisce N elementi per un template in batch.
|
||||
* Usa il client passato (per transazione).
|
||||
*/
|
||||
async function insertElements(client, templateId, elements) {
|
||||
if (!Array.isArray(elements) || !elements.length) return [];
|
||||
const rows = [];
|
||||
for (const raw of elements) {
|
||||
const e = sanitizeElement(raw);
|
||||
const err = validateElementForInsert({ x:0, y:0, width:1, height:1, ...e });
|
||||
if (err) throw new Error(`element invalid: ${err}`);
|
||||
const r = await client.query(
|
||||
`INSERT INTO kioskelements (template_id, font, label, x, y, width, height, color)
|
||||
VALUES ($1, COALESCE($2,16), COALESCE($3,''), COALESCE($4,0), COALESCE($5,0),
|
||||
COALESCE($6,1), COALESCE($7,1), COALESCE($8,'#1e293b'))
|
||||
RETURNING *`,
|
||||
[templateId, e.font, e.label, e.x, e.y, e.width, e.height, e.color]
|
||||
);
|
||||
rows.push(r.rows[0]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ritorna un template con la lista elements aggregata.
|
||||
*/
|
||||
async function fetchTemplateWithElements(idOrCondition, value) {
|
||||
const where = idOrCondition === 'active'
|
||||
? 't.active = true AND t.archived = false'
|
||||
: 't.id = $1';
|
||||
const params = idOrCondition === 'active' ? [] : [value];
|
||||
|
||||
const sql = `
|
||||
SELECT t.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(e.* ORDER BY e.id)
|
||||
FROM kioskelements e WHERE e.template_id = t.id),
|
||||
'[]'::json
|
||||
) AS elements
|
||||
FROM kiosktemplates t
|
||||
WHERE ${where}
|
||||
LIMIT 1`;
|
||||
const r = await query(sql, params, DB);
|
||||
return r.rows[0] || null;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// READS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// GET /kiosk/template/active → template attivo (con elements)
|
||||
router.get('/template/active', async (req, res) => {
|
||||
try {
|
||||
const tpl = await fetchTemplateWithElements('active');
|
||||
if (!tpl) return res.status(404).json({ error: 'no active template' });
|
||||
res.json(tpl);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] active error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /kiosk/templates → lista (senza elements)
|
||||
router.get('/templates', async (req, res) => {
|
||||
try {
|
||||
const r = await query(
|
||||
`SELECT t.id, t.name, t.tags, t.active, t.archived,
|
||||
t.created_at, t.updated_at,
|
||||
(SELECT COUNT(*)::int FROM kioskelements e WHERE e.template_id = t.id) AS elements_count
|
||||
FROM kiosktemplates t
|
||||
ORDER BY t.updated_at DESC`,
|
||||
[], DB
|
||||
);
|
||||
res.json(r.rows);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] list error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /kiosk/templates/:id → dettaglio con elements
|
||||
router.get('/templates/:id', async (req, res) => {
|
||||
try {
|
||||
const tpl = await fetchTemplateWithElements('id', req.params.id);
|
||||
if (!tpl) return res.status(404).json({ error: 'not found' });
|
||||
res.json(tpl);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] get error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// WRITES
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// POST /kiosk/templates → crea template + elements (transazionale)
|
||||
router.post('/templates', async (req, res) => {
|
||||
const { name, tags, elements } = req.body || {};
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({ error: 'name required' });
|
||||
}
|
||||
const tagsArr = Array.isArray(tags) ? tags.map(String) : [];
|
||||
const els = Array.isArray(elements) ? elements : [];
|
||||
|
||||
const client = await getClient(DB);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Genera id in app (no default DB). Retry se collisione (estremamente rara).
|
||||
let tpl = null;
|
||||
for (let attempt = 0; attempt < 5 && !tpl; attempt++) {
|
||||
const id = genShortId8();
|
||||
try {
|
||||
const t = await client.query(
|
||||
`INSERT INTO kiosktemplates (id, name, tags, active, archived, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, false, false, NOW(), NOW()) RETURNING *`,
|
||||
[id, name.slice(0, 50), tagsArr]
|
||||
);
|
||||
tpl = t.rows[0];
|
||||
} catch (e) {
|
||||
if (e.code !== '23505') throw e; // PK conflict → retry
|
||||
}
|
||||
}
|
||||
if (!tpl) throw new Error('id generation failed');
|
||||
|
||||
const insertedEls = await insertElements(client, tpl.id, els);
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ ...tpl, elements: insertedEls });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
console.error('[KIOSK] create error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /kiosk/templates/:id → patch metadata. Se `elements` viene passato,
|
||||
// sostituisce TUTTI gli elements (delete + insert in transazione).
|
||||
router.put('/templates/:id', async (req, res) => {
|
||||
const { name, tags, elements } = req.body || {};
|
||||
const fields = [], values = [];
|
||||
let i = 1;
|
||||
|
||||
if (name !== undefined) { fields.push(`name = $${i++}`); values.push(String(name).slice(0, 50)); }
|
||||
if (tags !== undefined) {
|
||||
if (!Array.isArray(tags)) return res.status(400).json({ error: 'tags must be array' });
|
||||
fields.push(`tags = $${i++}`); values.push(tags.map(String));
|
||||
}
|
||||
|
||||
if (!fields.length && elements === undefined) {
|
||||
return res.status(400).json({ error: 'no fields' });
|
||||
}
|
||||
|
||||
const client = await getClient(DB);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
let tpl;
|
||||
if (fields.length) {
|
||||
// Trigger set_updated_at non presente: lo facciamo manualmente.
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(req.params.id);
|
||||
const r = await client.query(
|
||||
`UPDATE kiosktemplates SET ${fields.join(', ')}
|
||||
WHERE id = $${i} RETURNING *`,
|
||||
values
|
||||
);
|
||||
if (!r.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
tpl = r.rows[0];
|
||||
} else {
|
||||
const r = await client.query(`SELECT * FROM kiosktemplates WHERE id = $1`, [req.params.id]);
|
||||
if (!r.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
tpl = r.rows[0];
|
||||
}
|
||||
|
||||
let elsRows;
|
||||
if (Array.isArray(elements)) {
|
||||
await client.query(`DELETE FROM kioskelements WHERE template_id = $1`, [tpl.id]);
|
||||
elsRows = await insertElements(client, tpl.id, elements);
|
||||
} else {
|
||||
const r = await client.query(
|
||||
`SELECT * FROM kioskelements WHERE template_id = $1 ORDER BY id`,
|
||||
[tpl.id]
|
||||
);
|
||||
elsRows = r.rows;
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ ...tpl, elements: elsRows });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
console.error('[KIOSK] update error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /kiosk/templates/:id/activate → attiva (disattiva tutti gli altri)
|
||||
router.post('/templates/:id/activate', async (req, res) => {
|
||||
const client = await getClient(DB);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// pre-check: archived non puo' diventare active
|
||||
const cur = await client.query(
|
||||
`SELECT archived FROM kiosktemplates WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!cur.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
if (cur.rows[0].archived) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'cannot activate archived template' });
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE kiosktemplates SET active = false, updated_at = NOW()
|
||||
WHERE active = true AND id <> $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
const r = await client.query(
|
||||
`UPDATE kiosktemplates SET active = true, updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[req.params.id]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
|
||||
const tpl = r.rows[0];
|
||||
|
||||
// notifica realtime (best-effort)
|
||||
const RT = process.env.REALTIME_URL || 'http://meb-realtime:3000';
|
||||
const KEY = process.env.INTERNAL_API_KEY;
|
||||
if (KEY) {
|
||||
fetch(`${RT}/kiosk/notify-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
|
||||
body: JSON.stringify({ template: tpl })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
res.json(tpl);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
console.error('[KIOSK] activate error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /kiosk/templates/:id/archive → toggle archived (e disattiva)
|
||||
router.patch('/templates/:id/archive', async (req, res) => {
|
||||
try {
|
||||
const cur = await query(
|
||||
`SELECT archived FROM kiosktemplates WHERE id = $1`,
|
||||
[req.params.id], DB
|
||||
);
|
||||
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const willArchive = !cur.rows[0].archived;
|
||||
const r = await query(
|
||||
`UPDATE kiosktemplates
|
||||
SET archived = $1,
|
||||
active = CASE WHEN $1 = true THEN false ELSE active END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 RETURNING *`,
|
||||
[willArchive, req.params.id], DB
|
||||
);
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] archive error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /kiosk/templates/:id
|
||||
// FK CASCADE non e' garantita lato DB: cancelliamo manualmente in transazione
|
||||
// prima gli elements e poi il template.
|
||||
router.delete('/templates/:id', async (req, res) => {
|
||||
const client = await getClient(DB);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const check = await client.query(
|
||||
`SELECT active FROM kiosktemplates WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!check.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
if (check.rows[0].active) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'cannot delete active template' });
|
||||
}
|
||||
await client.query(`DELETE FROM kioskelements WHERE template_id = $1`, [req.params.id]);
|
||||
await client.query(`DELETE FROM kiosktemplates WHERE id = $1`, [req.params.id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ deleted: true });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
console.error('[KIOSK] delete error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// ELEMENTS — CRUD granulare (utile per editor live)
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// POST /kiosk/templates/:id/elements → aggiunge un singolo element
|
||||
router.post('/templates/:id/elements', async (req, res) => {
|
||||
const client = await getClient(DB);
|
||||
try {
|
||||
const tpl = await client.query(
|
||||
`SELECT id FROM kiosktemplates WHERE id = $1`, [req.params.id]
|
||||
);
|
||||
if (!tpl.rows[0]) return res.status(404).json({ error: 'template not found' });
|
||||
|
||||
const [created] = await insertElements(client, req.params.id, [req.body || {}]);
|
||||
// bumpa updated_at del template
|
||||
await client.query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id]);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] add element error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /kiosk/templates/:id/elements/:elementId → patch element
|
||||
router.put('/templates/:id/elements/:elementId', async (req, res) => {
|
||||
const e = sanitizeElement(req.body || {});
|
||||
const fields = [], values = [];
|
||||
let i = 1;
|
||||
for (const k of ELEMENT_FIELDS) {
|
||||
if (e[k] !== undefined) { fields.push(`${k} = $${i++}`); values.push(e[k]); }
|
||||
}
|
||||
if (!fields.length) return res.status(400).json({ error: 'no fields' });
|
||||
|
||||
values.push(req.params.elementId, req.params.id);
|
||||
try {
|
||||
const r = await query(
|
||||
`UPDATE kioskelements SET ${fields.join(', ')}
|
||||
WHERE id = $${i++} AND template_id = $${i} RETURNING *`,
|
||||
values, DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
await query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id], DB);
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] update element error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /kiosk/templates/:id/elements/:elementId
|
||||
router.delete('/templates/:id/elements/:elementId', async (req, res) => {
|
||||
try {
|
||||
const r = await query(
|
||||
`DELETE FROM kioskelements
|
||||
WHERE id = $1 AND template_id = $2 RETURNING id`,
|
||||
[req.params.elementId, req.params.id], DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
await query(`UPDATE kiosktemplates SET updated_at = NOW() WHERE id = $1`, [req.params.id], DB);
|
||||
res.json({ deleted: true });
|
||||
} catch (err) {
|
||||
console.error('[KIOSK] delete element error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
49
api/src/routes/kiosk.public.js
Normal file
49
api/src/routes/kiosk.public.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const router = require('express').Router();
|
||||
const { query } = require('../storage/postgres');
|
||||
|
||||
// Endpoint pubblici (usati dal plugin kiosk sulla barca). Solo read.
|
||||
// Restituisce template + array `elements`.
|
||||
|
||||
const DB = 'kiosk';
|
||||
|
||||
const SELECT_WITH_ELEMENTS = `
|
||||
SELECT t.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(e.* ORDER BY e.id)
|
||||
FROM kioskelements e WHERE e.template_id = t.id),
|
||||
'[]'::json
|
||||
) AS elements
|
||||
FROM kiosktemplates t
|
||||
`;
|
||||
|
||||
router.get('/template/active', async (req, res) => {
|
||||
try {
|
||||
const r = await query(
|
||||
`${SELECT_WITH_ELEMENTS}
|
||||
WHERE t.active = true AND t.archived = false
|
||||
LIMIT 1`,
|
||||
[], DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'no active template' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK/PUB] active error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/templates/:id', async (req, res) => {
|
||||
try {
|
||||
const r = await query(
|
||||
`${SELECT_WITH_ELEMENTS} WHERE t.id = $1 LIMIT 1`,
|
||||
[req.params.id], DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK/PUB] get error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
67
api/src/routes/kiosk.sensor.js
Normal file
67
api/src/routes/kiosk.sensor.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const router = require('express').Router();
|
||||
const crypto = require('crypto');
|
||||
const { query } = require('../storage/postgres');
|
||||
|
||||
const DB = 'kiosk';
|
||||
|
||||
function hash(code) { return crypto.createHash('sha256').update(code).digest('hex'); }
|
||||
|
||||
async function authSensor(req, res, next) {
|
||||
try {
|
||||
const r = await query(
|
||||
'SELECT id, active FROM sensors WHERE code_hash = $1',
|
||||
[hash(req.params.sensorCode)], 'sensors'
|
||||
);
|
||||
if (!r.rows[0]) return res.status(401).json({ error: 'invalid sensor code' });
|
||||
if (!r.rows[0].active) return res.status(403).json({ error: 'sensor inactive' });
|
||||
req.sensorId = r.rows[0].id;
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[KIOSK/SENSOR] auth error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT_WITH_ELEMENTS = `
|
||||
SELECT t.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(e.* ORDER BY e.id)
|
||||
FROM kioskelements e WHERE e.template_id = t.id),
|
||||
'[]'::json
|
||||
) AS elements
|
||||
FROM kiosktemplates t
|
||||
`;
|
||||
|
||||
// GET /kiosk/sensor/:sensorCode/template/active
|
||||
router.get('/:sensorCode/template/active', authSensor, async (req, res) => {
|
||||
try {
|
||||
const r = await query(
|
||||
`${SELECT_WITH_ELEMENTS}
|
||||
WHERE t.active = true AND t.archived = false
|
||||
LIMIT 1`,
|
||||
[], DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'no active template' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK/SENSOR] active error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /kiosk/sensor/:sensorCode/templates/:id
|
||||
router.get('/:sensorCode/templates/:id', authSensor, async (req, res) => {
|
||||
try {
|
||||
const r = await query(
|
||||
`${SELECT_WITH_ELEMENTS} WHERE t.id = $1 LIMIT 1`,
|
||||
[req.params.id], DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('[KIOSK/SENSOR] get error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
232
api/src/routes/marine.datasets.js
Normal file
232
api/src/routes/marine.datasets.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* /marine/datasets — CRUD sulla tabella `datasets` del database `ml`.
|
||||
*
|
||||
* Layout:
|
||||
* POST /marine/datasets/upload (multipart: file + metadata JSON)
|
||||
* GET /marine/datasets (query: ?tags=a,b&type=copernicus&mine=1)
|
||||
* GET /marine/datasets/:id (metadata)
|
||||
* GET /marine/datasets/:id/download (presigned URL 1h)
|
||||
* GET /marine/datasets/:id/raw (stream diretto)
|
||||
* PATCH /marine/datasets/:id (aggiorna nome/tags/notes)
|
||||
* DELETE /marine/datasets/:id (rimuove da MinIO + DB)
|
||||
*
|
||||
* I file vivono SEMPRE nel bucket MinIO "ml.datasets". La colonna `file_key` salva
|
||||
* il nome dell'oggetto (basta `${uuid}.${ext}`, senza prefissi).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
const { query } = require('../storage/postgres');
|
||||
const { bucketExists, upload, download, removeObject, getFileStream } = require('../storage/minio');
|
||||
|
||||
const router = express.Router();
|
||||
const upload_mw = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 } });
|
||||
|
||||
// Bucket MinIO fisso per tutti i dataset.
|
||||
const BUCKET = 'ml.datasets';
|
||||
|
||||
const parseTags = (s) => (s ? String(s).split(',').map(x => x.trim()).filter(Boolean) : []);
|
||||
|
||||
function rowToDataset(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
file_key: r.file_key,
|
||||
nome: r.nome,
|
||||
description: r.description,
|
||||
tags: r.tags,
|
||||
type: r.type,
|
||||
format: r.format,
|
||||
notes: r.notes,
|
||||
created_by: r.created_by,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
size_bytes: Number(r.size_bytes),
|
||||
row_count: r.row_count != null ? Number(r.row_count) : null,
|
||||
columns: r.columns,
|
||||
copernicus_id: r.copernicus_id,
|
||||
variables: r.variables,
|
||||
variable_renames: r.variable_renames,
|
||||
bbox: r.bbox,
|
||||
start_date: r.start_date,
|
||||
end_date: r.end_date,
|
||||
params: r.params,
|
||||
version: r.version,
|
||||
};
|
||||
}
|
||||
|
||||
// ── LIST ─────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const filters = [];
|
||||
const params = [];
|
||||
if (req.query.type) {
|
||||
params.push(req.query.type);
|
||||
filters.push(`type = $${params.length}`);
|
||||
}
|
||||
if (req.query.tags) {
|
||||
const tags = parseTags(req.query.tags);
|
||||
if (tags.length) {
|
||||
params.push(tags);
|
||||
filters.push(`tags && $${params.length}`);
|
||||
}
|
||||
}
|
||||
if (req.query.mine === '1' && req.user?.username) {
|
||||
params.push(req.user.username);
|
||||
filters.push(`created_by = $${params.length}`);
|
||||
}
|
||||
if (req.query.search) {
|
||||
params.push(`%${req.query.search}%`);
|
||||
filters.push(`(nome ILIKE $${params.length} OR description ILIKE $${params.length})`);
|
||||
}
|
||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
const r = await query(
|
||||
`SELECT * FROM datasets ${where} ORDER BY created_at DESC LIMIT 500`,
|
||||
params, 'ml'
|
||||
);
|
||||
res.json({ count: r.rows.length, datasets: r.rows.map(rowToDataset) });
|
||||
} catch (e) {
|
||||
console.error('[marine/datasets list]', e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── UPLOAD (usato dal servizio copernicus) ───────────────────────────────
|
||||
router.post('/upload', upload_mw.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'file required (multipart field "file")' });
|
||||
|
||||
let meta = {};
|
||||
try { meta = JSON.parse(req.body.metadata || '{}'); } catch { /* ignore */ }
|
||||
|
||||
const fmt = (meta.type && ['csv', 'json', 'netcdf'].includes(meta.type)) ? meta.type : 'csv';
|
||||
const id = randomUUID();
|
||||
const ext = fmt === 'netcdf' ? 'nc' : fmt;
|
||||
const fileKey = `${id}.${ext}`;
|
||||
|
||||
await bucketExists(BUCKET);
|
||||
await upload(BUCKET, fileKey, req.file.buffer, req.file.size, req.file.mimetype || 'application/octet-stream');
|
||||
|
||||
const createdBy = req.user?.username || meta.created_by || 'unknown';
|
||||
const insert = await query(
|
||||
`INSERT INTO datasets (
|
||||
id, file_key, nome, description, tags, type, format, notes,
|
||||
created_by, size_bytes, copernicus_id, variables, variable_renames,
|
||||
bbox, start_date, end_date, params
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
fileKey,
|
||||
meta.nome || req.file.originalname || fileKey,
|
||||
meta.description || null,
|
||||
Array.isArray(meta.tags) ? meta.tags : [],
|
||||
'copernicus',
|
||||
fmt,
|
||||
meta.notes || null,
|
||||
createdBy,
|
||||
req.file.size,
|
||||
meta.copernicus_id || meta.copernicus_dataset_id || null,
|
||||
Array.isArray(meta.variables) ? meta.variables : null,
|
||||
meta.variable_renames ? JSON.stringify(meta.variable_renames) : null,
|
||||
meta.bbox ? JSON.stringify(meta.bbox) : null,
|
||||
meta.start_date || null,
|
||||
meta.end_date || null,
|
||||
JSON.stringify(meta.params || {}),
|
||||
],
|
||||
'ml'
|
||||
);
|
||||
res.status(201).json(rowToDataset(insert.rows[0]));
|
||||
} catch (e) {
|
||||
console.error('[marine/datasets upload]', e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DETAIL ───────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const r = await query(`SELECT * FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(rowToDataset(r.rows[0]));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DOWNLOAD presigned ───────────────────────────────────────────────────
|
||||
router.get('/:id/download', async (req, res) => {
|
||||
try {
|
||||
const r = await query(`SELECT file_key FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||
const { file_key } = r.rows[0];
|
||||
const url = await download(BUCKET, file_key, 3600);
|
||||
res.json({ url, expires_in: 3600 });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── STREAM raw (per download diretto dal browser) ────────────────────────
|
||||
router.get('/:id/raw', async (req, res) => {
|
||||
try {
|
||||
const r = await query(`SELECT file_key, nome, format FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||
const { file_key, nome, format } = r.rows[0];
|
||||
const mime = format === 'json' ? 'application/json' : format === 'csv' ? 'text/csv' : 'application/octet-stream';
|
||||
const ext = format === 'netcdf' ? 'nc' : format;
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(nome)}.${ext}"`);
|
||||
const stream = await getFileStream(BUCKET, file_key);
|
||||
stream.on('error', (err) => { console.error(err); res.end(); });
|
||||
stream.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── PATCH metadata ───────────────────────────────────────────────────────
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
const allowed = ['nome', 'description', 'tags', 'notes'];
|
||||
const sets = [];
|
||||
const params = [];
|
||||
for (const k of allowed) {
|
||||
if (k in req.body) {
|
||||
params.push(req.body[k]);
|
||||
sets.push(`${k} = $${params.length}`);
|
||||
}
|
||||
}
|
||||
if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
|
||||
|
||||
// Trigger updated_at non presente nel DB: lo aggiorniamo manualmente.
|
||||
sets.push('updated_at = NOW()');
|
||||
|
||||
params.push(req.params.id);
|
||||
const r = await query(
|
||||
`UPDATE datasets SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||
params, 'ml'
|
||||
);
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(rowToDataset(r.rows[0]));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE ───────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const r = await query(`SELECT file_key FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
|
||||
const { file_key } = r.rows[0];
|
||||
try { await removeObject(BUCKET, file_key); } catch (e) { console.warn('[minio remove]', e.message); }
|
||||
await query(`DELETE FROM datasets WHERE id = $1`, [req.params.id], 'ml');
|
||||
res.status(204).end();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
100
api/src/routes/pageconnections.js
Normal file
100
api/src/routes/pageconnections.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* /pageconnections — registro sessioni di pagina attive, con heartbeat.
|
||||
*
|
||||
* Storage Redis:
|
||||
* pageconn:{page} ZSET score=lastPing, member=session_id
|
||||
* pageconn:meta:{session_id} HASH {page, user_id, created_at}
|
||||
*
|
||||
* Limiti:
|
||||
* page = "test" → max 2 session_id attive (entro TTL heartbeat). Altrimenti 429.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const Redis = require('ioredis');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || 'meb-redis',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
});
|
||||
|
||||
const HEARTBEAT_TTL_SEC = 30;
|
||||
const LIMITS = { test: 2 };
|
||||
|
||||
function nowSec() { return Math.floor(Date.now() / 1000); }
|
||||
|
||||
async function activeMembers(page) {
|
||||
const min = nowSec() - HEARTBEAT_TTL_SEC;
|
||||
// rimuovi stale
|
||||
await redis.zremrangebyscore(`pageconn:${page}`, '-inf', `(${min}`);
|
||||
return redis.zrange(`pageconn:${page}`, 0, -1);
|
||||
}
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { page, session_id, user_id } = req.body || {};
|
||||
if (!page || !session_id) return res.status(400).json({ error: 'page and session_id required' });
|
||||
|
||||
const active = await activeMembers(page);
|
||||
const limit = LIMITS[page];
|
||||
if (limit && !active.includes(session_id) && active.length >= limit) {
|
||||
return res.status(429).json({ error: 'slot full', active: active.length, limit });
|
||||
}
|
||||
const ts = nowSec();
|
||||
await redis.zadd(`pageconn:${page}`, ts, session_id);
|
||||
await redis.hset(`pageconn:meta:${session_id}`, {
|
||||
page,
|
||||
user_id: user_id || req.user?.username || 'unknown',
|
||||
created_at: String(ts),
|
||||
});
|
||||
await redis.expire(`pageconn:meta:${session_id}`, HEARTBEAT_TTL_SEC * 4);
|
||||
res.status(201).json({ session_id, page, active: (await activeMembers(page)).length, limit: limit || null });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:sid/ping', async (req, res) => {
|
||||
try {
|
||||
const sid = req.params.sid;
|
||||
const meta = await redis.hgetall(`pageconn:meta:${sid}`);
|
||||
if (!meta || !meta.page) return res.status(404).json({ error: 'session not found' });
|
||||
const ts = nowSec();
|
||||
await redis.zadd(`pageconn:${meta.page}`, ts, sid);
|
||||
await redis.expire(`pageconn:meta:${sid}`, HEARTBEAT_TTL_SEC * 4);
|
||||
res.json({ ok: true, ts });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:sid', async (req, res) => {
|
||||
try {
|
||||
const sid = req.params.sid;
|
||||
const meta = await redis.hgetall(`pageconn:meta:${sid}`);
|
||||
if (meta && meta.page) {
|
||||
await redis.zrem(`pageconn:${meta.page}`, sid);
|
||||
}
|
||||
await redis.del(`pageconn:meta:${sid}`);
|
||||
res.status(204).end();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:page', async (req, res) => {
|
||||
try {
|
||||
const members = await activeMembers(req.params.page);
|
||||
res.json({
|
||||
page: req.params.page,
|
||||
active: members.length,
|
||||
limit: LIMITS[req.params.page] || null,
|
||||
sessions: members,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
35
api/src/routes/queue.js
Normal file
35
api/src/routes/queue.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* /queue — stato delle code (solo read). La coda vera è gestita in Redis
|
||||
* dai servizi esecutori (ml-service per `train`). Qui aggreghiamo lo stato
|
||||
* leggendo la tabella `jobs`.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { query } = require('../storage/postgres');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const type = req.query.type || 'train';
|
||||
const r = await query(
|
||||
`SELECT id, type, status, created_by, created_at, started_at
|
||||
FROM jobs
|
||||
WHERE type = $1 AND status IN ('queued','running')
|
||||
ORDER BY created_at ASC`,
|
||||
[type], 'ml'
|
||||
);
|
||||
const queued = r.rows.filter(x => x.status === 'queued');
|
||||
const running = r.rows.filter(x => x.status === 'running');
|
||||
res.json({
|
||||
type,
|
||||
queued_count: queued.length,
|
||||
running_count: running.length,
|
||||
queued,
|
||||
running,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
609
api/src/routes/rules.js
Normal file
609
api/src/routes/rules.js
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Rulesets API
|
||||
* Base: /rules
|
||||
*
|
||||
* Tipi supportati: logs | forecast_current | forecast_hourly | marine_current | marine_hourly
|
||||
*
|
||||
* Un ruleset ha:
|
||||
* - version {major, build, patch} (interi 1..100, unici per tipo)
|
||||
* - description, tags[]
|
||||
* - items JSONB: array di { ref, path, enabled, meta }
|
||||
* ref: identificatore STABILE scelto dall'utente (chiave logica, usata come tag Influx)
|
||||
* path: SK path (logs) o codice openmeteo (forecast/marine)
|
||||
* meta: libero (unit, measurement, sk_path, name, group_name, category, ...)
|
||||
* - active (un solo attivo per tipo), archived
|
||||
*
|
||||
* Deploy: POST /rules/:type/:id/deploy { sensors: [name,...] }
|
||||
* -> salva in ruleset_deployments
|
||||
* -> notifica il servizio realtime (HTTP interno) che fara' il push WS al plugin
|
||||
*/
|
||||
|
||||
const router = require('express').Router();
|
||||
const crypto = require('crypto');
|
||||
const { query, getClient } = require('../storage/postgres');
|
||||
|
||||
const DB = 'rules';
|
||||
const VALID_TYPES = ['logs', 'forecast_current', 'forecast_hourly', 'marine_current', 'marine_hourly'];
|
||||
|
||||
// Sostituisce il default DB `gen_random_uuid()` (estensione pgcrypto non presente).
|
||||
function genUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function isValidType(t) { return VALID_TYPES.includes(t); }
|
||||
|
||||
function parseVersion(body) {
|
||||
// accetta sia { version_major, version_build, version_patch } che { version: "1.0.0" }
|
||||
let M = body?.version_major, B = body?.version_build, P = body?.version_patch;
|
||||
if (M === undefined && typeof body?.version === 'string') {
|
||||
const m = body.version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (m) { M = +m[1]; B = +m[2]; P = +m[3]; }
|
||||
}
|
||||
const toInt = v => (v === undefined || v === null || v === '') ? null : parseInt(v, 10);
|
||||
M = toInt(M); B = toInt(B); P = toInt(P);
|
||||
return { M, B, P };
|
||||
}
|
||||
|
||||
function validVersionPart(n, min = 0) {
|
||||
return Number.isInteger(n) && n >= min && n <= 100;
|
||||
}
|
||||
|
||||
function validateItems(items) {
|
||||
if (!Array.isArray(items)) return 'items must be an array';
|
||||
const refs = new Set();
|
||||
for (const it of items) {
|
||||
if (!it || typeof it !== 'object') return 'each item must be an object';
|
||||
if (!it.ref || typeof it.ref !== 'string') return 'each item needs a non-empty "ref"';
|
||||
if (refs.has(it.ref)) return `duplicate ref "${it.ref}"`;
|
||||
refs.add(it.ref);
|
||||
if (it.path !== undefined && typeof it.path !== 'string') return `item "${it.ref}" has invalid path`;
|
||||
if (it.enabled !== undefined && typeof it.enabled !== 'boolean') return `item "${it.ref}" enabled must be boolean`;
|
||||
if (it.meta !== undefined && (typeof it.meta !== 'object' || it.meta === null || Array.isArray(it.meta))) {
|
||||
return `item "${it.ref}" meta must be an object`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeItems(items) {
|
||||
return (items || []).map(it => ({
|
||||
ref: String(it.ref),
|
||||
path: it.path != null ? String(it.path) : '',
|
||||
enabled: it.enabled === undefined ? true : !!it.enabled,
|
||||
meta: it.meta && typeof it.meta === 'object' ? it.meta : {}
|
||||
}));
|
||||
}
|
||||
|
||||
function rowToRuleset(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
version: {
|
||||
major: row.version_major,
|
||||
build: row.version_build,
|
||||
patch: row.version_patch,
|
||||
str: `${row.version_major}.${row.version_build}.${row.version_patch}`
|
||||
},
|
||||
description: row.description,
|
||||
tags: row.tags || [],
|
||||
items: row.items || [],
|
||||
active: row.active,
|
||||
archived: row.archived,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
async function logChange(rulesetId, type, action, userId, payload) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO ruleset_changes (ruleset_id, type, action, user_id, payload)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[rulesetId, type, action, userId || null, payload ? JSON.stringify(payload) : null],
|
||||
DB
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[RULES] audit log error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SENSORS (deve stare PRIMA delle route /:type/:id per via del routing Express)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
// GET /rules/-/sensors → lista sensori disponibili (dal DB sensors)
|
||||
router.get('/-/sensors', async (req, res) => {
|
||||
try {
|
||||
const r = await query(`SELECT name, created_at FROM sensors ORDER BY name`, [], 'sensors');
|
||||
res.json(r.rows);
|
||||
} catch (err) {
|
||||
console.error('[RULES] sensors list error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
READS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
// GET /rules → { logs:[], forecast_current:[], ... } (lista completa, senza items per leggerezza)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const r = await query(
|
||||
`SELECT id, type, version_major, version_build, version_patch,
|
||||
description, tags, active, archived, created_at, updated_at,
|
||||
jsonb_array_length(items) AS items_count
|
||||
FROM rulesets
|
||||
ORDER BY type, version_major DESC, version_build DESC, version_patch DESC`,
|
||||
[], DB
|
||||
);
|
||||
const grouped = Object.fromEntries(VALID_TYPES.map(t => [t, []]));
|
||||
for (const row of r.rows) {
|
||||
grouped[row.type].push({
|
||||
...rowToRuleset(row),
|
||||
items: undefined,
|
||||
items_count: row.items_count
|
||||
});
|
||||
}
|
||||
res.json(grouped);
|
||||
} catch (err) {
|
||||
console.error('[RULES] list error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /rules/:type → lista versioni del tipo
|
||||
router.get('/:type', async (req, res) => {
|
||||
const { type } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const r = await query(
|
||||
`SELECT * FROM rulesets WHERE type = $1
|
||||
ORDER BY version_major DESC, version_build DESC, version_patch DESC`,
|
||||
[type], DB
|
||||
);
|
||||
res.json(r.rows.map(rowToRuleset));
|
||||
} catch (err) {
|
||||
console.error('[RULES] list type error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /rules/:type/:id
|
||||
router.get('/:type/:id', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const r = await query(`SELECT * FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
res.json(rowToRuleset(r.rows[0]));
|
||||
} catch (err) {
|
||||
console.error('[RULES] get error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /rules/:type/active → ruleset attivo
|
||||
router.get('/:type/-/active', async (req, res) => {
|
||||
const { type } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const r = await query(
|
||||
`SELECT * FROM rulesets WHERE type = $1 AND active = true AND archived = false LIMIT 1`,
|
||||
[type], DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'no active ruleset' });
|
||||
res.json(rowToRuleset(r.rows[0]));
|
||||
} catch (err) {
|
||||
console.error('[RULES] active error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
WRITES
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
// POST /rules/:type
|
||||
router.post('/:type', async (req, res) => {
|
||||
const { type } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
|
||||
let { M, B, P } = parseVersion(req.body);
|
||||
if (M === null) M = 1;
|
||||
if (B === null) B = 0;
|
||||
if (P === null) P = 0;
|
||||
if (!validVersionPart(M, 1) || !validVersionPart(B) || !validVersionPart(P)) {
|
||||
return res.status(400).json({ error: 'version parts must be integers (major 1..100, build/patch 0..100)' });
|
||||
}
|
||||
|
||||
const description = typeof req.body?.description === 'string' ? req.body.description : '';
|
||||
const tags = Array.isArray(req.body?.tags) ? req.body.tags.map(String) : [];
|
||||
const items = normalizeItems(req.body?.items);
|
||||
const itemsErr = validateItems(items);
|
||||
if (itemsErr) return res.status(400).json({ error: itemsErr });
|
||||
|
||||
try {
|
||||
const newId = genUUID();
|
||||
const r = await query(
|
||||
`INSERT INTO rulesets (id, type, version_major, version_build, version_patch,
|
||||
description, tags, items, active, archived,
|
||||
created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false,false,NOW(),NOW()) RETURNING *`,
|
||||
[newId, type, M, B, P, description, tags, JSON.stringify(items)],
|
||||
DB
|
||||
);
|
||||
const rs = rowToRuleset(r.rows[0]);
|
||||
logChange(rs.id, type, 'created', req.user?.user_id, { version: rs.version.str });
|
||||
res.status(201).json(rs);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'version already exists for this type' });
|
||||
}
|
||||
console.error('[RULES] create error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /rules/:type/:id → update campi (version, description, tags, items)
|
||||
router.put('/:type/:id', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let i = 1;
|
||||
|
||||
if (req.body?.description !== undefined) {
|
||||
fields.push(`description = $${i++}`); values.push(String(req.body.description));
|
||||
}
|
||||
if (req.body?.tags !== undefined) {
|
||||
if (!Array.isArray(req.body.tags)) return res.status(400).json({ error: 'tags must be array' });
|
||||
fields.push(`tags = $${i++}`); values.push(req.body.tags.map(String));
|
||||
}
|
||||
if (req.body?.items !== undefined) {
|
||||
const items = normalizeItems(req.body.items);
|
||||
const itemsErr = validateItems(items);
|
||||
if (itemsErr) return res.status(400).json({ error: itemsErr });
|
||||
fields.push(`items = $${i++}`); values.push(JSON.stringify(items));
|
||||
}
|
||||
if (req.body?.version_major !== undefined || req.body?.version_build !== undefined ||
|
||||
req.body?.version_patch !== undefined || req.body?.version !== undefined) {
|
||||
const { M, B, P } = parseVersion(req.body);
|
||||
if (!validVersionPart(M, 1) || !validVersionPart(B) || !validVersionPart(P)) {
|
||||
return res.status(400).json({ error: 'version parts must be integers (major 1..100, build/patch 0..100)' });
|
||||
}
|
||||
fields.push(`version_major = $${i++}`); values.push(M);
|
||||
fields.push(`version_build = $${i++}`); values.push(B);
|
||||
fields.push(`version_patch = $${i++}`); values.push(P);
|
||||
}
|
||||
|
||||
if (!fields.length) return res.status(400).json({ error: 'no fields to update' });
|
||||
|
||||
// Trigger set_updated_at non presente: lo facciamo manualmente.
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(id, type);
|
||||
try {
|
||||
const r = await query(
|
||||
`UPDATE rulesets SET ${fields.join(', ')}
|
||||
WHERE id = $${i++} AND type = $${i}
|
||||
RETURNING *`,
|
||||
values, DB
|
||||
);
|
||||
if (!r.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const rs = rowToRuleset(r.rows[0]);
|
||||
logChange(rs.id, type, 'updated', req.user?.user_id, null);
|
||||
res.json(rs);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'version already exists for this type' });
|
||||
}
|
||||
console.error('[RULES] update error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /rules/:type/:id/active → toggle (deattiva le altre dello stesso tipo)
|
||||
router.patch('/:type/:id/active', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
|
||||
const client = await getClient(DB);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const cur = await client.query(
|
||||
`SELECT active, archived FROM rulesets WHERE id = $1 AND type = $2`,
|
||||
[id, type]
|
||||
);
|
||||
if (!cur.rows[0]) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
|
||||
if (cur.rows[0].archived && !cur.rows[0].active) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'cannot activate an archived ruleset' });
|
||||
}
|
||||
const willActivate = !cur.rows[0].active;
|
||||
if (willActivate) {
|
||||
await client.query(
|
||||
`UPDATE rulesets SET active = false, updated_at = NOW()
|
||||
WHERE type = $1 AND active = true`,
|
||||
[type]
|
||||
);
|
||||
}
|
||||
const r = await client.query(
|
||||
`UPDATE rulesets SET active = $1, updated_at = NOW()
|
||||
WHERE id = $2 RETURNING *`,
|
||||
[willActivate, id]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
logChange(id, type, willActivate ? 'activated' : 'deactivated', req.user?.user_id, null);
|
||||
res.json({ active: r.rows[0].active, ruleset: rowToRuleset(r.rows[0]) });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
console.error('[RULES] active error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /rules/:type/:id/archive → toggle
|
||||
router.patch('/:type/:id/archive', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const cur = await query(`SELECT archived, active FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const willArchive = !cur.rows[0].archived;
|
||||
// archiviare implica disattivare
|
||||
const r = await query(
|
||||
`UPDATE rulesets SET archived = $1,
|
||||
active = CASE WHEN $1 = true THEN false ELSE active END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 RETURNING *`,
|
||||
[willArchive, id], DB
|
||||
);
|
||||
logChange(id, type, willArchive ? 'archived' : 'unarchived', req.user?.user_id, null);
|
||||
res.json({ archived: r.rows[0].archived, ruleset: rowToRuleset(r.rows[0]) });
|
||||
} catch (err) {
|
||||
console.error('[RULES] archive error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /rules/:type/:id
|
||||
// FK CASCADE non e' garantita lato DB: cancelliamo manualmente in transazione
|
||||
// prima i deployments e poi il ruleset. ruleset_changes (audit) viene preservato
|
||||
// volutamente — annulliamo solo ruleset_id se necessario.
|
||||
router.delete('/:type/:id', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
const client = await getClient(DB);
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const cur = await client.query(
|
||||
`SELECT active FROM rulesets WHERE id = $1 AND type = $2`,
|
||||
[id, type]
|
||||
);
|
||||
if (!cur.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
if (cur.rows[0].active) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'cannot delete active ruleset' });
|
||||
}
|
||||
await client.query(`DELETE FROM ruleset_deployments WHERE ruleset_id = $1`, [id]);
|
||||
await client.query(`DELETE FROM rulesets WHERE id = $1`, [id]);
|
||||
await client.query('COMMIT');
|
||||
logChange(id, type, 'deleted', req.user?.user_id, null);
|
||||
res.json({ deleted: true });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
console.error('[RULES] delete error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
ITEMS: helper endpoints (comodi per la UI)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
// POST /rules/:type/:id/items → aggiungi item
|
||||
router.post('/:type/:id/items', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const items = cur.rows[0].items || [];
|
||||
const newItem = normalizeItems([req.body || {}])[0];
|
||||
if (!newItem.ref) return res.status(400).json({ error: 'ref required' });
|
||||
if (items.some(it => it.ref === newItem.ref)) {
|
||||
return res.status(409).json({ error: `ref "${newItem.ref}" already exists` });
|
||||
}
|
||||
items.push(newItem);
|
||||
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||
logChange(id, type, 'item_added', req.user?.user_id, { ref: newItem.ref });
|
||||
res.status(201).json(newItem);
|
||||
} catch (err) {
|
||||
console.error('[RULES] add item error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /rules/:type/:id/items/:ref → patch item (per ref)
|
||||
router.put('/:type/:id/items/:ref', async (req, res) => {
|
||||
const { type, id, ref } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const items = cur.rows[0].items || [];
|
||||
const idx = items.findIndex(it => it.ref === ref);
|
||||
if (idx < 0) return res.status(404).json({ error: 'item not found' });
|
||||
|
||||
const body = req.body || {};
|
||||
const newRef = body.ref !== undefined ? String(body.ref) : items[idx].ref;
|
||||
if (newRef !== ref && items.some(it => it.ref === newRef)) {
|
||||
return res.status(409).json({ error: `ref "${newRef}" already exists` });
|
||||
}
|
||||
items[idx] = {
|
||||
ref: newRef,
|
||||
path: body.path !== undefined ? String(body.path) : items[idx].path,
|
||||
enabled: body.enabled !== undefined ? !!body.enabled : items[idx].enabled,
|
||||
meta: body.meta !== undefined
|
||||
? (body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : {})
|
||||
: { ...(items[idx].meta || {}), ...Object.fromEntries(
|
||||
Object.entries(body).filter(([k]) => !['ref','path','enabled','meta'].includes(k))
|
||||
) }
|
||||
};
|
||||
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||
logChange(id, type, 'item_updated', req.user?.user_id, { ref: newRef });
|
||||
res.json(items[idx]);
|
||||
} catch (err) {
|
||||
console.error('[RULES] update item error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /rules/:type/:id/items/:ref/toggle
|
||||
router.patch('/:type/:id/items/:ref/toggle', async (req, res) => {
|
||||
const { type, id, ref } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const items = cur.rows[0].items || [];
|
||||
const idx = items.findIndex(it => it.ref === ref);
|
||||
if (idx < 0) return res.status(404).json({ error: 'item not found' });
|
||||
items[idx].enabled = !items[idx].enabled;
|
||||
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||
res.json({ enabled: items[idx].enabled });
|
||||
} catch (err) {
|
||||
console.error('[RULES] toggle item error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /rules/:type/:id/items/:ref
|
||||
router.delete('/:type/:id/items/:ref', async (req, res) => {
|
||||
const { type, id, ref } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const cur = await query(`SELECT items FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||
if (!cur.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const items = (cur.rows[0].items || []).filter(it => it.ref !== ref);
|
||||
await query(`UPDATE rulesets SET items = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(items), id], DB);
|
||||
res.json({ deleted: true });
|
||||
} catch (err) {
|
||||
console.error('[RULES] delete item error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SENSORS & DEPLOYMENT
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
// GET /rules/:type/:id/deployments → sensori su cui e' deployato
|
||||
router.get('/:type/:id/deployments', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
try {
|
||||
const r = await query(
|
||||
`SELECT sensor_name, deployed_at, acked_at
|
||||
FROM ruleset_deployments
|
||||
WHERE ruleset_id = $1 AND type = $2
|
||||
ORDER BY deployed_at DESC`,
|
||||
[id, type], DB
|
||||
);
|
||||
res.json(r.rows);
|
||||
} catch (err) {
|
||||
console.error('[RULES] deployments error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /rules/:type/:id/deploy { sensors: [name, ...] }
|
||||
// Registra il deploy e notifica il servizio realtime, che fara' il push WS al plugin.
|
||||
router.post('/:type/:id/deploy', async (req, res) => {
|
||||
const { type, id } = req.params;
|
||||
if (!isValidType(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
const sensors = Array.isArray(req.body?.sensors) ? req.body.sensors.map(String).filter(Boolean) : [];
|
||||
if (!sensors.length) return res.status(400).json({ error: 'sensors array required' });
|
||||
|
||||
try {
|
||||
const rs = await query(`SELECT * FROM rulesets WHERE id = $1 AND type = $2`, [id, type], DB);
|
||||
if (!rs.rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
if (rs.rows[0].archived) return res.status(409).json({ error: 'cannot deploy archived ruleset' });
|
||||
|
||||
const ruleset = rowToRuleset(rs.rows[0]);
|
||||
|
||||
// upsert deployments
|
||||
for (const name of sensors) {
|
||||
await query(
|
||||
`INSERT INTO ruleset_deployments (sensor_name, type, ruleset_id, deployed_at, acked_at)
|
||||
VALUES ($1, $2, $3, NOW(), NULL)
|
||||
ON CONFLICT (sensor_name, type) DO UPDATE
|
||||
SET ruleset_id = EXCLUDED.ruleset_id,
|
||||
deployed_at = NOW(),
|
||||
acked_at = NULL`,
|
||||
[name, type, id], DB
|
||||
);
|
||||
}
|
||||
|
||||
// notifica realtime (best-effort)
|
||||
const RT = process.env.REALTIME_URL || 'http://meb-realtime:3000';
|
||||
const KEY = process.env.INTERNAL_API_KEY;
|
||||
const results = { pushed: [], offline: [], errors: [] };
|
||||
if (KEY) {
|
||||
try {
|
||||
const r = await fetch(`${RT}/rules/push`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
|
||||
body: JSON.stringify({ sensors, type, ruleset })
|
||||
});
|
||||
if (r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
Object.assign(results, j);
|
||||
} else {
|
||||
results.errors.push(`realtime HTTP ${r.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
results.errors.push(`realtime unreachable: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
results.errors.push('INTERNAL_API_KEY missing');
|
||||
}
|
||||
|
||||
logChange(id, type, 'deployed', req.user?.user_id, { sensors, results });
|
||||
res.json({ deployed: sensors, ...results });
|
||||
} catch (err) {
|
||||
console.error('[RULES] deploy error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /rules/:type/:id/ack { sensor } → chiamato dal servizio realtime quando il plugin conferma
|
||||
router.post('/:type/:id/ack', async (req, res) => {
|
||||
if (!req.internal) return res.status(403).json({ error: 'forbidden' });
|
||||
const { type, id } = req.params;
|
||||
const sensor = req.body?.sensor;
|
||||
if (!isValidType(type) || !sensor) return res.status(400).json({ error: 'bad request' });
|
||||
try {
|
||||
await query(
|
||||
`UPDATE ruleset_deployments
|
||||
SET acked_at = NOW()
|
||||
WHERE sensor_name = $1 AND type = $2 AND ruleset_id = $3`,
|
||||
[String(sensor), type, id], DB
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[RULES] ack error:', err.message);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -60,7 +60,9 @@ async function query(bucket, relativeTime, measurement, sensor, field) {
|
||||
|
||||
}
|
||||
|
||||
const sessionBucket = 'boat';
|
||||
// Sorgente di verità per i logs di sessione: stesso bucket usato da
|
||||
// realtime/store/influx.js. Sovrascrivibile via env per ambiente.
|
||||
const sessionBucket = process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs';
|
||||
|
||||
/**
|
||||
* Query storica per una sessione di registrazione.
|
||||
|
||||
@@ -8,6 +8,13 @@ const client = new Minio.Client({
|
||||
secretKey: process.env.MINIO_SECRET_KEY
|
||||
})
|
||||
|
||||
// Unified ML bucket: tutti gli oggetti del dominio ML vivono nel bucket
|
||||
// indicato da MINIO_BUCKET (default "ml"), con prefissi logici:
|
||||
// datasets/<uuid>.<ext>
|
||||
// models/<model_id>/<version>/<patch>/...
|
||||
// trainings/<training_id>/logs.jsonl
|
||||
const ML_BUCKET = process.env.MINIO_BUCKET || 'ml';
|
||||
|
||||
|
||||
// Buckets
|
||||
|
||||
@@ -132,7 +139,51 @@ async function checkMinio() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legge un oggetto e restituisce il suo contenuto come stringa UTF-8 (comodo per file testo/markdown).
|
||||
*/
|
||||
async function readText(bucket, objectName) {
|
||||
const stream = await client.getObject(bucket, objectName);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
stream.on('data', (c) => chunks.push(c));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrive una stringa come oggetto (auto-create del bucket).
|
||||
*/
|
||||
async function writeText(bucket, objectName, content, contentType = 'text/markdown; charset=utf-8') {
|
||||
await bucketExists(bucket);
|
||||
const buf = Buffer.from(content, 'utf8');
|
||||
await client.putObject(bucket, objectName, buf, buf.length, { 'Content-Type': contentType });
|
||||
return { bucket, objectName, size: buf.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Elenca oggetti di un bucket in forma compatta (name, size, lastModified).
|
||||
*/
|
||||
async function listObjects(bucket) {
|
||||
await bucketExists(bucket);
|
||||
return new Promise((resolve, reject) => {
|
||||
const out = [];
|
||||
const stream = client.listObjects(bucket, '', true);
|
||||
stream.on('data', (o) => out.push({
|
||||
name: o.name,
|
||||
size: o.size,
|
||||
lastModified: o.lastModified,
|
||||
etag: o.etag,
|
||||
}));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(out));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
client,
|
||||
ML_BUCKET,
|
||||
bucketExists,
|
||||
getBuckets,
|
||||
getBucket,
|
||||
@@ -142,5 +193,8 @@ module.exports = {
|
||||
upload,
|
||||
download,
|
||||
getFileStream,
|
||||
checkMinio
|
||||
checkMinio,
|
||||
readText,
|
||||
writeText,
|
||||
listObjects,
|
||||
}
|
||||
@@ -15,6 +15,11 @@ const pools = {
|
||||
users: new Pool({ ...config, database: process.env.USERS_DB }),
|
||||
sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }),
|
||||
rules: new Pool({ ...config, database: process.env.RULES_DB || 'rules' }),
|
||||
ml: new Pool({ ...config, database: process.env.ML_DB || 'ml' }),
|
||||
references: new Pool({ ...config, database: process.env.REFERENCES_DB || 'references' }),
|
||||
// Le tabelle kiosktemplates / kioskelements vivono nel DB `sensors`.
|
||||
// KIOSK_DB resta override-abile per environment legacy.
|
||||
kiosk: new Pool({ ...config, database: process.env.KIOSK_DB || 'sensors' }),
|
||||
}
|
||||
|
||||
Object.entries(pools).forEach(([name, pool]) => {
|
||||
@@ -73,6 +78,10 @@ async function remove(table, condition, params, type = 'users') {
|
||||
return await query(sql, params, type);
|
||||
}
|
||||
|
||||
// initKioskSchema rimosso: lo schema kiosktemplates/kioskelements vive nel DB
|
||||
// `sensors` ed e' gestito dalla migration 007_kiosktemplates.sql. L'auto-create
|
||||
// qui creava un schema legacy divergente (UUID + JSONB content) sul DB sbagliato.
|
||||
|
||||
async function checkPostgres() {
|
||||
const status = {};
|
||||
console.log("Checking PostgreSQL connections with config:", config);
|
||||
|
||||
Reference in New Issue
Block a user