1import{B as e,Ct as t,M as n,R as r,St as i,kt as a,ot as o,st as s}from"./CzsObDY8.js";import"./xihTtKlq.js";import"./C5qy-xbg.js";var c=a({default:()=>p,metadata:()=>l}),l={title:`Admin UI Extensions`,description:`Extend the PocketBase superuser dashboard with custom pages, using pb_hooks registration and pb_admin_ext client assets on PocketHost`},{title:u,description:d}=l,f=e(`<h1>Admin UI Extensions</h1> <p>PocketBase <strong>0.37+</strong> ships a rewritten superuser dashboard with an experimental <strong>UI extension</strong> system. You can add custom admin pages, header links, collection tabs, and field types without forking PocketBase or building a separate SPA.</p> <p>This is different from <a href="/docs/js">server-side hooks</a>:</p> <table><thead><tr><th>Layer</th><th>Runs in</th><th>Purpose</th></tr></thead><tbody><tr><td><strong>Server registration</strong></td><td>JSVM (<code>pb_hooks</code>)</td><td>Mount static files and register the extension with PocketBase</td></tr><tr><td><strong>Client code</strong></td><td>Browser (admin SPA)</td><td><code>main.js</code> uses global <code>window.app</code> for routes, data, and UI</td></tr></tbody></table> <p>Customer instances on PocketHost need PocketBase <strong>â¥0.37</strong>. The mothership control plane runs <strong>0.39</strong>.</p> <h2>Directory layout</h2> <p>Place extension assets next to your hooks. PocketHost mounts both via <a href="/docs/ftp">SFTP</a> or <a href="/docs/phio">phio</a>:</p> <pre class="hljs language-plaintext"></pre> <p>On the mothership repo, registration hooks are bundled by tsdown into <code>pb_hooks/mothership.pb.js</code>. Static trees under <code>pb_admin_ext/</code> are plain files and must ship beside <code>pb_hooks/</code> on deploy.</p> <h2>Server registration</h2> <p>Register the extension in an <code>onServe</code> hook. Each <code>name</code> becomes a URL segment under <code>/_/extensions/{name}/</code>.</p> <pre class="hljs language-js"></pre> <p>PocketBase automatically exposes:</p> <table><thead><tr><th>Route</th><th>Purpose</th></tr></thead><tbody><tr><td><code>GET /_/extensions/{name}/{path...}</code></td><td>Static files from the extension directory</td></tr><tr><td><code>GET /_/extensions.js</code></td><td>Concatenated <code>main.js</code> from all registered extensions</td></tr></tbody></table> <p>Restart PocketBase after changing <strong>registration</strong> (the hook). Edits to <code>main.js</code> alone often show up after a browser refresh because PocketBase rebuilds <code>/_/extensions.js</code> from disk on each request.</p> <h2>Client entry (<code>main.js</code>)</h2> <p>Client code runs in the <strong>browser</strong>. Modern JavaScript is fine (<code>async</code>/<code>await</code>, DOM APIs). It is <strong>not</strong> subject to JSVM restrictions.</p> <pre class="hljs language-js"></pre> <p>Load extension CSS with paths under <code>/_/extensions/{name}/</code>:</p> <pre class="hljs language-js"></pre> <p>Use <code>app.pb</code> for data access. It inherits superuser auth from the admin session.</p> <h2>Deploy on PocketHost</h2> <ol><li>Upload <code>pb_hooks/*.pb.js</code> and <code>pb_admin_ext/**</code> together via SFTP or phio.</li> <li>Restart the instance (or mothership) so hook registration loads cleanly.</li> <li>Open the admin UI (<code>/_/</code>), sign in as superuser, and check for your header link.</li></ol> <p>Verify from the command line:</p> <pre class="hljs language-bash"></pre> <p><code>/_/extensions.js</code> should return JavaScript with a non-zero body when <code>main.js</code> exists.</p> <h2>Cloudflare caching (important)</h2> <p>PocketHost serves admin traffic through <strong>Cloudflare</strong>. In production (non-<code>--dev</code>), PocketBase sets a long cache header on most <code>/_/*</code> static routes:</p> <pre class="hljs language-plaintext"></pre> <p>That is <strong>14 days</strong>. Cloudflare honors it.</p> <p><code>/_/extensions.js</code> is rebuilt from disk on every origin request, but the CDN can still serve a <strong>stale cached copy</strong> for days. Symptom: the admin UI loads, <code>/_/extensions.js</code> returns <strong>200</strong>, but the body is <strong>empty</strong> or outdated after you deploy a new plugin.</p> <p>This bit us on the mothership <strong>Live</strong> operator dashboard. The plugin was registered correctly on the server. Cloudflare
1kept serving an empty cached bundle from before registration existed.</p> <h3>Fix: bypass cache for extension routes</h3> <p>In the Cloudflare dashboard for your zone, add a <strong>Cache Rule</strong>:</p> <p><img src="/generated/docs/admin-extensions/2026-06-16_20-25-07.png" alt="Cloudflare Cache Rule â bypass cache for /_/extensions.js and /_/extensions/*"/></p> <p><strong>If</strong> (custom expression):</p> <pre class="hljs language-plaintext"></pre> <p>Scope to your hostname when possible (mothership or instance custom domain):</p> <pre class="hljs language-plaintext"></pre> <p><strong>Then:</strong> Cache eligibility â <strong>Bypass cache</strong></p> <p>Both paths matter:</p> <ul><li><code>/_/extensions.js</code> is the bundled entry the admin SPA loads.</li> <li><code>/_/extensions/*</code> serves static assets (<code>style.css</code>, images).</li></ul> <p>The wildcard <code>/_/extensions/*</code> does <strong>not</strong> match <code>/_/extensions.js</code>. You need both conditions.</p> <p>After saving the rule, <strong>purge cache once</strong> for <code>/_/extensions.js</code>. An old HIT will not clear itself.</p> <p>Verify:</p> <pre class="hljs language-bash"></pre> <p>You want <code>BYPASS</code> or <code>MISS</code>, not <code>HIT</code> with an old <code>age</code>.</p> <h3>Local dev</h3> <p>When you run mothership with <code>--dev</code>, PocketBase skips the 14-day cache header on <code>/_/*</code>. Local testing without Cloudflare will not reproduce the CDN issue.</p> <h2>Realtime vs polling in client code</h2> <p>Admin plugin routes use Shablon reactive rendering. If your route callback re-runs setup code on every render, you can accidentally fire API calls in a loop.</p> <p>For fleet-scale data (thousands of instances):</p> <ul><li>Subscribe to <strong>small collections</strong> (e.g. <code>edges</code>) with realtime.</li> <li>Poll <strong>aggregate counts</strong> on an interval instead of subscribing to <code>instances/*</code> and re-querying on every event.</li> <li>Run initialization once per page visit, not on every reactive update.</li></ul> <h2>API status</h2> <p>Admin UI extensions are <strong>experimental</strong> in PocketBase as of 0.37â0.39. Expect API shape changes before v1.0. Inspect <code>console.log(app)</code> in DevTools on your target PocketBase version.</p> <h2>Related docs</h2> <ul><li><a href="/docs/js">Extending via JS</a> â JSVM hooks (server-side, not admin SPA)</li> <li><a href="/docs/ftp">SFTP File Access</a> â upload hooks and extension files</li> <li><a href="/docs/phio">phio CLI</a> â deploy hooks and sibling directories</li> <li><a href="/docs/static-assets">Publishing Static Assets</a> â Cloudflare caching for <code>pb_public</code></li></ul>`,1);function p(e){var a=f(),c=s(o(a),14);n(c,()=>`<code class="hljs language-plaintext">pb_hooks/ 2 admin_plugins<span class="hljs-selector-class">.pb</span><span class="hljs-selector-class">.js</span> â server registration (JSVM) 3pb_admin_ext/ 4 my-plugin/ 5 <span class="hljs-selector-tag">main</span><span class="hljs-selector-class">.js</span> â client entry (required <span class="hljs-keyword">for</span> UI hooks) 6 style<span class="hljs-selector-class">.css</span> â optional static assets</code>`,!0),t(c);var l=s(c,8);n(l,()=>`<code class="hljs language-js"><span class="hljs-comment">// pb_hooks/admin_plugins.pb.js</span> 7$app.<span class="hljs-title function_">onServe</span>().<span class="hljs-title function_">bindFunc</span>(<span class="hljs-function">(<span class="hljs-params">e</span>) =></span> { 8 e.<span class="hljs-property">uiExtensions</span>.<span class="hljs-title function_">push</span>({ 9 <span class="hljs-attr">name</span>: <span class="hljs-string">'my-plugin'</span>, 10 <span class="hljs-attr">fs</span>: $os.<span class="hljs-title function_">dirFS</span>(<span class="hljs-string">`<span class="hljs-subst">${__hooks}</span>/../pb_admin_ext/my-plugin`</span>), 11 }) 12 e.<span class="hljs-title function_">next</span>() 13})</code>`,!0),t(l);var u=s(l,12);n(u,()=>`<code class="hljs language-js"><span class="hljs-comment">// pb_admin_ext/my-plugin/main.js</span> 14app.<span class="hljs-property">store</span>.<span class="hljs-property">headerLinks</span>.<span class="hljs-title function_">push</span>({ 15 <span class="hljs-attr">href</span>: <span class="hljs-string">'#/my-plugin'</span>, 16 <span class="hljs-attr">icon</span>: <span class="hljs-string">'ri-pulse-line'</span>, 17 <span class="hljs-attr">label</span>: <span class="hljs-string">'My Plugin'</span>, 18}) 19 20app.<span class="hljs-property">routes</span>.<span class="hljs-title function_">superuserOnly</span>(<span class="hljs-string">'#/my-plugin'</span>, <span class="hljs-function">() =></span> { 21 <span class="hljs-keyword">return</span> t.<span class="hljs-title function_">div</span>({ <span class="hljs-attr">className</span>: <span class="hljs-string">'page'</span> }, t.<span class="hljs-title function_">h1</span>(<span class="hljs-literal">null</span>, <span class="hljs-string">'Hello from an admin plugin'</span>)) 22})</code>`,!0),t(u);var d=s(u,4);n(d,()=>`<code class="hljs language-js"><span class="hljs-variable language_">document</span>.<span class="hljs-property">head</span>.<span class="hljs-title function_">appendChild</span>( 23 t.<span class="hljs-title function_">link</span>({ 24 <span class="hljs-attr">rel</span>: <span class="hljs-string">'
24stylesheet'</span>, 25 <span class="hljs-attr">href</span>: <span class="hljs-string">'/_/extensions/my-plugin/style.css'</span>, 26 }) 27)</code>`,!0),t(d);var p=s(d,10);n(p,()=>`<code class="hljs language-bash">curl -sI https://your-instance.pockethost.io/_/extensions.js | grep -iE <span class="hljs-string">'content-length|cache-control'</span> 28curl -s https://your-instance.pockethost.io/_/extensions/my-plugin/main.js | <span class="hljs-built_in">head</span></code>`,!0),t(p);var m=s(p,8);n(m,()=>`<code class="hljs language-plaintext">Cache-Control: <span class="hljs-attribute">max-age</span>=1209600, <span class="hljs-attribute">stale-while-revalidate</span>=86400</code>`,!0),t(m);var h=s(m,16);n(h,()=>`<code class="hljs language-plaintext">(http.request.uri.path eq <span class="hljs-string">"/_/extensions.js"</span>) <span class="hljs-keyword">or</span> (http.request.uri.path wildcard <span class="hljs-string">r"/_/extensions/*"</span>)</code>`,!0),t(h);var g=s(h,4);n(g,()=>`<code class="hljs language-plaintext">(http.host eq <span class="hljs-string">"pockethost-central.pockethost.io"</span>) <span class="hljs-keyword">and</span> ( 29 (http.request.uri.path eq <span class="hljs-string">"/_/extensions.js"</span>) <span class="hljs-keyword">or</span> 30 (http.request.uri.path wildcard <span class="hljs-string">r"/_/extensions/*"</span>) 31)</code>`,!0),t(g);var _=s(g,14);n(_,()=>`<code class="hljs language-bash">curl -sI https://your-host/_/extensions.js | grep -i cf-cache-status</code>`,!0),t(_),i(22),r(e,a)}export{p as n,c as t};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.