diff --git a/routes/sources.js b/routes/sources.js
index 3b79ab6..cc27fa7 100644
--- a/routes/sources.js
+++ b/routes/sources.js
@@ -222,6 +222,24 @@ module.exports = function(pool) {
}
});
+ // set or clear the default Perspective layout for a source.
+ // Body: a Perspective view config (group_by, split_by, columns, plugin_config, …).
+ // Pass null or {} to clear.
+ router.put('/sources/:id/default-layout', async (req, res) => {
+ try {
+ const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null;
+ const result = await pool.query(
+ `UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`,
+ [layout, req.params.id]
+ );
+ if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
+ res.json(result.rows[0]);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
// deregister a source — does not drop existing forecast tables
router.delete('/sources/:id', async (req, res) => {
try {
diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql
index 66f3bf5..ea9ed8c 100644
--- a/setup_sql/01_schema.sql
+++ b/setup_sql/01_schema.sql
@@ -4,16 +4,20 @@
CREATE SCHEMA IF NOT EXISTS pf;
CREATE TABLE IF NOT EXISTS pf.source (
- id serial PRIMARY KEY,
- schema text NOT NULL,
- tname text NOT NULL,
- label text,
- status text NOT NULL DEFAULT 'active', -- active | archived
- created_at timestamptz NOT NULL DEFAULT now(),
- created_by text,
+ id serial PRIMARY KEY,
+ schema text NOT NULL,
+ tname text NOT NULL,
+ label text,
+ status text NOT NULL DEFAULT 'active', -- active | archived
+ default_layout jsonb, -- Perspective view config used as the per-source default
+ created_at timestamptz NOT NULL DEFAULT now(),
+ created_by text,
UNIQUE (schema, tname)
);
+-- backfill column for existing installs
+ALTER TABLE pf.source ADD COLUMN IF NOT EXISTS default_layout jsonb;
+
CREATE TABLE IF NOT EXISTS pf.col_meta (
id serial PRIMARY KEY,
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx
index 7557f59..3fffda6 100644
--- a/ui/src/views/Forecast.jsx
+++ b/ui/src/views/Forecast.jsx
@@ -29,7 +29,7 @@ function cleanLayout(cfg, validCols) {
return c
}
-export default function Forecast({ sourceId, versionId }) {
+export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
const { dark } = useTheme()
const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false)
@@ -238,13 +238,21 @@ export default function Forecast({ sourceId, versionId }) {
await viewer.restore(cfg)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else {
- const valueCol = meta.find(c => c.role === 'value')?.cname
- const cfg = {
- table: tableName,
- settings: false,
- group_by: ['pf_iter'],
- columns: valueCol ? [valueCol] : [],
- plugin_config: { edit_mode: 'SELECT_REGION' }
+ const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
+ let cfg
+ if (sourceDefault && Object.keys(sourceDefault).length > 0) {
+ cfg = cleanLayout(sourceDefault, validCols)
+ cfg.table = tableName
+ cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
+ } else {
+ const valueCol = meta.find(c => c.role === 'value')?.cname
+ cfg = {
+ table: tableName,
+ settings: false,
+ group_by: ['pf_iter'],
+ columns: valueCol ? [valueCol] : [],
+ plugin_config: { edit_mode: 'SELECT_REGION' }
+ }
}
await viewer.restore(cfg)
}
@@ -320,6 +328,22 @@ export default function Forecast({ sourceId, versionId }) {
flash('Saved')
}
+ async function saveAsSourceDefault() {
+ const cfg = await captureConfig()
+ if (!cfg) return
+ const { table, expand_depth, ...rest } = cfg
+ try {
+ const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(rest)
+ })
+ if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
+ if (refreshSources) await refreshSources()
+ flash('Saved as source default')
+ } catch (err) { flash(err.message, 'error') }
+ }
+
async function handleSaveOver() {
const layout = layouts.find(l => l.id === activeLayoutId)
if (!layout) return
@@ -492,6 +516,11 @@ export default function Forecast({ sourceId, versionId }) {
+
{activeLayoutId !== null && (
)}