diff --git a/docs/plugins.md b/docs/plugins.md index 93c87f5..6ed35d1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,17 +1,30 @@ # Plugin System -Pinakes is very powerful on its own, but with a goal as ambitious as "to be the -last media management system you will ever need" I recognize the need for a -plugin system; not everything belongs in the core of Pinakes. Thus, Pinakes -supports WASM-based plugins for extending media type support, metadata -extraction, thumbnail generation, search, event handling, and theming. +Pinakes is first and foremost a _server_ application. This server can be +extended with a plugin system that runs WASM binaries in the server process. +They extend media type detection, metadata extension, thumbnail generation, +search, event handling and theming. -Plugins run in a sandboxed wasmtime runtime with capability-based security, fuel -metering, memory limits, and a circuit breaker for fault isolation. +The first-party GUI for Pinakes, dubbed `pinakes-ui` within this codebase, can +also be extended through a separate plugin system. GUI plugins add pages and +widgets to the desktop/web interface through declarative JSON schemas. No WASM +code runs during rendering. -## How It Works +> [!NOTE] +> While mostly functional, the plugin system is **experimental**. It might +> change at any given time, without notice and without any effort for backwards +> compatibility. Please provide any feedback that you might have! -A plugin is a directory containing: +## Server Plugins + +Server plugins run in a sandboxed wasmtime runtime with capability-based +security, fuel metering, memory limits, and a circuit breaker for fault +isolation. + +### How It Works + +A plugin is a directory containing a WASM binary and a plugin manifest. Usually +in this format: ```plaintext my-plugin/ @@ -19,9 +32,9 @@ my-plugin/ my_plugin.wasm Compiled WASM binary (wasm32-unknown-unknown target) ``` -The server discovers plugins in configured directories, validates their +The server discovers plugins from configured directories, validates their manifests, checks capabilities against the security policy, compiles the WASM -module, and registers the plugin. +module and registers the plugin. Extension points communicate via JSON-over-WASM. The host writes a JSON request into the plugin's memory, calls the exported function, and reads the JSON @@ -32,7 +45,7 @@ introduces a little bit of overhead, it's the more debuggable approach and thus more suitable for the initial plugin system. In the future, this might change. Plugins go through a priority-ordered pipeline. Each plugin declares a priority -(0–999, default 500). Built-in handlers run at implicit priority 100, so plugins +(0-999, default 500). Built-in handlers run at implicit priority 100, so plugins at priority <100 run _before_ built-ins and plugins at >100 run _after_. Different extension points use different merge strategies: @@ -49,7 +62,7 @@ Different extension points use different merge strategies: -## Plugin Kinds +### Plugin Kinds A plugin can implement one or more of these roles: @@ -74,7 +87,7 @@ kind = ["media_type", "metadata_extractor", "thumbnail_generator"] priority = 50 ``` -## Writing a Plugin +### Writing a Plugin [dlmalloc]: https://crates.io/crates/dlmalloc @@ -99,6 +112,8 @@ lto = true Let's go over a minimal example that registers a custom `.mytype` file format: + + ```rust #![no_std] @@ -156,34 +171,39 @@ pub extern "C" fn can_handle(ptr: i32, len: i32) { } ``` -### Building + + +#### Building ```bash +# Build for the wasm32-unknown-unknown target $ cargo build --target wasm32-unknown-unknown --release ``` The `RUSTFLAGS=""` override may be needed if your environment sets linker flags (e.g., `-fuse-ld=lld`) that are incompatible with the WASM target. You can also specify a compatible linker explicitly. As pinakes uses a `clang` and `lld` -based pipeline, it's necessary to set for, e.g., the test fixtures. +based pipeline, it's necessary to set for, e.g., the test fixtures while +building inside the codebase. In most cases, you will not need it. -The compiled binary will be at -`target/wasm32-unknown-unknown/release/my_plugin.wasm`. +Once the compilation is done, the resulting binary will be in the target +directory. More specifically it will be in +`target/wasm32-unknown-unknown/release` but the path changes based on your +target, and the build mode (dev/release). -### A note on `serde` in `no_std` +> [!NOTE] +> Since `serde_json` requires `std`, you cannot use it in a plugin. Either +> hand-write JSON strings (as shown above) or use a lightweight no_std JSON +> library. +> +> The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/` +> demonstrates the hand-written approach. It is ugly, but it works, and the +> binaries are tiny (~17KB). You are advised to replace this in your plugins. -Since `serde_json` requires `std`, you cannot use it in a plugin. Either -hand-write JSON strings (as shown above) or use a lightweight no_std JSON -library. - -The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/` -demonstrates the hand-written approach. It is ugly, but it works, and the -binaries are tiny (~17KB). - -### Installing +#### Installing Place the plugin directory in one of the configured plugin directories, or use -the API: +the API to load them while the server is running. ```bash curl -X POST http://localhost:3000/api/v1/plugins/install \ @@ -192,10 +212,10 @@ curl -X POST http://localhost:3000/api/v1/plugins/install \ -d '{"source": "/path/to/my-plugin"}' ``` -## Manifest Reference +### Manifest Reference Plugins are required to provide a manifest with explicit intents for everything. -Here is the expected manifest format as of 0.3.0-dev version of Pinakes: +Here is the expected manifest format as of **0.3.0-dev version** of Pinakes: ```toml [plugin] @@ -230,7 +250,7 @@ max_memory_mb = 64 # Maximum linear memory (default: 512MB) max_cpu_time_secs = 5 # Fuel budget per invocation (default: 60s) ``` -## Extension Points +### Extension Points Every plugin must export these three functions: @@ -244,7 +264,7 @@ Beyond those, which functions you export depends on your plugin's kind(s). The capability enforcer prevents a plugin from being called for functions it hasn't declared; a `metadata_extractor` plugin cannot have `search` called on it. -### `MediaTypeProvider` +#### `MediaTypeProvider` Plugins with `kind` containing `"media_type"` export: @@ -272,7 +292,7 @@ In the pipeline first-match-wins. Plugins are checked in priority order. The first where `can_handle` returns `true` and has a matching definition claims the file. If no plugin claims it, built-in handlers run as fallback. -### `MetadataExtractor` +#### `MetadataExtractor` Plugins with `kind` containing `"metadata_extractor"` export: @@ -301,7 +321,7 @@ The pipeline features accumulating merge. All matching plugins run in priority order. Later plugins' non-null fields overwrite earlier ones. The `extra` maps are merged (later keys win). -### `ThumbnailGenerator` +#### `ThumbnailGenerator` Plugins with `kind` containing `"thumbnail_generator"` export: @@ -329,7 +349,7 @@ Same as [MetadataExtractor](#metadataextractor) First-success-wins. The first plugin to return a successful result produces the thumbnail. -### `SearchBackend` +#### `SearchBackend` Plugins with `kind` containing `"search_backend"` export: @@ -371,7 +391,7 @@ Results from all backends are merged, deduplicated by ID (keeping the highest score), and sorted by score descending. `index_item` and `remove_item` are fanned out to all backends. -### `EventHandler` +#### `EventHandler` Plugins with `kind` containing `"event_handler"` export: @@ -394,9 +414,9 @@ Plugins with `kind` containing `"event_handler"` export: All interested plugins receive the event. Events are dispatched asynchronously via `tokio::spawn` and do not block the caller. Handler failures are logged but -never propagated. Suffice to say that the pipeline is fan-out. +never propagated. The pipeline is fan-out. -### `ThemeProvider` +#### `ThemeProvider` > [!IMPORTANT] > `ThemeProvider` is experimental, and will likely be subject to change. @@ -431,7 +451,7 @@ Plugins with `kind` containing `"theme_provider"` export: Themes from all providers are accumulated. When loading a specific theme, the pipeline dispatches to the plugin that registered it. -### System Events +#### System Events The server emits these events at various points: @@ -450,45 +470,45 @@ The server emits these events at various points: Plugins can also emit events themselves via `host_emit_event`, enabling plugin-to-plugin communication. -## Host Functions +### Host Functions Plugins can call these host functions from within WASM (imported from the `"env"` module): -### `host_set_result(ptr, len)` +#### `host_set_result(ptr, len)` Write a JSON response back to the host. The plugin writes the response bytes into its own linear memory and passes the pointer and length. This is how you return data from any extension point function. -### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32` +#### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32` Emit a system event from within a plugin. Enables plugin-to-plugin communication. Returns `0` on success, `-1` on error. -### `host_log(level, ptr, len)` +#### `host_log(level, ptr, len)` Log a message. Levels: 0=error, 1=warn, 2=info, 3+=debug. Messages appear in the server's tracing output. -### `host_read_file(path_ptr, path_len) -> i32` +#### `host_read_file(path_ptr, path_len) -> i32` Read a file into the exchange buffer. Returns file size on success, `-1` on IO error, `-2` if the path is outside the plugin's allowed read paths (paths are canonicalized before checking, so traversal tricks won't work). -### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32` +#### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32` Write data to a file. Returns `0` on success, `-1` on IO error, `-2` if the path is outside allowed write paths. -### `host_http_request(url_ptr, url_len) -> i32` +#### `host_http_request(url_ptr, url_len) -> i32` Make an HTTP GET request. @@ -496,12 +516,12 @@ Returns response size on success (body in exchange buffer), `-1` on error, `-2` if network access is disabled, `-3` if the domain is not in the plugin's `allowed_domains` list. -### `host_get_config(key_ptr, key_len) -> i32` +#### `host_get_config(key_ptr, key_len) -> i32` Read a plugin configuration value. Returns JSON value length on success (in exchange buffer), `-1` if key not found. -### `host_get_env(key_ptr, key_len) -> i32` +#### `host_get_env(key_ptr, key_len) -> i32` Read an environment variable. @@ -509,7 +529,7 @@ Returns value length on success (in exchange buffer), `-1` if the variable is not set, `-2` if environment access is disabled or the variable is not in the plugin's `environment` list. -### `host_get_buffer(dest_ptr, dest_len) -> i32` +#### `host_get_buffer(dest_ptr, dest_len) -> i32` Copy the exchange buffer into WASM memory. @@ -517,9 +537,9 @@ Returns bytes copied. Use this after `host_read_file`, `host_http_request`, `host_get_config`, or `host_get_env` to retrieve data the host placed in the buffer. -## Security Model +### Security Model -### Capabilities +#### Capabilities Every plugin declares what it needs in `plugin.toml`. The `CapabilityEnforcer` validates these at load time _and_ at runtime; a plugin declaring @@ -545,7 +565,7 @@ without `network = true` will get `-2` from `host_http_request`. - **CPU**: Enforced via wasmtime's fuel metering. Each invocation gets a fuel budget proportional to the configured CPU time limit. -### Isolation +#### Isolation - Each `call_function` invocation creates a fresh wasmtime `Store`. Plugins cannot retain WASM runtime state between calls. If you need persistence, use @@ -555,7 +575,7 @@ without `network = true` will get `-2` from `host_http_request`. - The wasmtime stack is limited to 1MB. -### Timeouts +#### Timeouts Three tiers of per-call timeouts prevent runaway plugins: @@ -569,7 +589,7 @@ Three tiers of per-call timeouts prevent runaway plugins: -### Circuit Breaker +#### Circuit Breaker If a plugin fails consecutively, the circuit breaker disables it automatically: @@ -579,7 +599,7 @@ If a plugin fails consecutively, the circuit breaker disables it automatically: - Disabled plugins are skipped in all pipeline stages. - Reload or toggle the plugin via the API to re-enable. -### Signatures +#### Signatures Plugins can be signed with Ed25519. The signing flow: @@ -603,7 +623,7 @@ verifies against at least one trusted key. If the signature is missing, invalid, or matches no trusted key, the plugin is rejected at load time. Set `allow_unsigned = true` during development to skip this check. -## Plugin Lifecycle +### Plugin Lifecycle 1. **Discovery**: On startup, the plugin manager walks configured plugin directories looking for `plugin.toml` files. @@ -637,7 +657,7 @@ or matches no trusted key, the plugin is rejected at load time. Set 9. **Hot-reload**: The `/plugins/:id/reload` endpoint reloads the WASM binary from disk and re-discovers capabilities without restarting the server. -## Plugin API Endpoints +### Plugin API Endpoints @@ -657,7 +677,7 @@ Reload and toggle operations automatically re-discover the plugin's capabilities, so changes to supported types or event subscriptions take effect immediately. -## Configuration +### Configuration In `pinakes.toml`: @@ -682,7 +702,7 @@ allowed_read_paths = ["/media", "/tmp/pinakes"] allowed_write_paths = ["/tmp/pinakes/plugin-data"] ``` -## Debugging +### Debugging - **`host_log`**: Call from within your plugin to emit structured log messages. They appear in the server's tracing output. @@ -693,3 +713,394 @@ allowed_write_paths = ["/tmp/pinakes/plugin-data"] - **Circuit breaker**: If your plugin is silently skipped, check the server logs for "circuit breaker tripped" messages. Fix the issue, then re-enable via `POST /api/v1/plugins/:id/enable`. + +--- + +## GUI Plugins + +A plugin declares its UI pages and optional widgets in `plugin.toml`, either +inline or as separate `.json` files. At startup, the server indexes all plugin +manifests and serves the schemas from `GET /api/v1/plugins/ui/pages`. The UI +fetches and validates these schemas, registers them in the `PluginRegistry`, and +adds the pages to the sidebar navigation. Widgets are injected into fixed +locations in host views at registration time. + +No WASM code runs during page rendering. The schema is purely declarative JSON. + +### Manifest Additions + +Add a `[ui]` section to `plugin.toml`: + +```toml +[plugin] +name = "my-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +# Inline page definition +[[ui.pages]] +id = "stats" +title = "My Stats" +route = "/plugins/my-plugin/stats" +icon = "chart-bar" + +[ui.pages.data_sources.summary] +type = "endpoint" +path = "/api/v1/plugins/my-plugin/summary" +poll_interval = 30 # re-fetch every 30 seconds + +[ui.pages.layout] +type = "container" +children = [] + +# File-referenced page +[[ui.pages]] +file = "pages/detail.json" # path relative to plugin directory + +# Widget injections +[[ui.widgets]] +id = "my-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "My Plugin" +variant = "default" +``` + +Alternatively, pages can be defined entirely in a separate JSON file: + +```json +{ + "id": "stats", + "title": "My Stats", + "route": "/plugins/my-plugin/stats", + "icon": "chart-bar", + "data_sources": { + "summary": { + "type": "endpoint", + "path": "/api/v1/plugins/my-plugin/summary" + } + }, + "layout": { + "type": "container", + "children": [] + } +} +``` + +### `UiPage` Fields + + + +| Field | Type | Required | Description | +| -------------- | -------------------------- | -------- | ----------------------------------------------------- | +| `id` | string | Yes | Unique identifier (alphanumeric, dashes, underscores) | +| `title` | string | Yes | Display name shown in navigation | +| `route` | string | Yes | URL path (must start with `/`) | +| `icon` | string | No | Icon name (from dioxus-free-icons) | +| `layout` | `UiElement` | Yes | Root layout element (see Element Reference) | +| `data_sources` | `{name: DataSource}` | No | Named data sources available to this page | +| `actions` | `{name: ActionDefinition}` | No | Named actions referenced by elements | + + + +### Data Sources + +Data sources populate named slots that elements bind to via their `data` field. + +#### `endpoint`: HTTP API call + +```toml +[ui.pages.data_sources.items] +type = "endpoint" +method = "GET" # GET (default), POST, PUT, PATCH, DELETE +path = "/api/v1/media" # must start with / +poll_interval = 60 # seconds; 0 = no polling (default) + +# Query params for GET, body for other methods +# +# Values are Expressions (see Expression Syntax) +[ui.pages.data_sources.items.params] +limit = 20 +offset = 0 +``` + +#### `static`: Inline JSON + +```toml +[ui.pages.data_sources.options] +type = "static" +value = ["asc", "desc"] +``` + +#### `transform`: Derived from another source + +```toml +# Evaluate an expression against an already-fetched source +[ui.pages.data_sources.count] +type = "transform" +source = "items" # name of the source to read from +expression = "items.total_count" # path expression into the context +``` + +Transform sources always run after all non-transform sources, so the named +source is guaranteed to be available in the context. + +### Expression Syntax + +Expressions appear as values in `params`, `transform`, `TextContent`, and +`Progress.value`. + + + +| Form | JSON example | Description | +| --------- | --------------------------------------------------- | -------------------------------------------- | +| Literal | `42`, `"hello"`, `true`, `null` | A fixed JSON value | +| Path | `"users.0.name"`, `"summary.count"` | Dot-separated path into the data context | +| Operation | `{"left":"a","op":"concat","right":"b"}` | Binary expression (see operators table) | +| Call | `{"function":"format","args":["Hello, {}!","Bob"]}` | Built-in function call (see functions table) | + + + +Path expressions use dot notation. Array indices are plain numbers: +`"items.0.title"` accesses `items[0].title`. + +**Operators:** + +| `op` | Result type | Description | +| -------- | ----------- | ------------------------------------------ | +| `eq` | bool | Equal | +| `ne` | bool | Not equal | +| `gt` | bool | Greater than (numeric) | +| `gte` | bool | Greater than or equal | +| `lt` | bool | Less than | +| `lte` | bool | Less than or equal | +| `and` | bool | Logical AND | +| `or` | bool | Logical OR | +| `concat` | string | String concatenation (both sides coerced) | +| `add` | number | f64 addition | +| `sub` | number | f64 subtraction | +| `mul` | number | f64 multiplication | +| `div` | number | f64 division (returns 0 on divide-by-zero) | + +**Built-in functions:** + + + +| Function | Signature | Description | +| ----------- | -------------------------------------- | -------------------------------------------------------------- | +| `len` | `len(value)` | Length of array, string (chars), or object (key count) | +| `upper` | `upper(str)` | Uppercase string | +| `lower` | `lower(str)` | Lowercase string | +| `trim` | `trim(str)` | Remove leading/trailing whitespace | +| `format` | `format(template, ...args)` | Replace `{}` placeholders left-to-right with args | +| `join` | `join(array, sep)` | Join array elements into a string with separator | +| `contains` | `contains(haystack, needle)` | True if string contains substring, or array contains value | +| `keys` | `keys(object)` | Array of object keys | +| `values` | `values(object)` | Array of object values | +| `abs` | `abs(number)` | Absolute value | +| `round` | `round(number)` | Round to nearest integer | +| `floor` | `floor(number)` | Round down | +| `ceil` | `ceil(number)` | Round up | +| `not` | `not(bool)` | Boolean negation | +| `coalesce` | `coalesce(a, b, ...)` | First non-null argument | +| `to_string` | `to_string(value)` | Convert any value to its display string | +| `to_number` | `to_number(value)` | Parse string to f64; pass through numbers; bool coerces to 0/1 | +| `slice` | `slice(array_or_string, start[, end])` | Sub-array or substring; negative indices count from end | +| `reverse` | `reverse(array_or_string)` | Reversed array or string | +| `if` | `if(cond, then, else)` | Return `then` if `cond` is true, otherwise `else` | + + + +### Actions + +Actions define what happens when a button is clicked or a form is submitted. + +#### Inline action + +```json +{ + "type": "button", + "label": "Delete", + "action": { + "method": "DELETE", + "path": "/api/v1/media/123", + "success_message": "Deleted!", + "navigate_to": "/library" + } +} +``` + +#### Named action + +Define the action in the page's `actions` map, then reference it by name: + +```toml +[ui.pages.actions.delete-item] +method = "DELETE" +path = "/api/v1/media/target" +navigate_to = "/library" +``` + +Then reference it anywhere an `ActionRef` is accepted: + +```json +{ "type": "button", "label": "Delete", "action": "delete-item" } +``` + +Named actions allow multiple elements to share the same action definition +without repetition. The action name must be a valid identifier (alphanumeric, +dashes, underscores). + +#### `ActionDefinition` fields + + + +| Field | Type | Default | Description | +| ----------------- | ------ | ------- | ------------------------------------------ | +| `method` | string | `GET` | HTTP method | +| `path` | string | | API path (must start with `/`) | +| `params` | object | `{}` | Fixed params merged with form data on POST | +| `success_message` | string | | Toast message on success | +| `error_message` | string | | Toast message on error | +| `navigate_to` | string | | Route to navigate to after success | + + + +### Element Reference + +All elements are JSON objects with a `type` field. + + + +| Type | Key fields | Description | +| ------------------ | ----------------------------------------------------------------------- | ---------------------------------------- | +| `container` | `children`, `gap`, `padding` | Stacked children with gap/padding | +| `grid` | `children`, `columns` (1-12), `gap` | CSS grid layout | +| `flex` | `children`, `direction`, `justify`, `align`, `gap`, `wrap` | Flexbox layout | +| `split` | `sidebar`, `sidebar_width`, `main` | Sidebar + main content layout | +| `tabs` | `tabs` (array of `{id,label,content[]}`), `default_tab` | Tabbed panels | +| `heading` | `level` (1-6), `content` | Section heading h1-h6 | +| `text` | `content`, `variant`, `allow_html` | Paragraph text | +| `code` | `content`, `language`, `show_line_numbers` | Code block with syntax highlighting | +| `data_table` | `columns`, `data`, `sortable`, `filterable`, `page_size`, `row_actions` | Sortable, filterable data table | +| `card` | `title`, `content`, `footer` | Content card with optional header/footer | +| `media_grid` | `data`, `columns`, `gap` | Responsive image/video grid | +| `list` | `data`, `item_template`, `dividers` | Templated list (loops over data items) | +| `description_list` | `data`, `horizontal` | Key-value pair list (metadata display) | +| `button` | `label`, `variant`, `action`, `disabled` | Clickable button | +| `form` | `fields`, `submit_label`, `submit_action`, `cancel_label` | Input form with submission | +| `link` | `text`, `href`, `external` | Navigation link | +| `progress` | `value` (Expression), `max`, `show_percentage` | Progress bar | +| `badge` | `text`, `variant` | Status badge/chip | +| `loop` | `data`, `item`, `template` | Iterates data array, renders template | +| `conditional` | `condition` (Expression), `then`, `else` | Conditional rendering | +| `chart` | `chart_type`, `data`, `x_key`, `y_key`, `title` | Bar/line/pie/scatter chart | +| `image` | `src`, `alt`, `width`, `height`, `object_fit` | Image element | +| `divider` | | Horizontal rule | +| `spacer` | `size` | Blank vertical space | +| `raw_html` | `html` | Sanitized raw HTML block | + + + +### Widget Injection + +Widgets are small UI elements injected into fixed locations in the host views. +Unlike pages, widgets have no data sources; they render with static or +expression-based content only. + +#### Declaring a widget + +```toml +[[ui.widgets]] +id = "status-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "My Plugin Active" +variant = "success" +``` + +#### Target locations + +| Target string | Where it renders | +| ----------------- | ----------------------------------------- | +| `library_header` | Before the stats grid in the Library view | +| `library_sidebar` | After the stats grid in the Library view | +| `search_filters` | Above the Search component in Search view | +| `detail_panel` | Above the Detail component in Detail view | + +Multiple plugins can register widgets at the same target; all are rendered in +registration order. + +### Complete Example + +A plugin page that lists media items and lets the user trigger a re-scan: + +```toml +[plugin] +name = "rescan-page" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[[ui.pages]] +id = "rescan" +title = "Re-scan" +route = "/plugins/rescan-page/rescan" +icon = "refresh" + +[ui.pages.actions.trigger-scan] +method = "POST" +path = "/api/v1/scan/trigger" +success_message = "Scan started!" +navigate_to = "/plugins/rescan-page/rescan" + +[ui.pages.data_sources.media] +type = "endpoint" +path = "/api/v1/media" +poll_interval = 30 + +[ui.pages.layout] +type = "container" +gap = 16 + +[[ui.pages.layout.children]] +type = "flex" +direction = "row" +justify = "space-between" +gap = 8 + +[[ui.pages.layout.children.children]] +type = "heading" +level = 2 +content = "Library" + +[[ui.pages.layout.children.children]] +type = "button" +label = "Trigger Re-scan" +variant = "primary" +action = "trigger-scan" + +[[ui.pages.layout.children]] +type = "data_table" +data = "media" +sortable = true +filterable = true +page_size = 25 + +[[ui.pages.layout.children.columns]] +key = "title" +label = "Title" + +[[ui.pages.layout.children.columns]] +key = "media_type" +label = "Type" + +[[ui.pages.layout.children.columns]] +key = "file_size" +label = "Size" +```