domma-cms 1.1.0 → 1.2.0
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.
- package/admin/css/admin.css +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/dashboard-board-arrange.js +1 -0
- package/admin/js/lib/sidebar-frequent.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/widget-render.js +1 -0
- package/admin/js/templates/my-dashboard.html +3 -0
- package/admin/js/views/dashboard-board.js +30 -0
- package/admin/js/views/dashboard.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/my-dashboard.js +1 -0
- package/package.json +1 -1
- package/server/routes/api/dashboard.js +85 -0
- package/server/routes/api/widgets.js +106 -0
- package/server/server.js +2 -0
- package/server/services/plugins.js +15 -0
- package/server/services/siteGitignore.js +2 -0
- package/server/services/widgets.js +269 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const h=["ok","warn","danger","info","attention","live"],w=t=>Number.isFinite(Number(t))?Number(t).toLocaleString("en-GB"):String(t??"");function i(t,e,r){const n=document.createElement(t);return e&&(n.className=e),r!=null&&r!==""&&(n.textContent=String(r)),n}function l(t){const e=t==="warn"?"attention":t;return h.includes(e)?` is-${e}`:""}function u(t,e){const r=i("div","dbp-rows");if(!t.length)return r.append(i("p","dbp-empty",e||"Nothing to show.")),r;for(const n of t){const a=i(n.href?"a":"div",`dbp-row${l(n.tone)}`);n.href&&/^#\//.test(n.href)&&(a.href=n.href);const o=i("span","dbp-row-main");o.append(i("span","dbp-row-text",n.text)),n.meta&&o.append(i("small","dbp-row-meta",n.meta)),a.append(o),n.value!==void 0&&n.value!==""&&a.append(i("span","dbp-row-value",n.value)),r.append(a)}return r}function g(t,e){const r=i("div",`dbp-figure${l(e.tone)}`);r.append(i("span","dbp-figure-value",w(e.count??0))),e.label&&r.append(i("span","dbp-figure-label",e.label)),t.append(r);const n=e.details||{};if(Array.isArray(n.rows)&&n.rows.length){const a=i("dl","dbp-facts");for(const o of n.rows.slice(0,4))a.append(i("dt","",o.label),i("dd",l(o.tone).trim(),o.value));t.append(a)}Array.isArray(n.items)&&n.items.length&&t.append(u(n.items.slice(0,5),""))}function v(t,e){const r=i("div",`dbp-figure${l(e.tone)}`);r.append(i("span","dbp-figure-value",e.value??"-")),e.label&&r.append(i("span","dbp-figure-label",e.label)),t.append(r),e.sub&&t.append(i("p","dbp-sub",e.sub))}function C(t,e){t.append(u(Array.isArray(e.items)?e.items.slice(0,12):[],e.empty))}let d=null;const y="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js";export function loadChart(){return window.Chart?Promise.resolve(!0):(d||(d=new Promise(t=>{const e=document.createElement("script");e.src=y,e.async=!0,e.onload=()=>t(!0),e.onerror=()=>{d=null,t(!1)},document.head.appendChild(e)})),d)}async function A(t,e){const r=Array.isArray(e.labels)?e.labels:[],n=Array.isArray(e.values)?e.values.map(p=>Number(p)||0):[];if(!n.some(p=>p>0)){t.append(i("p","dbp-empty","Nothing recorded yet."));return}const a=i("div","dbp-chart"),o=document.createElement("canvas");if(a.append(o),t.append(a),!await loadChart()||!o.isConnected){a.replaceWith(i("p","dbp-empty","The chart could not load."));return}const f=getComputedStyle(t),s=(p,b)=>f.getPropertyValue(p).trim()||b,c=s("--dm-primary","#5b8cff"),m=new window.Chart(o,{type:"line",data:{labels:r,datasets:[{label:e.label||"",data:n,borderColor:c,backgroundColor:/^#[0-9a-f]{6}$/i.test(c)?`${c}26`:"transparent",fill:!0,tension:.3,pointRadius:0,borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8,color:s("--dm-text-muted","#94a3b8")},grid:{color:s("--dm-border","rgba(127,127,127,.2)")}},y:{beginAtZero:!0,ticks:{precision:0,color:s("--dm-text-muted","#94a3b8")},grid:{color:s("--dm-border","rgba(127,127,127,.2)")}}}}});t.__dbpChart=m}const N={count:g,stat:v,list:C,chart:A};export async function drawWidget(t,e,r){destroyWidget(t),t.replaceChildren();const n=N[e];if(!n||!r||typeof r!="object"){t.append(i("p","dbp-empty","This widget has nothing to show."));return}await n(t,r)}export function drawError(t,e){destroyWidget(t),t.replaceChildren(i("p","dbp-empty is-error",e||"Could not load."))}export function destroyWidget(t){try{t.__dbpChart?.destroy()}catch{}t.__dbpChart=null}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import{apiRequest as y}from"../api.js";import{addBannerAction as v}from"../lib/plugin-chrome.js";import{helpPopovers as Q}from"../lib/help-popover.js";import{openMenuAt as G}from"../lib/tool-kit.js";import{SIZE_LABELS as ee,SIZES as te,addWidget as ae,anyOpen as oe,boardCards as se,dropWidget as de,galleryGroups as re,removeWidget as Y,resizeWidget as ie,sameLayout as T,setAllCollapsed as ne,setCollapsed as le,shiftWidget as S,withToday as ce}from"../lib/dashboard-board-arrange.js";import{destroyWidget as pe,drawError as ue,drawWidget as be}from"../lib/widget-render.js";const fe=400,he=l=>String(l||"").replace(/^\/api(?=\/)/,""),n=l=>String(l??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");export async function mountBoard(l,b,{isAdmin:N=!1}={}){const q=l.get(0),c=q.querySelector(".dbx")||q;c.classList.add("dbp"),c.replaceChildren(),c.insertAdjacentHTML("beforeend",`
|
|
2
|
+
<div class="dbp-head">
|
|
3
|
+
<p class="dbp-hint">Your own Dashboard. Drag a card by its grip to move it; the chevron folds it; \u22EE or right-click to resize or remove it.
|
|
4
|
+
<span data-help-title="Dashboard Pro" data-help="Add widgets from the Tools you use with Add widget. Each card keeps itself up to date. This layout is yours - other people arrange their own. Reset goes back to the site's default; an administrator can make their layout that default for everyone who has not arranged their own."></span></p>
|
|
5
|
+
<span class="dbp-source" aria-live="polite"></span>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="dbp-board" role="list" aria-label="Dashboard widgets"></div>
|
|
8
|
+
<div class="dbp-empty-board" hidden>
|
|
9
|
+
<p>Nothing on your Dashboard yet.</p>
|
|
10
|
+
<button type="button" class="btn btn-primary dbp-add-first"><span data-icon="plus" data-icon-size="14"></span> Add a widget</button>
|
|
11
|
+
</div>`);const i=c.querySelector(".dbp-board"),U=c.querySelector(".dbp-source"),D=()=>c.isConnected;let f=[],a={version:1,widgets:[]},w=a,k="built-in";const g=new Map;b.add(()=>{for(const e of g.values())clearInterval(e);g.clear()});async function _(){const[e,t]=await Promise.all([y("/widgets",{method:"GET"}).catch(()=>[]),y("/dashboard/layout",{method:"GET"}).catch(()=>null)]);f=Array.isArray(e)?e:[],a=t?.layout||{version:1,widgets:[]},w=a,k=t?.source||"built-in"}let A=null;function $(){clearTimeout(A),A=setTimeout(async()=>{if(!T(a,w))try{w=(await y("/dashboard/layout",{method:"PUT",body:JSON.stringify(a)}))?.layout||a,k="mine",M()}catch(e){E.toast(`Your layout was not saved: ${e.message||e}`,{type:"error"})}},fe)}b.add(()=>clearTimeout(A));function M(){U.textContent={mine:"Your layout",site:"The site's default layout","built-in":"The starting layout"}[k]||""}function p(e){T(e,a)||(a=e,C(),$())}function O(e,t){a=le(a,e,t);const s=i.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`);s&&x(s,t),$()}function x(e,t){const s=e.classList.contains("is-collapsed");e.classList.toggle("is-collapsed",t);const o=e.querySelector(".dbp-body");o&&(o.hidden=t);const d=e.querySelector(".dbp-fold"),r=e.getAttribute("aria-label")||"";d&&(d.setAttribute("aria-expanded",t?"false":"true"),d.title=t?"Open":"Fold",d.setAttribute("aria-label",`${t?"Open":"Fold"} ${r}`)),s&&!t&&h(e)}function j(e){a=ne(a,e),i.querySelectorAll(".dbp-card").forEach(t=>x(t,e)),$()}const H=4,K=16;function z(e){const t=e.getBoundingClientRect().height;t&&(e.style.gridRowEnd=`span ${Math.max(1,Math.ceil((t+K)/H))}`)}const L=typeof ResizeObserver=="function"?new ResizeObserver(e=>{for(const t of e)z(t.target)}):null;b.add(()=>L?.disconnect());async function h(e){const t=f.find(o=>o.id===e.dataset.id),s=e.querySelector(".dbp-body");if(!(!t||!s)){e.classList.add("is-loading");try{const o=t.kind==="count"?ce(t.dataUrl):t.dataUrl,d=await y(he(o),{method:"GET"});if(!D()||!e.isConnected)return;await be(s,t.kind,d)}catch(o){e.isConnected&&ue(s,o.status===403?"You cannot see this any more.":"Could not load.")}finally{e.classList.remove("is-loading")}}}function J(e){const t=e.href?`<a class="dbp-title" href="${n(e.href)}" title="Open ${n(e.title)}">${n(e.title)}</a>`:`<span class="dbp-title">${n(e.title)}</span>`;return`<section class="dbp-card dbp-size-${n(e.size)}${e.collapsed?" is-collapsed":""}" data-id="${n(e.id)}" role="listitem" tabindex="0" aria-label="${n(e.title)}">
|
|
12
|
+
<header class="dbp-card-head">
|
|
13
|
+
<span class="dbp-grip" title="Drag to move" aria-hidden="true"><span data-icon="menu" data-icon-size="14"></span></span>
|
|
14
|
+
<span class="dbp-icon" aria-hidden="true"><span data-icon="${n(e.icon||"grid")}" data-icon-size="16"></span></span>
|
|
15
|
+
${t}
|
|
16
|
+
<small class="dbp-from">${e.source&&e.source!=="Built in"?n(e.source):""}</small>
|
|
17
|
+
<button type="button" class="dbp-fold" aria-expanded="${e.collapsed?"false":"true"}" aria-label="${e.collapsed?"Open":"Fold"} ${n(e.title)}" title="${e.collapsed?"Open":"Fold"}"><span data-icon="chevron-down" data-icon-size="14"></span></button>
|
|
18
|
+
<button type="button" class="dbp-kebab" aria-label="Actions for ${n(e.title)}" title="Actions">\u22EE</button>
|
|
19
|
+
</header>
|
|
20
|
+
<div class="dbp-body"${e.collapsed?" hidden":""}><div class="dbp-skeleton"></div></div>
|
|
21
|
+
</section>`}function C(){for(const t of g.values())clearInterval(t);g.clear(),i.querySelectorAll(".dbp-body").forEach(pe);const e=se(a,f);i.innerHTML=e.map(J).join(""),c.querySelector(".dbp-empty-board").hidden=e.length>0,L?.disconnect();for(const t of i.querySelectorAll(".dbp-card"))z(t),L?.observe(t);I.scan(i);for(const t of i.querySelectorAll(".dbp-card")){t.classList.contains("is-collapsed")||h(t);const s=f.find(o=>o.id===t.dataset.id);g.set(t.dataset.id,setInterval(()=>{!document.hidden&&t.isConnected&&!t.classList.contains("is-collapsed")&&h(t)},(s?.refresh||60)*1e3))}M()}const R=e=>{const t=e.detail?.url;if(t)for(const s of i.querySelectorAll(".dbp-card"))f.find(d=>d.id===s.dataset.id)?.href===t&&h(s)};window.addEventListener("dm:sidebar-count",R),b.add(()=>window.removeEventListener("dm:sidebar-count",R));function X(e){const t=e.dataset.id,s=f.find(r=>r.id===t),o=a.widgets.find(r=>r.id===t);if(!s||!o)return[];const d=a.widgets.findIndex(r=>r.id===t);return[{header:s.title},...s.href?[{label:`Open ${s.title}`,icon:"arrow-right",action:()=>{location.hash=s.href}}]:[],{label:o.collapsed?"Open":"Fold",icon:o.collapsed?"chevron-down":"chevron-up",action:()=>O(t,!o.collapsed)},{label:"Refresh",icon:"refresh-cw",disabled:o.collapsed===!0,action:()=>h(e)},{type:"divider"},...te.map(r=>({label:ee[r],icon:o.size===r?"check":"maximize-2",disabled:o.size===r,action:()=>p(ie(a,t,r))})),{type:"divider"},{label:"Move earlier",icon:"arrow-left",disabled:d<=0,action:()=>p(S(a,t,-1))},{label:"Move later",icon:"arrow-right",disabled:d>=a.widgets.length-1,action:()=>p(S(a,t,1))},{type:"divider"},{label:"Remove from the Dashboard",icon:"x",action:()=>p(Y(a,t))}]}const Z=E.contextMenu(i,{cursor:"glyph",match:".dbp-card",inherit:!1,density:"compact",items:e=>X(e)});b.add(()=>Z?.destroy?.());const W=e=>{const t=e.target.closest(".dbp-kebab");if(t){G(t);return}const s=e.target.closest(".dbp-fold");if(s){const o=s.closest(".dbp-card");o&&O(o.dataset.id,!o.classList.contains("is-collapsed"))}},F=e=>{const t=e.target.closest(".dbp-card");if(t){if(e.key==="F10"&&e.shiftKey){e.preventDefault(),G(t);return}e.target===t&&(e.altKey&&(e.key==="ArrowLeft"||e.key==="ArrowUp")?(e.preventDefault(),p(S(a,t.dataset.id,-1)),P(t.dataset.id)):e.altKey&&(e.key==="ArrowRight"||e.key==="ArrowDown")?(e.preventDefault(),p(S(a,t.dataset.id,1)),P(t.dataset.id)):e.key==="Delete"&&(e.preventDefault(),p(Y(a,t.dataset.id))))}},P=e=>requestAnimationFrame(()=>i.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`)?.focus());if(i.addEventListener("click",W),i.addEventListener("keydown",F),b.add(()=>{i.removeEventListener("click",W),i.removeEventListener("keydown",F)}),typeof E.sortable=="function"){const e=E.sortable(i,{items:".dbp-card",handle:".dbp-grip",live:!1,axis:"x",onDrop({key:t,targetKey:s,zone:o}){if(o!=="before"&&o!=="after")return!1;const d=de(a,t,s,o);return T(d,a)?!1:(p(d),!0)}});b.add(()=>e?.destroy?.())}function B(){const e=E.slideover({title:"Add a widget",size:"md",position:"right"}),t=document.createElement("div");t.className="dbp-gallery",t.innerHTML=`<input type="search" class="form-input dbp-gallery-search" placeholder="Search widgets" aria-label="Search widgets">
|
|
22
|
+
<p class="dbp-gallery-hint">Widgets from the Tools you can open. Each one is added at its usual size - resize it from its \u22EE menu.</p>
|
|
23
|
+
<div class="dbp-gallery-list"></div>`,e.element.appendChild(t);const s=t.querySelector(".dbp-gallery-list"),o=t.querySelector(".dbp-gallery-search");function d(){const r=re(f,a,o.value);s.innerHTML=r.length?r.map(m=>`
|
|
24
|
+
<h4 class="dbp-gallery-group">${n(m.source)}</h4>
|
|
25
|
+
${m.widgets.map(u=>`
|
|
26
|
+
<div class="dbp-gallery-item">
|
|
27
|
+
<span class="dbp-icon"><span data-icon="${n(u.icon||"grid")}" data-icon-size="16"></span></span>
|
|
28
|
+
<span class="dbp-gallery-main"><strong>${n(u.title)}</strong><small>${n(u.description||"")}</small></span>
|
|
29
|
+
${u.added?'<span class="dbp-gallery-added">On the board</span>':`<button type="button" class="btn btn-sm btn-primary" data-add="${n(u.id)}">Add</button>`}
|
|
30
|
+
</div>`).join("")}`).join(""):'<p class="dbp-empty">No widgets match.</p>',I.scan(s)}s.addEventListener("click",r=>{const m=r.target.closest("[data-add]")?.dataset.add,u=m&&f.find(V=>V.id===m);u&&(p(ae(a,u)),E.toast(`${u.title} added.`,{type:"success",duration:2e3}),d())}),o.addEventListener("input",d),e.open(),d(),setTimeout(()=>o.focus(),50)}c.querySelector(".dbp-add-first").addEventListener("click",B),v(l,{id:"dashboard-add-widget",icon:"plus",title:"Add widget",onClick:B}),v(l,{id:"dashboard-fold-all",icon:"chevrons-up",title:"Fold or open every widget",onClick:()=>j(oe(a))}),v(l,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh every widget",onClick:()=>i.querySelectorAll(".dbp-card").forEach(h)}),v(l,{id:"dashboard-reset",icon:"rotate-ccw",title:"Reset to the site's default layout",onClick:async()=>{if(await E.confirm("Go back to the site's default layout? Your own arrangement is forgotten."))try{const e=await y("/dashboard/layout",{method:"DELETE"});a=e?.layout||a,w=a,k=e?.source||"built-in",C(),E.toast("Back to the default layout.",{type:"success"})}catch(e){E.toast(`Could not reset: ${e.message||e}`,{type:"error"})}}}),N&&v(l,{id:"dashboard-make-default",icon:"users",title:"Make this the default for everyone",onClick:async()=>{if(await E.confirm("Make this layout the default? Everyone who has not arranged their own Dashboard sees it. (Widgets they may not use are left out for them.)"))try{await y("/dashboard/layout/default",{method:"PUT",body:JSON.stringify(a)}),E.toast("This is now the default layout.",{type:"success"})}catch(e){E.toast(`Could not set the default: ${e.message||e}`,{type:"error"})}}}),b.add(Q(c,{position:"bottom"})),I.scan(c),await _(),D()&&C()}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as j,apiRequest as f}from"../api.js";import{addBannerAction as ie}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{openMenuAt as le,viewLifetime as Se}from"../lib/tool-kit.js";import{SHAPES as ce,showLoading as _}from"../lib/loading.js";import{BADGE_SOURCES as Ae,activityRows as Pe,attentionFrom as Re,canAny as B,chartSeries as Te,isSitePath as O,kpisFrom as Le}from"../lib/dashboard-arrange.js";const q=/^(#\/?)?(\?.*)?$/,je=3e4,_e="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js",Be={type:"custom",template:'<div class="skeleton skeleton-rect" style="flex:1;aspect-ratio:auto;height:240px"></div>'},Oe=`
|
|
2
2
|
<div class="dbx-row" data-id="{{id}}" data-href="{{href}}" data-url="{{url}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="dbx-main"><strong>{{text}}</strong><small data-bind-hidden="!meta">{{meta}}</small></span>
|
|
4
4
|
<span class="dbx-value" data-bind-hidden="!value">{{value}}</span>
|
|
5
5
|
<span class="dbx-chip" data-bind-hidden="!chip" data-bind-class="chipCls">{{chip}}</span>
|
|
6
6
|
<button type="button" class="dbx-kebab" aria-label="Actions" title="Actions" data-bind-hidden="!menu">\u22EE</button>
|
|
7
|
-
</div>`;let
|
|
7
|
+
</div>`;let v=null;function qe(){return window.Chart?Promise.resolve(!0):(v||(v=new Promise(i=>{const u=document.createElement("script");u.src=_e,u.async=!0,u.onload=()=>i(!0),u.onerror=()=>{v=null,i(!1)},document.head.appendChild(u)})),v)}const c=i=>Number(i||0).toLocaleString("en-GB"),Fe=i=>`${Number(i)>=0?"+":""}${Number(i).toFixed(0)}%`,g=(i,u,n,h={})=>({id:`${i}${u}`,text:n,meta:"",href:"",url:n,value:"",chip:"",chipCls:"",rowCls:"",menu:!0,...h}),d=Se(q);export const dashboardView={templateUrl:"/admin/js/templates/dashboard.html",async onMount(i){d.reset();const u=await f("/dashboard/pro",{method:"GET"}).catch(()=>null);if(!q.test(location.hash))return;if(u?.enabled){const{mountBoard:e}=await import("./dashboard-board.js");await e(i,d,{isAdmin:u.isAdmin===!0});return}const n=i.get(0).querySelector(".dbx")||i.get(0),h=()=>q.test(location.hash)&&n.isConnected,l=M.observable(null),y=M.observable({}),w=M.observable(!1),x=M.observable(""),k=M.observable(null),C=M.observable(Date.now()),m=M.observable(null),$=M.observable(!!window.Chart);let b=[],F=new Set;const de=M.computed(()=>Le(l.value,y.value)),ue=M.computed(()=>{const e=k.value;if(!e)return"";const a=Math.floor((C.value-e)/6e4);return a<1?"Updated just now":`Updated ${a} min ago`}),N=M.observable([]),D=M.observable([]),U=M.observable([]),G=M.observable([]),H=M.observable([]),K=M.observable([]),V=M.observable([]),z=M.observable([]);d.add(M.effect(()=>{const e=l.value,a=t=>t==="danger"?"is-danger":"is-warn";N.value=Re(y.value,e?.health?.checks||[]).map(t=>({id:t.id,text:`${t.title}: ${t.text}`,meta:t.meta,href:t.href,url:"",value:"",chip:t.tone==="danger"?"Urgent":"Check",chipCls:a(t.tone),rowCls:a(t.tone)+(t.href?"":" is-static"),menu:!!t.href})),D.value=Pe(e?.activity||[],C.value).map(t=>({id:t.id,text:t.text,meta:t.meta,href:t.href,url:t.url,value:"",chip:"",chipCls:"",rowCls:"",menu:!0})),U.value=(e?.topPages||[]).map((t,r)=>g("t",r,t.url,{value:c(t.hits),chip:t.deltaPct==null?"":Fe(t.deltaPct),chipCls:Number(t.deltaPct)>=0?"is-ok":"is-danger"})),G.value=(e?.spikes||[]).map((t,r)=>g("s",r,t.url,{value:c(t.hits),chip:`\xD7${Number(t.ratio)||0}`,chipCls:"is-warn"}));const s=e?.journeys;H.value=(s?.entry||[]).map((t,r)=>g("e",r,t.url,{value:c(t.count)})),K.value=(s?.exit||[]).map((t,r)=>g("x",r,t.url,{value:c(t.count)})),V.value=(s?.paths||[]).map((t,r)=>({id:`p${r}`,text:`${t.from} \u2192 ${t.to}`,meta:"",href:"",url:"",value:c(t.count),chip:"",chipCls:"",rowCls:"is-static",menu:!1}));const o={ok:["OK","is-ok"],warn:["Look","is-warn"],fail:["Failing","is-danger"]};z.value=(e?.health?.checks||[]).map((t,r)=>({id:`c${r}`,text:String(t.label||t.id),meta:Array.isArray(t.detail)?t.detail.slice(0,5).join(", "):"",href:"",url:"",value:String(t.value??""),chip:(o[t.level]||o.warn)[0],chipCls:(o[t.level]||o.warn)[1],rowCls:"is-static",menu:!1}))}));const he=M.computed(()=>!!l.value?.journeys?.totalSessions),pe=M.computed(()=>{const e=l.value?.journeys;return e?.totalSessions?`Bounce rate ${(Number(e.bounceRate)*100).toFixed(1)}%`:""}),S=M.computed(()=>Te(l.value?.traffic?.series)),A=M.computed(()=>$.value&&!S.value.empty),me=M.computed(()=>w.value?l.value?$.value?"No page views recorded yet.":"The chart could not load (Chart.js).":"Traffic needs an administrator.":"Loading\u2026"),fe=M.computed(()=>!!m.value?.enabled),be=M.computed(()=>{const e=m.value||{},a=(e.hits||0)+(e.misses||0),s=o=>{if(!o)return"never";const t=Math.max(0,Math.floor((Date.now()-new Date(o).getTime())/1e3));return t<60?`${t}s ago`:t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} h ago`:`${Math.floor(t/86400)} days ago`};return{hitRate:e.hitRate==null?"-":`${(e.hitRate*100).toFixed(1)}%`,hitsMisses:a?`${c(e.hits)} hits \xB7 ${c(e.misses)} misses`:"no visits yet",size:e.maxItems?`${c(e.size)} / ${c(e.maxItems)}`:c(e.size),driver:e.driver?`driver: ${e.driver}`:"",writes:c(e.writes),invalidations:`${c(e.invalidations)} invalidations`,cleared:s(e.lastClearedAt)}});let P=0;async function J(){const e=Ae.filter(s=>B(b,s.permission)),a=await Promise.all(e.map(s=>f(s.countUrl,{method:"GET"}).catch(()=>null)));h()&&(y.value=Object.fromEntries(e.map((s,o)=>[s.key,a[o]]).filter(([,s])=>s)))}async function W({first:e=!1}={}){const a=++P,s=e?[_(n.querySelectorAll(".dbx-kpi-value"),ce.figure),_(n.querySelectorAll(".dbx-loading"),{...ce.rows,count:3}),_(n.querySelector(".dbx-chart-note"),Be)]:[],o=()=>Promise.all(s.map(t=>t.done()));try{b=(await f("/auth/permissions",{method:"GET"}).catch(()=>({})))?.permissions||[];const[r,Ee,re]=await Promise.all([j.dashboard.summary().catch(L=>({__error:L})),f("/cache/status",{method:"GET"}).catch(()=>null),B(b,"pages")?j.pages.list().catch(()=>[]):Promise.resolve([]),J()]);if(await o(),a!==P||!h())return;F=new Set((Array.isArray(re)?re:[]).map(L=>L.urlPath).filter(Boolean)),r?.__error?(l.value=null,x.value=/403|forbidden/i.test(String(r.__error?.message))?"":`The dashboard could not load: ${r.__error.message}`):(l.value=r,x.value=r?.warnings?.length?`Some parts could not load: ${r.warnings.join(" \xB7 ")}`:""),m.value=Ee,k.value=Date.now()}finally{await o(),a===P&&(w.value=!0)}}let ve=0;async function ge(){if(!(!h()||!l.peek())){C.value=Date.now();try{const e=await j.dashboard.summaryLite();if(!h())return;const a={...l.peek()};e.traffic&&(a.traffic={...a.traffic,...e.traffic}),e.realtime&&(a.realtime=e.realtime),e.spikes&&(a.spikes=e.spikes),e.health&&(a.health={...a.health,status:e.health.status}),l.value=a,k.value=Date.now()}catch{}++ve%2===0&&J(),f("/cache/status",{method:"GET"}).then(e=>{h()&&(m.value=e)}).catch(()=>{})}}const ye=setInterval(ge,je);d.add(()=>clearInterval(ye));let p=null;const R=n.querySelector(".dbx-canvas"),we=()=>{const e=getComputedStyle(n),a=(s,o)=>e.getPropertyValue(s).trim()||o;return{line:a("--dm-primary","#5b8cff"),text:a("--dm-text-muted","#94a3b8"),grid:a("--dm-border","rgba(127,127,127,.2)")}},xe=e=>/^#[0-9a-f]{6}$/i.test(e)?`${e}26`:"transparent";function Z(){if(!A.peek()||!R?.isConnected||!R.offsetParent)return;const{labels:e,values:a}=S.peek(),{line:s,text:o,grid:t}=we();if(p){p.data.labels=e,p.data.datasets[0].data=a,p.update("none");return}p=new window.Chart(R,{type:"line",data:{labels:e,datasets:[{data:a,borderColor:s,backgroundColor:xe(s),fill:!0,tension:.3,pointRadius:0,borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8,color:o},grid:{color:t}},y:{beginAtZero:!0,ticks:{precision:0,color:o},grid:{color:t}}}}})}d.add(M.effect(()=>{S.value,A.value,requestAnimationFrame(Z)})),d.add(()=>{try{p?.destroy()}catch{}p=null});const Q=()=>[...document.body.classList].filter(e=>/^dm-(theme|custom)-/.test(e)).sort().join(" ");let X=Q();const Y=new MutationObserver(()=>{const e=Q();if(e!==X){X=e;try{p?.destroy()}catch{}p=null,requestAnimationFrame(Z)}});Y.observe(document.body,{attributes:!0,attributeFilter:["class"]}),d.add(()=>Y.disconnect());const T=e=>new URL(e,location.origin).href,ee=()=>B(b,"pages"),te=e=>O(e)&&F.has(e.split(/[?#]/)[0].replace(/\/$/,"")||"/");async function ae(e,a){try{await navigator.clipboard.writeText(e),E.toast(`${a} copied.`,{type:"success"})}catch{E.toast("Could not copy - select it and copy it by hand.",{type:"error"})}}function se(e){const{href:a,url:s}=e.dataset;if(a){location.hash=a;return}if(s&&ee()&&te(s)){location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`;return}s&&O(s)&&window.open(T(s),"_blank","noopener")}function ke(e){const{href:a,url:s}=e.dataset,o=[{header:(e.querySelector(".dbx-main strong")?.textContent||"").slice(0,60)}];return a&&o.push({label:"Open",icon:"arrow-right",action:()=>{location.hash=a}}),s&&O(s)?(o.push({label:"Open on the site",icon:"external-link",action:()=>window.open(T(s),"_blank","noopener")}),o.push({label:"Copy address",icon:"copy",action:()=>ae(T(s),"Address")}),ee()&&o.push({label:"Edit the page",icon:"edit",disabled:!te(s),action:()=>{location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`}})):s&&o.push({label:"Copy address",icon:"copy",action:()=>ae(s,"Address")}),o.length>1?o:[]}for(const e of[".dbx-attention",".dbx-activity",".dbx-top-pages",".dbx-spikes",".dbx-entry",".dbx-exit",".dbx-paths",".dbx-checks"]){const a=n.querySelector(e);a&&(a.innerHTML=Oe)}const Ce=M.applyBindings({summary:l,loaded:w,warning:x,asOf:ue,kpi:de,attention:N,activity:D,topPages:U,spikes:G,entry:H,exit:K,paths:V,checks:z,hasJourneys:he,bounce:pe,hasChart:A,chartNote:me,cache:m,cacheOn:fe,cacheView:be},n,{methods:{async toggleCache(e){const a=!!e?.target?.checked;try{m.value=await f("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:a})}),E.toast(`The cache is ${a?"on":"off"}.`,{type:"success"})}catch(s){e?.target&&(e.target.checked=!a),E.toast(s.message||"Could not change the cache.",{type:"error"})}},async clearCache(){if(await E.confirm("Clear the whole response cache? Pages render afresh until it fills again."))try{m.value=await f("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}}}});d.add(()=>Ce.dispose()),d.add($e(n,{position:"bottom"}));const Me=E.contextMenu(n,{cursor:"glyph",match:".dbx-row",inherit:!1,density:"compact",items:e=>ke(e)});d.add(()=>Me?.destroy?.());const oe=e=>{const a=e.target.closest(".dbx-row");if(!a||!n.contains(a))return;const s=e.target.closest(".dbx-kebab");if(s){le(s);return}se(a)},ne=e=>{const a=e.target.closest(".dbx-row");!a||!n.contains(a)||(e.key==="F10"&&e.shiftKey?(e.preventDefault(),le(a)):e.key==="Enter"&&e.target===a&&se(a))};n.addEventListener("click",oe),n.addEventListener("keydown",ne),d.add(()=>{n.removeEventListener("click",oe),n.removeEventListener("keydown",ne)}),ie(i,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>W()}),ie(i,{id:"dashboard-new-page",icon:"plus",title:"New page",onClick:()=>{location.hash="#/pages/new"}}),I.scan(n),qe().then(e=>{h()&&($.value=e)}),await W({first:!0}),h()&&I.scan(n)}};
|
package/admin/js/views/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{dashboardView as o}from"./dashboard.js";import{
|
|
1
|
+
import{dashboardView as o}from"./dashboard.js";import{myDashboardView as i}from"./my-dashboard.js";import{pagesView as r}from"./pages.js";import{pageEditorView as t}from"./page-editor.js";import{settingsView as e}from"./settings.js";import{themeView as m}from"./theme.js";import{notificationsView as p}from"./notifications.js";import{layoutsView as n}from"./layouts.js";import{mediaView as s}from"./media.js";import{loginView as f}from"./login.js";import{usersView as w}from"./users.js";import{userEditorView as c}from"./user-editor.js";import{pluginsView as V}from"./plugins.js";import{pluginCodeView as a}from"./plugin-code.js";import{documentationView as l}from"./documentation.js";import{tutorialsView as d}from"./tutorials.js";import{docViews as u}from"./doc-pages.js";import{pluginGuidesView as E,pluginGuideView as g}from"./plugin-guides.js";import{apiReferenceView as b}from"./api-reference.js";import{collectionsView as h}from"./collections.js";import{collectionEditorView as v}from"./collection-editor.js";import{collectionEntriesView as y}from"./collection-entries.js";import{formsView as j}from"./forms.js";import{formEditorView as k}from"./form-editor.js";import{formSubmissionsView as D}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as G}from"./actions-list.js";import{actionEditorView as P}from"./action-editor.js";import{proDocsView as R}from"./pro-docs.js";import{blocksView as S}from"./blocks.js";import{blockEditorView as A}from"./block-editor.js";import{componentsView as C}from"./components.js";import{componentEditorView as N}from"./component-editor.js";import{myProfileView as T}from"./my-profile.js";import{rolesView as U}from"./roles.js";import{roleEditorView as q}from"./role-editor.js";import{effectsView as z}from"./effects.js";import{searchView as B}from"./search.js";import{contentApiView as F}from"./content-api.js";import{notesView as H}from"./notes.js";import{todoView as I}from"./todo.js";import{calculatorView as J}from"../lib/calculator.js";import{analyticsView as K}from"./analytics.js";import{seoView as O}from"./seo.js";import{contactsView as Q}from"./contacts.js";import{callNewView as W}from"./call-new.js";import{menusView as X}from"./menus.js";import{contextMenusView as Y}from"./context-menus.js";import{contextMenuEditorView as Z}from"./context-menu-editor.js";import{menuEditorView as _}from"./menu-editor.js";import{menuLocationsView as $}from"./menu-locations.js";import{projectsView as oo}from"./projects.js";import{projectDetailView as io}from"./project-detail.js";import{projectSettingsView as ro}from"./project-settings.js";import{apiTokensView as to}from"./api-tokens.js";import{apiEndpointsView as eo}from"./api-endpoints.js";import{apiEndpointEditorView as mo}from"./api-endpoint-editor.js";const po={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:oo,projectDetail:io,projectSettings:ro,apiTokens:to,apiEndpoints:eo,apiEndpointEditor:mo,dashboard:o,myDashboard:i,pages:r,pageEditor:t,settings:e,theme:m,layouts:n,media:s,login:f,users:w,userEditor:c,plugins:V,pluginCode:a,documentation:l,tutorials:d,apiReference:b,collections:h,collectionEditor:v,collectionEntries:y,forms:j,formEditor:k,formSubmissions:D,viewsList:L,viewEditor:x,viewPreview:M,actionsList:G,actionEditor:P,proDocs:R,blocks:S,blockEditor:A,components:C,componentEditor:N,myProfile:T,roles:U,roleEditor:q,effects:z,search:B,contentApi:F,notes:H,todo:I,calculator:J,analytics:K,seo:O,contacts:Q,callNew:W,notifications:p,menus:X,contextMenus:Y,contextMenuEditor:Z,menuEditor:_,menuLocations:$,menusRedirect:po,pluginGuides:E,pluginGuide:g,...u};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{apiRequest as r}from"../api.js";import{viewLifetime as m}from"../lib/tool-kit.js";const o=/^#\/my-dashboard\/?(\?.*)?$/,a=m(o);export const myDashboardView={templateUrl:"/admin/js/templates/my-dashboard.html",async onMount(t){a.reset();const e=await r("/dashboard/pro",{method:"GET"}).catch(()=>null);if(!o.test(location.hash))return;const i=t.get(0).querySelector(".dbx")||t.get(0);if(!e?.enabled){i.innerHTML='<p class="dbp-empty">My Dashboard comes with Dashboard Pro, which is not switched on for this site. Find it in the Marketplace.</p>';return}const{mountBoard:s}=await import("./dashboard-board.js");await s(t,a,{isAdmin:e.isAdmin===!0})}};
|
package/package.json
CHANGED
|
@@ -12,6 +12,12 @@ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin
|
|
|
12
12
|
import {getHealth} from '../../services/health.js';
|
|
13
13
|
import {listVersions} from '../../services/versions.js';
|
|
14
14
|
import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
|
|
15
|
+
import {canAny as widgetCanAny, isDashboardPro} from '../../services/widgets.js';
|
|
16
|
+
import {getPermissionsForRoles} from '../../services/roles.js';
|
|
17
|
+
import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
|
|
18
|
+
import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
|
|
19
|
+
// The fixed Dashboard's own rules, so a widget says exactly what its card says.
|
|
20
|
+
import {BADGE_SOURCES, activityRows, attentionFrom, chartSeries} from '../../../admin/js/lib/dashboard-arrange.js';
|
|
15
21
|
|
|
16
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
23
|
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
@@ -290,6 +296,14 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
290
296
|
|
|
291
297
|
fastify.get('/dashboard/summary', guard, async (request) => {
|
|
292
298
|
const lite = request.query.lite === '1' || request.query.lite === 'true';
|
|
299
|
+
return buildSummary(lite);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The summary the fixed Dashboard reads - and, piece by piece, core's own
|
|
304
|
+
* widgets on the Dashboard Pro board (GET /dashboard/widget/:name below).
|
|
305
|
+
*/
|
|
306
|
+
async function buildSummary(lite) {
|
|
293
307
|
const warnings = [];
|
|
294
308
|
const safe = async (label, fn) => {
|
|
295
309
|
try { return await fn(); }
|
|
@@ -374,5 +388,76 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
374
388
|
const activity = (await safe('activity', () => buildActivity())) || [];
|
|
375
389
|
|
|
376
390
|
return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Core's own widgets (services/widgets.js CORE_WIDGETS), each in its kind's
|
|
395
|
+
* shape. Dashboard Pro only - a site without it answers 404, as for every
|
|
396
|
+
* widget route. "Needs attention" asks the same sidebar badges the fixed
|
|
397
|
+
* Dashboard does, as the signed-in user, so nobody sees a Tool they cannot open.
|
|
398
|
+
*/
|
|
399
|
+
fastify.get('/dashboard/widget/:name', {preHandler: [authenticate]}, async (request, reply) => {
|
|
400
|
+
if (!isDashboardPro()) return reply.status(404).send({error: 'Not found'});
|
|
401
|
+
const name = String(request.params.name || '');
|
|
402
|
+
const admin = getEffectiveLevel(request.user) <= 1;
|
|
403
|
+
if (name !== 'attention' && !admin) return reply.status(403).send({error: 'Administrators only'});
|
|
404
|
+
const fmtN = (n) => Number(n || 0).toLocaleString('en-GB');
|
|
405
|
+
|
|
406
|
+
if (name === 'attention') {
|
|
407
|
+
const permissions = getPermissionsForRoles(getEffectiveRoles(request.user), getEffectiveRegistry().map(r => r.key));
|
|
408
|
+
const want = BADGE_SOURCES.filter(src => widgetCanAny(permissions, src.permission));
|
|
409
|
+
const got = await Promise.all(want.map(src => fastify.inject({
|
|
410
|
+
method: 'GET', url: `/api${src.countUrl}`,
|
|
411
|
+
headers: {authorization: request.headers.authorization || ''}
|
|
412
|
+
}).then(r => (r.statusCode === 200 ? r.json() : null)).catch(() => null)));
|
|
413
|
+
const badges = Object.fromEntries(want.map((src, i) => [src.key, got[i]]).filter(([, b]) => b));
|
|
414
|
+
const checks = admin ? ((await getHealth().catch(() => null))?.checks || []) : [];
|
|
415
|
+
const rows = attentionFrom(badges, checks);
|
|
416
|
+
return {
|
|
417
|
+
items: rows.map(r => ({text: `${r.title}: ${r.text}`, meta: r.meta, href: r.href, tone: r.tone})),
|
|
418
|
+
empty: 'Nothing needs you right now.'
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const s = await buildSummary(false);
|
|
423
|
+
if (name === 'traffic') {
|
|
424
|
+
const c = chartSeries(s.traffic?.series);
|
|
425
|
+
return {labels: c.labels, values: c.values, label: 'Page views'};
|
|
426
|
+
}
|
|
427
|
+
if (name === 'visitors') {
|
|
428
|
+
const delta = s.traffic?.deltaPct;
|
|
429
|
+
return {
|
|
430
|
+
value: fmtN(s.realtime?.activeSessions), label: 'on the site now',
|
|
431
|
+
sub: `${fmtN(s.traffic?.today)} page views today${delta == null ? '' : ` (${delta >= 0 ? '+' : ''}${Number(delta).toFixed(0)}% on yesterday)`}`
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (name === 'top-pages') {
|
|
435
|
+
return {
|
|
436
|
+
items: (s.topPages || []).map(p => ({
|
|
437
|
+
text: p.url, value: fmtN(p.hits),
|
|
438
|
+
meta: p.deltaPct == null ? '' : `${p.deltaPct >= 0 ? '+' : ''}${Number(p.deltaPct).toFixed(0)}% on the week before`,
|
|
439
|
+
tone: p.deltaPct == null ? '' : (p.deltaPct >= 0 ? 'ok' : 'danger')
|
|
440
|
+
})),
|
|
441
|
+
empty: 'No page views in the last week.'
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (name === 'activity') {
|
|
445
|
+
return {
|
|
446
|
+
items: activityRows(s.activity || []).map(a => ({text: a.text, meta: a.meta, href: a.href})),
|
|
447
|
+
empty: 'Nothing has changed lately.'
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (name === 'health') {
|
|
451
|
+
const LEVEL = {ok: 'ok', warn: 'warn', fail: 'danger'};
|
|
452
|
+
return {
|
|
453
|
+
items: (s.health?.checks || []).map(c => ({
|
|
454
|
+
text: String(c.label || c.id), value: String(c.value ?? ''),
|
|
455
|
+
meta: Array.isArray(c.detail) ? c.detail.slice(0, 3).join(', ') : '',
|
|
456
|
+
tone: LEVEL[c.level] || 'warn'
|
|
457
|
+
})),
|
|
458
|
+
empty: 'No health checks ran.'
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return reply.status(404).send({error: 'Not found'});
|
|
377
462
|
});
|
|
378
463
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Pro - the widget list and the board layouts.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/dashboard/pro {enabled} - whether this site has the board (any signed-in user)
|
|
5
|
+
* GET /api/widgets the widgets this user may add (Pro)
|
|
6
|
+
* GET /api/dashboard/layout {layout, source: mine|site|built-in} (Pro)
|
|
7
|
+
* PUT /api/dashboard/layout save mine (Pro)
|
|
8
|
+
* DELETE /api/dashboard/layout go back to the site's default (Pro)
|
|
9
|
+
* GET /api/dashboard/layout/default the site default (Pro, admins)
|
|
10
|
+
* PUT /api/dashboard/layout/default set it (Pro, admins)
|
|
11
|
+
*
|
|
12
|
+
* Every route but /dashboard/pro answers 404 while no Pro Tool has switched the
|
|
13
|
+
* board on (services/widgets.js) - a free site has none of this.
|
|
14
|
+
*
|
|
15
|
+
* A layout is {version, widgets: [{id, size}]}, in board order. Mine is kept in
|
|
16
|
+
* content/dashboard/layouts/<user id>.json (ignored in a site repo - it is per
|
|
17
|
+
* user), the site default in content/dashboard/default.json (tracked).
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'fs/promises';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
|
|
22
|
+
import {getPermissionsForRoles} from '../../services/roles.js';
|
|
23
|
+
import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
|
|
24
|
+
import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_LAYOUT, getWidgets, isDashboardPro, layoutProblems, normaliseLayout, widgetsFor
|
|
27
|
+
} from '../../services/widgets.js';
|
|
28
|
+
|
|
29
|
+
const DIR = path.resolve('content', 'dashboard');
|
|
30
|
+
const LAYOUTS = path.join(DIR, 'layouts');
|
|
31
|
+
const DEFAULT_FILE = path.join(DIR, 'default.json');
|
|
32
|
+
const USER_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
33
|
+
|
|
34
|
+
async function readJson(file) {
|
|
35
|
+
try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Write via a temporary file, so a crash never leaves half a layout. */
|
|
39
|
+
async function writeJson(file, data) {
|
|
40
|
+
await fs.mkdir(path.dirname(file), {recursive: true});
|
|
41
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
42
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
43
|
+
await fs.rename(tmp, file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const userFile = (user) => (USER_ID_RE.test(String(user?.id || '')) ? path.join(LAYOUTS, `${user.id}.json`) : null);
|
|
47
|
+
|
|
48
|
+
/** The site default, or the built-in one. */
|
|
49
|
+
async function siteDefault() {
|
|
50
|
+
const stored = await readJson(DEFAULT_FILE);
|
|
51
|
+
return stored ? {layout: normaliseLayout(stored), source: 'site'} : {layout: normaliseLayout(DEFAULT_LAYOUT), source: 'built-in'};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Who is asking, in the terms widgetsFor() takes. */
|
|
55
|
+
function who(user) {
|
|
56
|
+
const permissions = getPermissionsForRoles(getEffectiveRoles(user), getEffectiveRegistry().map(r => r.key));
|
|
57
|
+
return {permissions, isAdmin: getEffectiveLevel(user) <= 1};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function widgetRoutes(fastify, opts = {}) {
|
|
61
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
62
|
+
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
63
|
+
const pro = async (request, reply) => {
|
|
64
|
+
if (!isDashboardPro()) return reply.status(404).send({error: 'Not found'});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// isAdmin: whether this user may set the site's default layout.
|
|
68
|
+
fastify.get('/dashboard/pro', {preHandler: [authenticate]}, async (request) => ({
|
|
69
|
+
enabled: isDashboardPro(),
|
|
70
|
+
isAdmin: getEffectiveLevel(request.user) <= 1
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
fastify.get('/widgets', {preHandler: [authenticate, pro]}, async (request) => widgetsFor(getWidgets(), who(request.user)));
|
|
74
|
+
|
|
75
|
+
fastify.get('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
|
|
76
|
+
const file = userFile(request.user);
|
|
77
|
+
const mine = file ? await readJson(file) : null;
|
|
78
|
+
return mine ? {layout: normaliseLayout(mine), source: 'mine'} : siteDefault();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
fastify.put('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request, reply) => {
|
|
82
|
+
const problems = layoutProblems(request.body);
|
|
83
|
+
if (problems.length) return reply.status(400).send({error: problems[0]});
|
|
84
|
+
const file = userFile(request.user);
|
|
85
|
+
if (!file) return reply.status(400).send({error: 'Your account cannot keep a layout.'});
|
|
86
|
+
const layout = normaliseLayout(request.body);
|
|
87
|
+
await writeJson(file, layout);
|
|
88
|
+
return {layout, source: 'mine'};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
fastify.delete('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
|
|
92
|
+
const file = userFile(request.user);
|
|
93
|
+
if (file) await fs.rm(file, {force: true});
|
|
94
|
+
return siteDefault();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
fastify.get('/dashboard/layout/default', {preHandler: [authenticate, pro, requireAdmin]}, async () => siteDefault());
|
|
98
|
+
|
|
99
|
+
fastify.put('/dashboard/layout/default', {preHandler: [authenticate, pro, requireAdmin]}, async (request, reply) => {
|
|
100
|
+
const problems = layoutProblems(request.body);
|
|
101
|
+
if (problems.length) return reply.status(400).send({error: problems[0]});
|
|
102
|
+
const layout = normaliseLayout(request.body);
|
|
103
|
+
await writeJson(DEFAULT_FILE, layout);
|
|
104
|
+
return {layout, source: 'site'};
|
|
105
|
+
});
|
|
106
|
+
}
|
package/server/server.js
CHANGED
|
@@ -523,6 +523,7 @@ const {effectsRoutes} = await import('./routes/api/effects.js');
|
|
|
523
523
|
const {searchRoutes} = await import('./routes/api/search.js');
|
|
524
524
|
const {notificationsRoutes} = await import('./routes/api/notifications.js');
|
|
525
525
|
const {dashboardRoutes} = await import('./routes/api/dashboard.js');
|
|
526
|
+
const {widgetRoutes} = await import('./routes/api/widgets.js');
|
|
526
527
|
const {cacheRoutes} = await import('./routes/api/cache.js');
|
|
527
528
|
const {scaffoldRoutes} = await import('./routes/api/scaffold.js');
|
|
528
529
|
|
|
@@ -558,6 +559,7 @@ await app.register(effectsRoutes, {prefix: '/api'});
|
|
|
558
559
|
await app.register(searchRoutes, {prefix: '/api'});
|
|
559
560
|
await app.register(notificationsRoutes, {prefix: '/api'});
|
|
560
561
|
await app.register(dashboardRoutes, {prefix: '/api'});
|
|
562
|
+
await app.register(widgetRoutes, {prefix: '/api'});
|
|
561
563
|
await app.register(cacheRoutes, {prefix: '/api'});
|
|
562
564
|
await app.register(scaffoldRoutes, {prefix: '/api'});
|
|
563
565
|
// Tools promoted out of plugins/ - their own prefixes, not /api/plugins/*.
|
|
@@ -38,6 +38,7 @@ import {registerThemeProvider} from './themeSettings.js';
|
|
|
38
38
|
import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
|
|
39
39
|
import {notifierFor} from './notify.js';
|
|
40
40
|
import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
|
|
41
|
+
import {disableDashboardPro, enableDashboardPro, registerWidget, unregisterWidgetsFrom} from './widgets.js';
|
|
41
42
|
import {createCollection, getCollection} from './collections.js';
|
|
42
43
|
import * as defaultRolesService from './roles.js';
|
|
43
44
|
import {getAdminScope, normaliseAdminScope} from './roles.js';
|
|
@@ -560,6 +561,10 @@ export async function registerPlugins(fastify) {
|
|
|
560
561
|
// them on at boot. Bound to the plugin's name, tier and closedSource: only a paid plugin that
|
|
561
562
|
// loads under a licence may.
|
|
562
563
|
enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
|
|
564
|
+
// Dashboard Pro (services/widgets.js) - the widget board; the same contract as instant calls.
|
|
565
|
+
enableDashboardPro: () => enableDashboardPro({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
|
|
566
|
+
// A widget from code (anything richer than a manifest's admin.widgets entry), owned by this plugin.
|
|
567
|
+
registerWidget: (def) => registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name}),
|
|
563
568
|
// Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
|
|
564
569
|
registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
|
|
565
570
|
// Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
|
|
@@ -594,6 +599,14 @@ export async function registerPlugins(fastify) {
|
|
|
594
599
|
}
|
|
595
600
|
}
|
|
596
601
|
|
|
602
|
+
// Dashboard widgets the manifest offers (services/widgets.js) - drawn by the admin
|
|
603
|
+
// from their `kind` and `dataUrl`, so a Tool needs no code to have one.
|
|
604
|
+
for (const def of manifest.admin?.widgets || []) {
|
|
605
|
+
if (!registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name})) {
|
|
606
|
+
fastify.log.warn(`[plugins] widget "${def?.id}" for "${manifest.name}" skipped: not a valid widget`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
597
610
|
// Ensure plugin collections exist (idempotent - skips if already present)
|
|
598
611
|
await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
|
|
599
612
|
.catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
|
|
@@ -1123,6 +1136,8 @@ export async function runLifecycleHook(name, hook, fastify) {
|
|
|
1123
1136
|
// A Tool with calls switched off stops keeping instant calls on - at once, not at the next restart.
|
|
1124
1137
|
if (hook === 'onDisable') {
|
|
1125
1138
|
disableInstantCalls(name);
|
|
1139
|
+
disableDashboardPro(name);
|
|
1140
|
+
unregisterWidgetsFrom(name);
|
|
1126
1141
|
unregisterContactAccess(name);
|
|
1127
1142
|
unregisterDirectMessenger(name);
|
|
1128
1143
|
}
|
|
@@ -317,6 +317,8 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
|
|
|
317
317
|
'/content/.migrations.json',
|
|
318
318
|
'/content/instances_log.json',
|
|
319
319
|
'/content/preview-links.json',
|
|
320
|
+
// Each admin's own Dashboard Pro layout (keyed by user id); the site default beside it is tracked.
|
|
321
|
+
'/content/dashboard/layouts/',
|
|
320
322
|
'/config/connections.json*',
|
|
321
323
|
'/config/keys/',
|
|
322
324
|
'/plugins/*/data/*.jsonl',
|