Agora
Dashboard

Dashboard

The management console that ships inside @adonis-agora/media — a React SPA plus a JSON API to browse buckets, inspect media records, watch resumable uploads, upload objects, and copy/move/delete across buckets, over the real disk and session-store surfaces.

The management console for your media: browse your buckets, watch resumable uploads in progress, upload new objects, and copy / move / delete objects across buckets. It's a small React SPA served by a thin AdonisJS provider, themed to Agora design tokens (light + dark).

Nothing about storage is reimplemented: the console consumes the real media surface — disk list / stat / copy / move / deleteMany (from the S3 disk's extended operations) and the resumable UploadSessionStore.list() — through provider-registered routes, and reuses @adonis-agora/media-react for the upload UI.

Ships embedded — no separate install

The console is built into @adonis-agora/media itself. node ace configure @adonis-agora/media registers its provider for you — nothing else to add. @adonis-agora/media-dashboard still exists as a standalone package (its build is the console's source, and its own provider still works as a thin delegate) for hosts that prefer registering it explicitly — see Standalone install below.


Install

adonisrc.ts
providers: [
  () => import('@adonis-agora/media/media_provider'),
  () => import('@adonis-agora/media/dashboard_provider'),
]

node ace configure @adonis-agora/media does this for you automatically, alongside publishing config/media_dashboard.ts.

Configure — config/media_dashboard.ts

config/media_dashboard.ts
import { defineConfig } from '@adonis-agora/media/dashboard'
import { middleware } from '#start/kernel'

export default defineConfig({
  basePath: '/media/dashboard',   // where the SPA mounts (default)
  actions: true,                  // enable copy/move/delete (default: false — read-only)
  disks: ['s3', 'backups'],       // browsable disks (default: derived from media config)
  middleware: middleware.auth(),  // gate the whole console — SPA + API
})
KeyTypeDefaultWhat it controls
basePathstring/media/dashboardWhere the SPA is mounted.
apiBasePathstring<basePath>/apiWhere the JSON API is mounted.
actionsbooleanfalseEnable mutating actions (copy / move / delete). Off = read-only browser.
disksstring[]derived from media configDisk names the console may browse / act on.
uploadsPrefixstring/media/uploadsMatch media.uploads.routes.prefix.
tusPrefixstring/media/uploads/tusMatch media.uploads.resumable.routes.prefix.
middlewarehandler(s)Host auth applied to the whole console (SPA + API).
enabledbooleantrueMount the console at all. false registers the provider — so its config type stays available — without exposing a single route.
authConsoleAuthOptionsBuilt-in session-cookie login. See Console authentication.
objectInsightsObjectInsightProvider[][]Host-supplied annotations rendered in the object preview. See below.
objectUrls'auto' | 'proxy''auto'Where the url reported for an object's bytes points — the store (signed), or this console's own /object/raw. See below.

enabled: false is the per-environment switch: keep the provider registered (and config/media_dashboard.ts type-checked) in every environment, and let the config decide where the console actually exists. It short-circuits in boot(), before any route is registered — not a 403, simply nothing mounted.

The console is a read-only browser by default. Flipping actions: true enables copy / move / delete, folder operations, record deletion, upload aborts and the console upload — gate the console with middleware, auth, or both before you do. Mounting the SPA under any basePath needs no rebuild: the provider rewrites Vite's asset base and injects the runtime bootstrap (window.__MEDIA_DASHBOARD__) into index.html at serve time.


Routes

The provider registers a JSON API under apiBasePath (default <basePath>/api). Everything below runs behind your middleware and, when auth is configured, the built-in session guard — a request must pass both.

With four deliberate exceptions. The auth routes are mounted outside both gates, because they are what creates the session the guard checks for. A session hook reads your app's own auth directly off the raw request, so it needs no help from middleware; gating the mint behind the guard it mints for would be a closed loop.

MethodPathBacked by
GET/meSession probe. { authRequired: false } when auth is unset; the signed-in user; or 401 carrying the offered login modes. Ungated.
POST/loginMode B — credentials → session cookie. 404 when no login hook. Ungated.
POST/sessionMode A — mint from your app's own auth on the raw request. 404 when no session hook. Ungated.
POST/logoutClears the cookie. 204 always. Ungated.
GET/topologycapability probe ({ disks, hasUploads, actions })
GET/disksStorageManager + DiskCapabilities
GET/objects?disk&prefix&after&firstdisk list (cursor pagination)
GET/object?disk&keydisk stat + the object's url (signed, or the proxy — see objectUrls)
POST/object?disk&key&typeraw-body object upload (the console's convenience upload) — actions only
GET/object/raw?disk&keystreams the object's bytes through the app, content-disposition: inline
GET/object/insights?disk&keythe registered object-insight providers
GET/uploads?disk&prefixResumableUploadManager.list()
GET/uploads/:idone session in full, plus its recorded parts
POST/uploads/:id/abortdiscard an in-flight session — actions only
GET/collections?collection&ownerType&ownerId&prefix&after&firstMediaStore.list, cursor-paginated (see Collections view)
GET/collections/summaryper-collection rollup (count + total bytes) for the filter chips
GET/media-record?idone MediaRecord plus a signed URL per conversion
POST/media-record/deleteMediaStore.deleteactions only
POST/copy · /movedisk copy / move (streamed for cross-disk) — actions only
POST/deletedisk deleteManyactions only
POST/foldercreate a zero-byte folder marker — actions only
POST/folder/deleterecursively delete a folder — actions only
POST/folder/copy · /folder/moverecursively copy / move a folder (same or cross disk) — actions only

The actions-only routes are always mounted; the gate is enforced one layer down, in DashboardService, which throws a 403 when actions is false. So a read-only console needs no conditional routing — the routes exist and refuse.

Two upload paths coexist, on purpose. POST /object is the console's own convenience upload: raw bytes as application/octet-stream (so your app's body parsers never consume the stream), the real MIME riding as the type query param, buffered in memory and therefore capped at 100 MB. Anything larger belongs on the core @adonis-agora/media TUS / direct routes, which the console drives through @adonis-agora/media-react.

GET /object/raw exists for the preview: a signed URL is fine for an image tag, but streaming through the app is what lets the lightbox read a text/CSV body without a cross-origin fetch, and what works at all on a disk the browser cannot reach directly.


Cursor pagination

The two listing routes — GET /objects and GET /collections — page through the cursor interface shared by every @adonis-agora/* library (it mirrors @adonis-agora/filter's CursorParams / CursorPage, structurally: the console does not depend on that package, it just refuses to invent a second vocabulary for the same idea).

Query paramMeaning
afterOpaque cursor from the previous page's nextCursor. Omit for the first page.
firstPage size (the console SPA asks for 50).

Every paginated response carries the same envelope:

{
  // …the page's payload: `items` for /collections, `folders` + `files` for /objects
  "nextCursor": "eyJ…",  // hand back as ?after= — null on the last page
  "prevCursor": null,    // always null here — see below
  "hasNext": true,
  "hasPrev": false       // always false here — see below
}

Forward-only, by the backend's nature

This console pages forward only. Its listings are backed by S3's ListObjectsV2 continuation token (and, for /collections, an equally forward-only createdAt/id keyset): an opaque handle to "what comes next" with no inverse and no notion of an offset. So before / last are not accepted — rather than accepted and silently ignored — and prevCursor / hasPrev are always null / false. They stay in the response so the shape is the ecosystem's CursorPage, not a near-miss of it. Walk forward and keep the pages you've seen (which is exactly what the SPA does).

The cursor is opaque: never parse it, never construct one — hand a nextCursor straight back as after.


Object URLs

Every object the console shows has a url: what "Open ↗" links to, and what the image / video / audio previews set as their src. By default (objectUrls: 'auto') that is a short-lived signed URL straight to the object store — a real link, one that survives a copy-paste out of the console and needs nothing from your app to resolve.

That default assumes the browser can reach the store. On a deployment where it cannot — a private bucket on an internal network, a MinIO addressed by an in-cluster hostname, a policy against client-to-bucket traffic — the signed URL is minted against the internal endpoint and resolves nowhere in a browser (ERR_NAME_NOT_RESOLVED, or a CORS wall). Set objectUrls: 'proxy' and the console reports its own same-origin <apiBasePath>/object/raw instead, so every byte travels through your app, under the same auth as the rest of the console:

config/media_dashboard.ts
export default defineConfig({
  basePath: '/media',
  objectUrls: 'proxy',
})

The trade-off is the mirror image of the default: a proxied URL only means something to a session that can reach your app, and your app pays the bandwidth. The PDF and text previews are unaffected either way — they always read through /object/raw, because rendering them needs same-origin bytes.

When objectUrls is unset, the console follows your core media config: delivery.mode: 'proxy' in config/media.ts implies 'proxy' here — a host already streaming every read through the app has declared its store unreachable from a browser, so the console does the same without being told twice. Anything else keeps 'auto'. An explicit objectUrls always wins over the delivery mode.

This is the same option, with the same two values, as the NestJS sibling console.


Folder operations

Object stores are flat key spaces with no real directories — a "folder" is just the /-delimited prefix that delimiter listing rolls keys up under. The console makes that browsable and editable through four actions-gated routes. All take a JSON body with a disk; copy/move add from / to (and an optional toDisk to cross disks).

RouteBodyBehaviour
POST /folder{ disk, prefix }Create. Writes a zero-byte marker object at <prefix>/, which delimiter listing then surfaces as a navigable, empty folder. The prefix is normalized to exactly one trailing slash (no double-slash keys); an empty prefix is rejected 400.
POST /folder/delete{ disk, prefix }Recursive delete. Sweeps every object under <prefix>/ — nested included — with an empty delimiter so the driver lists keys flat, paginates until the bucket is exhausted, deleteManys each page, then removes the marker itself.
POST /folder/copy{ disk, from, to, toDisk? }Recursive copy. Relocates every key under <from>/ to <to>/, preserving each key's path relative to the source, and writes the destination marker. The source is left intact.
POST /folder/move{ disk, from, to, toDisk? }Recursive move. Same recursive relocation, then drops the source objects and marker.

The recursive copy/move share one engine with the single-object /copy · /move: each key transfers same-disk via the driver's native server-side copy / move (no bytes through the app), or cross-disk by streaming get→put through the app (drivers can't copy across disks), capped per object at 100 MB. A same-disk move/copy whose destination sits inside the source is rejected 400 (it would recurse forever); across disks that can't happen.

Folder actions need `actions: true`

Like copy / move / delete, every folder route is gated on actions: true and returns 403 when the console is read-only. Recursive delete and move are destructive and irreversible — keep the whole console behind your middleware auth guard.


Object insights

The console can only describe a file the way storage sees it: key, size, content type, last modified. Everything that makes the file mean something — which work order this scan belongs to, which knowledge base indexed it, who uploaded it — lives in your app.

objectInsights is the seam for handing that back. You register providers; the console asks them whenever an object is previewed and renders whatever comes back:

config/media_dashboard.ts
import { defineConfig } from '@adonis-agora/media/dashboard'
import Document from '#models/document'

export default defineConfig({
  objectInsights: [
    {
      id: 'documents',
      async resolve({ disk, key }) {
        const doc = await Document.findBy('storageKey', key)
        if (!doc) return null // nothing to say about this object

        return {
          title: 'Document',
          facts: [
            { label: 'Title', value: doc.title },
            { label: 'Owner', value: doc.ownerEmail },
            { label: 'Indexed', value: doc.indexedAt?.toISO() ?? 'not yet' },
          ],
          links: [{ label: 'Open in app', href: `/documents/${doc.id}` }],
          note: 'Deleting this object leaves the document row orphaned.',
        }
      },
    },
  ],
})

Three properties of the design are worth being explicit about:

  • Data, not components. The console ships as a prebuilt SPA bundle, so a host cannot inject React into it. A provider returns a small structured value (facts, links, a note) the console knows how to render.
  • Failures are invisible. A provider that throws is skipped and logged; providers run concurrently so one slow lookup doesn't serialize the rest. Annotation must never be able to stop an admin opening a file.
  • Links are sanitized. Every insight passes through sanitizeInsight before it reaches the browser: a relative /… href is kept, an absolute one only if it is http:/https:. javascript:, data: and protocol-relative //evil.com are dropped, not rendered.

resolve runs on every preview of every object, so keep it cheap — a single indexed lookup, not a fan-out.


Views

  • Library — the disk browser. A lazy, collapsible folder tree in the left rail (each disk is a root; expanding a node fetches only that level, and every node is a drop target for drag-to-move), the object list in the main pane with cursor-paginated list, and create / copy / move / delete for folders and objects across buckets (see Folder operations).
  • Lightbox — clicking an object opens a modal preview that renders by content type: images, video, audio, PDFs, and text/JSON/CSV/TSV (tabular formats as a real table); anything else falls back to an "open original" link. The object insights your app registered render alongside the preview.
  • Collections — the media-library records themselves, cross-owner and filterable by collection / owner type / owner id / path prefix. One-click collection chips across the top carry each collection's record count and total size (from /collections/summary), and clicking a row drills into the record: its full metadata plus a signed URL per conversion, with delete when actions is on. See Collections view.
  • Uploads — live resumable sessions, polled every 2s from the session store. Clicking a row drills into the session: its full state plus every recorded part, and an abort button when actions is on.
  • Upload — drag-drop / picker, resumable TUS uploads into the chosen disk.
  • Login — when auth is configured and no valid session cookie exists, the SPA renders its login screen instead of the console, offering exactly the mode(s) the server advertised. See Console authentication.

Standalone install (legacy)

@adonis-agora/media-dashboard is still published and still works, for hosts that already register it directly or prefer the console as its own explicit, separately-versioned install:

pnpm add @adonis-agora/media-dashboard
adonisrc.ts
providers: [
  () => import('@adonis-agora/media/media_provider'),
  () => import('@adonis-agora/media-dashboard/media_dashboard_provider'),
]

Its provider is a thin delegate to the embedded one above — no logic of its own — so both entry points stay identical in behavior, and config/media_dashboard.ts is read from the same media_dashboard key either way. Register only one of the two providers.

Next steps

On this page