domma-cms 0.25.13 → 0.25.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- .dashboard-grid{display:flex;flex-direction:column;gap:var(--dm-spacing-4, 16px)}.dash-row{display:grid;gap:var(--dm-spacing-4, 16px)}.dash-row-kpi{grid-template-columns:repeat(4,1fr)}.dash-row-traffic{grid-template-columns:2fr 1fr}.dash-row-content,.dash-row-health{grid-template-columns:1fr 1fr}@media(max-width:960px){.dash-row-kpi{grid-template-columns:repeat(2,1fr)}.dash-row-traffic,.dash-row-content,.dash-row-health{grid-template-columns:1fr}}.dash-card{background:var(--dm-surface, #fff);border:1px solid var(--dm-border, #e5e7eb);border-radius:var(--dm-radius, 8px);padding:var(--dm-spacing-4, 16px)}.dash-kpi{display:flex;align-items:center;gap:var(--dm-spacing-3, 12px)}.dash-kpi-icon{font-size:24px;opacity:.8}.dash-kpi-value{font-size:28px;font-weight:600;line-height:1}.dash-kpi-label{font-size:12px;color:var(--dm-muted, #6b7280);text-transform:uppercase;letter-spacing:.05em}.dash-kpi-delta{font-size:12px;margin-top:4px}.dash-kpi-delta.up{color:var(--dm-success, #16a34a)}.dash-kpi-delta.down{color:var(--dm-danger, #dc2626)}.dash-health-pill{display:inline-flex;align-items:center;gap:6px}.dash-health-pill[data-level=ok]{color:var(--dm-success, #16a34a)}.dash-health-pill[data-level=warn]{color:var(--dm-warning, #d97706)}.dash-health-pill[data-level=fail]{color:var(--dm-danger, #dc2626)}.dash-spike-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed var(--dm-border, #e5e7eb)}.dash-spike-row:last-child{border-bottom:0}.dash-spark{display:inline-block;width:80px;height:24px;vertical-align:middle;margin-left:8px}.dash-warnings{background:var(--dm-warning-bg, #fef3c7);border:1px solid var(--dm-warning, #d97706);color:var(--dm-warning-fg, #78350f);padding:8px 12px;border-radius:var(--dm-radius, 8px);margin-bottom:12px}.dash-updated{font-size:12px;color:var(--dm-muted, #6b7280);margin-right:8px}.dash-journey-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}@media(max-width:720px){.dash-journey-grid{grid-template-columns:1fr}}.dash-journey-grid h4{font-size:12px;text-transform:uppercase;color:var(--dm-muted, #6b7280);margin:0 0 6px}.dash-bounce{margin-top:12px;font-size:14px;color:var(--dm-muted, #6b7280)}.dash-empty{font-size:13px;color:var(--dm-muted, #6b7280);margin:8px 0 0}.dash-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.dash-card-header h3{margin:0}.dash-cache-toggle{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--dm-muted, #6b7280);cursor:pointer}.dash-cache-toggle input[type=checkbox]{transform:scale(1.2);cursor:pointer}.dash-cache-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:12px}@media(max-width:720px){.dash-cache-grid{grid-template-columns:repeat(2,1fr)}}.dash-cache-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-muted, #6b7280);margin-bottom:4px}.dash-cache-stat-value{font-size:22px;font-weight:600;line-height:1.1}.dash-cache-stat-sub{font-size:12px;color:var(--dm-muted, #6b7280);margin-top:4px;min-height:14px}[data-field=size-bar]{height:4px;background:var(--dm-border, #e5e7eb);border-radius:2px;overflow:hidden;margin-top:6px}[data-field=size-fill]{display:block;height:100%;background:var(--dm-primary, #2563eb);width:0;transition:width .3s ease}.dash-cache-actions{display:flex;gap:8px;justify-content:flex-end}
1
+ .dashboard-grid{display:flex;flex-direction:column;gap:var(--dm-spacing-4, 16px)}.dash-row{display:grid;gap:var(--dm-spacing-4, 16px)}.dash-row-kpi{grid-template-columns:repeat(4,1fr)}.dash-row-traffic{grid-template-columns:2fr 1fr}.dash-row-content,.dash-row-health{grid-template-columns:1fr 1fr}@media(max-width:960px){.dash-row-kpi{grid-template-columns:repeat(2,1fr)}.dash-row-traffic,.dash-row-content,.dash-row-health{grid-template-columns:1fr}}.dash-card{background:var(--dm-surface, #fff);border:1px solid var(--dm-border, #e5e7eb);border-radius:var(--dm-radius, 8px);padding:var(--dm-spacing-4, 16px)}.dash-kpi{display:flex;align-items:center;gap:var(--dm-spacing-3, 12px)}.dash-kpi-icon{font-size:24px;opacity:.8}.dash-kpi-value{font-size:28px;font-weight:600;line-height:1}.dash-kpi-label{font-size:12px;color:var(--dm-muted, #6b7280);text-transform:uppercase;letter-spacing:.05em}.dash-kpi-delta{font-size:12px;margin-top:4px}.dash-kpi-delta.up{color:var(--dm-success, #16a34a)}.dash-kpi-delta.down{color:var(--dm-danger, #dc2626)}.dash-health-pill{display:inline-flex;align-items:center;gap:6px}.dash-health-pill[data-level=ok]{color:var(--dm-success, #16a34a)}.dash-health-pill[data-level=warn]{color:var(--dm-warning, #d97706)}.dash-health-pill[data-level=fail]{color:var(--dm-danger, #dc2626)}.dash-spike-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed var(--dm-border, #e5e7eb)}.dash-spike-row:last-child{border-bottom:0}.dash-spark{display:inline-block;width:80px;height:24px;vertical-align:middle;margin-left:8px}.dash-warnings{background:var(--dm-warning-bg, #fef3c7);border:1px solid var(--dm-warning, #d97706);color:var(--dm-warning-fg, #78350f);padding:8px 12px;border-radius:var(--dm-radius, 8px);margin-bottom:12px}.dash-updated{font-size:12px;color:var(--dm-muted, #6b7280);margin-right:8px}.dash-journey-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}@media(max-width:720px){.dash-journey-grid{grid-template-columns:1fr}}.dash-journey-grid h4{font-size:12px;text-transform:uppercase;color:var(--dm-muted, #6b7280);margin:0 0 6px}.dash-bounce{margin-top:12px;font-size:14px;color:var(--dm-muted, #6b7280)}.dash-empty{font-size:13px;color:var(--dm-muted, #6b7280);margin:8px 0 0}.dash-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.dash-card-header h3{margin:0}.dash-cache-toggle{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--dm-muted, #6b7280);cursor:pointer}.dash-cache-toggle input[type=checkbox]{transform:scale(1.2);cursor:pointer}.dash-cache-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:12px}@media(max-width:720px){.dash-cache-grid{grid-template-columns:repeat(2,1fr)}}.dash-cache-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-muted, #6b7280);margin-bottom:4px}.dash-cache-stat-value{font-size:22px;font-weight:600;line-height:1.1}.dash-cache-stat-sub{font-size:12px;color:var(--dm-muted, #6b7280);margin-top:4px;min-height:14px}[data-field=size-bar]{height:4px;background:var(--dm-border, #e5e7eb);border-radius:2px;overflow:hidden;margin-top:6px}[data-field=size-fill]{display:block;height:100%;background:var(--dm-primary, #2563eb);width:0;transition:width .3s ease}.dash-cache-actions{display:flex;gap:8px;justify-content:flex-end}.dash-chart-wrap{position:relative;height:240px}.dash-chart-wrap>canvas{position:absolute;inset:0}
@@ -1,3 +1,3 @@
1
1
  <h3>Traffic — last 30 days</h3>
2
- <canvas id="dash-traffic-canvas" height="120"></canvas>
2
+ <div class="dash-chart-wrap"><canvas id="dash-traffic-canvas"></canvas></div>
3
3
  <p class="dash-empty" hidden>No traffic recorded yet.</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.25.13",
3
+ "version": "0.25.14",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -98,6 +98,88 @@ function buildTopPages(daily, days = 7) {
98
98
  .slice(0, 5);
99
99
  }
100
100
 
101
+ // ── Journeys / spikes — ported verbatim from plugins/analytics so the core
102
+ // dashboard can compute them off disk without the plugin's encapsulated
103
+ // `fastify.analytics` decorator (which it cannot see). Keep in sync with
104
+ // plugins/analytics/plugin.js.
105
+ const REALTIME_WINDOW_MS = 5 * 60 * 1000;
106
+
107
+ function eventsInRange(journeys, start, end) {
108
+ const startMs = start.getTime(), endMs = end.getTime();
109
+ const out = [];
110
+ for (const [day, events] of Object.entries(journeys)) {
111
+ const dayMs = new Date(day + 'T00:00:00Z').getTime();
112
+ if (dayMs < startMs || dayMs > endMs) continue;
113
+ if (Array.isArray(events)) for (const ev of events) out.push(ev);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ function groupBySession(events) {
119
+ const sessions = new Map();
120
+ for (const ev of events) {
121
+ if (!sessions.has(ev.sid)) sessions.set(ev.sid, []);
122
+ sessions.get(ev.sid).push(ev);
123
+ }
124
+ for (const arr of sessions.values()) arr.sort((a, b) => a.t - b.t);
125
+ return sessions;
126
+ }
127
+
128
+ function aggregateJourneys(events) {
129
+ const sessions = groupBySession(events);
130
+ const entry = new Map(), exit = new Map(), paths = new Map();
131
+ let bouncedSessions = 0;
132
+ for (const arr of sessions.values()) {
133
+ if (arr.length === 0) continue;
134
+ entry.set(arr[0].url, (entry.get(arr[0].url) || 0) + 1);
135
+ exit.set(arr[arr.length - 1].url, (exit.get(arr[arr.length - 1].url) || 0) + 1);
136
+ if (arr.length === 1) bouncedSessions += 1;
137
+ for (let i = 0; i < arr.length - 1; i += 1) {
138
+ const from = arr[i].url, to = arr[i + 1].url;
139
+ const key = from + '\x00' + to;
140
+ const existing = paths.get(key);
141
+ if (existing) existing.count += 1;
142
+ else paths.set(key, { from, to, count: 1 });
143
+ }
144
+ }
145
+ const toSorted = (m) => Array.from(m.entries())
146
+ .map(([url, count]) => ({ url, count }))
147
+ .sort((a, b) => b.count - a.count).slice(0, 10);
148
+ const totalSessions = sessions.size;
149
+ return {
150
+ entry: toSorted(entry),
151
+ exit: toSorted(exit),
152
+ paths: Array.from(paths.values()).sort((a, b) => b.count - a.count).slice(0, 10),
153
+ bounceRate: totalSessions === 0 ? 0 : +(bouncedSessions / totalSessions).toFixed(3),
154
+ totalSessions
155
+ };
156
+ }
157
+
158
+ function detectSpikes(daily, now = new Date()) {
159
+ const todayStr = todayKey(now);
160
+ const currentHour = now.getUTCHours();
161
+ const todayUrls = daily[todayStr] || {};
162
+ const elapsedHours = Math.max(1, currentHour + 1);
163
+ const flagged = [];
164
+ for (const [url, totalToday] of Object.entries(todayUrls)) {
165
+ const currentHits = totalToday / elapsedHours;
166
+ const samples = [];
167
+ for (let i = 1; i <= 7; i += 1) {
168
+ const prev = new Date(now);
169
+ prev.setUTCDate(prev.getUTCDate() - i);
170
+ const key = todayKey(prev);
171
+ samples.push((daily[key] && daily[key][url]) ? daily[key][url] / 24 : 0);
172
+ }
173
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
174
+ const variance = samples.reduce((acc, x) => acc + (x - mean) * (x - mean), 0) / samples.length;
175
+ const stddev = Math.sqrt(variance);
176
+ if (currentHits >= 5 && currentHits > mean + 2 * stddev && mean > 0) {
177
+ flagged.push({ url, hits: Math.round(currentHits), baseline: Math.round(mean), ratio: +(currentHits / Math.max(mean, 0.5)).toFixed(2) });
178
+ }
179
+ }
180
+ return flagged.sort((a, b) => b.ratio - a.ratio).slice(0, 5);
181
+ }
182
+
101
183
  /**
102
184
  * Recent activity feed — combines page version events with recent collection
103
185
  * entries (form submissions). Returns at most 10 items, newest first.
@@ -193,7 +275,18 @@ export async function dashboardRoutes(fastify, opts = {}) {
193
275
  };
194
276
 
195
277
  const analytics = fastify.analytics;
196
- const daily = analytics ? (await safe('analytics.daily', () => analytics.getDaily())) || {} : {};
278
+ // The analytics plugin decorates `analytics` inside its own prefixed,
279
+ // encapsulated scope, so `fastify.analytics` is undefined here in the
280
+ // core dashboard route — traffic/topPages always read 0 even though hits
281
+ // are recorded. Read the plugin's daily counters straight off disk (same
282
+ // pattern as buildActivity reading versions/collections below).
283
+ const daily = (await safe('analytics.daily', async () => {
284
+ for (const rel of ['plugins/analytics/data/daily.json', 'plugins/analytics/daily.json']) {
285
+ try { return JSON.parse(await fs.readFile(path.join(ROOT, rel), 'utf8')); }
286
+ catch { /* try next location */ }
287
+ }
288
+ return {};
289
+ })) || {};
197
290
 
198
291
  const today = todayKey();
199
292
  const yesterday = isoDaysAgo(1);
@@ -210,12 +303,22 @@ export async function dashboardRoutes(fastify, opts = {}) {
210
303
  previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
211
304
  };
212
305
 
213
- const realtime = analytics
214
- ? (await safe('analytics.realtime', () => analytics.getRealtime())) || { activeSessions: 0 }
215
- : { activeSessions: 0 };
216
- const spikes = analytics
217
- ? ((await safe('analytics.spikes', () => analytics.getSpikes())) || { items: [] }).items
218
- : [];
306
+ // Session events read straight off disk too (same reason as daily above).
307
+ const journeysData = (await safe('analytics.journeysData', async () => {
308
+ for (const rel of ['plugins/analytics/data/journeys.json', 'plugins/analytics/journeys.json']) {
309
+ try { return JSON.parse(await fs.readFile(path.join(ROOT, rel), 'utf8')); }
310
+ catch { /* try next location */ }
311
+ }
312
+ return {};
313
+ })) || {};
314
+ const realtime = (await safe('analytics.realtime', async () => {
315
+ const evs = journeysData[today] || [];
316
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
317
+ const sids = new Set();
318
+ for (const ev of evs) if (ev.t >= cutoff) sids.add(ev.sid);
319
+ return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
320
+ })) || { activeSessions: 0 };
321
+ const spikes = (await safe('analytics.spikes', () => detectSpikes(daily))) || [];
219
322
  const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
220
323
 
221
324
  if (lite) {
@@ -229,9 +332,11 @@ export async function dashboardRoutes(fastify, opts = {}) {
229
332
  }
230
333
 
231
334
  const topPages = buildTopPages(daily);
232
- const journeys = analytics
233
- ? (await safe('analytics.journeys', () => analytics.getJourneys({ range: '7d' }))) || null
234
- : null;
335
+ const journeys = (await safe('analytics.journeys', () => {
336
+ const end = new Date(); end.setUTCHours(0, 0, 0, 0);
337
+ const start = new Date(end); start.setUTCDate(start.getUTCDate() - 6);
338
+ return aggregateJourneys(eventsInRange(journeysData, start, end));
339
+ })) || null;
235
340
  const activity = (await safe('activity', () => buildActivity())) || [];
236
341
 
237
342
  return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };