diff --git a/docs-src/docs/cleanup.md b/docs-src/docs/cleanup.md index e7665998225..710bd413b50 100644 --- a/docs-src/docs/cleanup.md +++ b/docs-src/docs/cleanup.md @@ -12,7 +12,7 @@ import {Faq, FaqItem} from '@site/src/components/faq'; To make the [replication](./replication.md) work, and for other reasons, RxDB has to keep deleted documents in storage so that it can replicate their deletion state. This ensures that when a client is [offline](./offline-first.md), the deletion state is still known and can be replicated with the backend when the client goes online again. -Keeping too many deleted documents in the storage, can slow down queries or fill up too much disc space. +Keeping too many deleted documents in the storage, can slow down queries or fill up too much disc space. The Storage panel of the [devtool](./devtool.md) counts how many of them a collection currently holds and can run a cleanup on demand. With the cleanup plugin, RxDB will run cleanup cycles that clean up deleted documents when it can be done safely. diff --git a/docs-src/docs/dev-mode.md b/docs-src/docs/dev-mode.md index e7078e20554..bc14aec31a9 100644 --- a/docs-src/docs/dev-mode.md +++ b/docs-src/docs/dev-mode.md @@ -11,7 +11,7 @@ import {Steps} from '@site/src/components/steps'; The dev-mode plugin adds many checks and validations to RxDB. This ensures that you use the RxDB API properly and so the dev-mode plugin should always be used when -using RxDB in development mode. +using RxDB in development mode. To look at the data itself while developing, open the [database viewer](./devtool.md). - Adds readable error messages. - Ensures that `readonly` JavaScript objects are not accidentally mutated. diff --git a/docs-src/docs/devtool.md b/docs-src/docs/devtool.md new file mode 100644 index 00000000000..869c1fe2ae0 --- /dev/null +++ b/docs-src/docs/devtool.md @@ -0,0 +1,138 @@ +--- +title: Devtool - Database Viewer and Editor for RxDB +slug: devtool.html +description: Inspect and edit a running RxDB database in the browser with a data grid, Mango query bar, schema analysis, query explain, replication and change feeds. +image: /headers/devtool.jpg +--- + +# Devtool + +With the `devtool` plugin you can open a **database viewer** for a running [RxDatabase](./rx-database.md) inside your app. It reads the data of the live database, so you see the same documents your code sees, including the ones written a millisecond ago. + +Key features: + +- **Data grid and JSON view** with a [Mango query](./rx-query.md) bar, sorting and paging at 100 rows per page. +- **Document drawer** that stages your edits and previews the exact `upsert()` call before anything is written. +- **Live activity map** that draws the database as app, collections and remote, with per collection write rates over the last 60 seconds. +- **Schema panel** that samples the stored documents and reports what they actually contain next to what the [schema](./rx-schema.md) declares. +- **Query lab** that explains which index a query used, how many documents it examined and what it discarded. +- **Replication and Changes panels** that show what crossed the wire and the diff of every write. +- **Storage panel** with document counts, tombstone counts and a button to run the [cleanup](./cleanup.md). + +The whole UI ships inside the plugin. There are no external stylesheets, no font files and no network requests. + +## Installation + +```ts +import { mountRxDBDevtool } from 'rxdb/plugins/devtool'; +``` + +## Usage + +Mount the devtool on a database and it renders as a full screen overlay: + +```ts +import { createRxDatabase } from 'rxdb'; +import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage'; +import { mountRxDBDevtool } from 'rxdb/plugins/devtool'; + +const db = await createRxDatabase({ + name: 'heroesdb', + storage: getRxStorageLocalstorage() +}); +await db.addCollections({ + heroes: { schema: heroSchema } +}); + +const devtool = mountRxDBDevtool(db); +``` + +To render it into your own element instead, pass a `target`: + +```ts +const devtool = mountRxDBDevtool(db, { + target: document.querySelector('#rxdb-panel') +}); +``` + +Call `devtool.destroy()` to close it again. Mounting twice for the same database returns the devtool that is already open. + +You can also add the plugin and use the `mountDevtool()` method on the database: + +```ts +import { addRxPlugin } from 'rxdb'; +import { RxDBDevtoolPlugin } from 'rxdb/plugins/devtool'; +addRxPlugin(RxDBDevtoolPlugin); + +const devtool = db.mountDevtool(); +``` + +## Options + +```ts +mountRxDBDevtool(db, { + // where the devtool is mounted, changes only the chrome of the top bar + surface: 'tab', // 'tab' | 'embedded' | 'tanstack' | 'dump' + // element to render into, defaults to a full screen overlay + target: myElement, + // rows per page in every grid and result list + pageSize: 100, + // set when reading a static export instead of a live database + dump: { fileName: 'heroesdb-2026-08-05.json', exportedAt: Date.now() }, + // state of a remote connection, for example over WebRTC + connection: { state: 'local' } +}); +``` + +When `dump` is set, or when `connection` reports a read-only remote, every writing action is disabled and says so in its tooltip. Counts, Schema, Query lab and Storage keep working. + +## Editing documents + +Rows open in the drawer, the checkbox selects without opening it. Editing a field in the drawer, or double clicking a cell in the grid, stages the change instead of writing it. The **WILL RUN** block shows the exact call with the changed lines highlighted, and only `Apply changes` runs it. + +Deleting more than one document at once states the blast radius first: how many of how many documents match, that the deletes replicate to connected peers, and that tombstones remain until cleanup runs. The delete button stays disabled until you type the collection name. + +## What the Live map shows + +The Live map draws names, counts and rates, never document contents, so the screen stays safe to share. Every colour is paired with a glyph: `+` insert, `~` update, `-` delete, `?` query, `◆` live query result, `↑` `↓` push and pull. Above roughly 200 events per second a lane becomes a moving band and the exact rate is printed beside it, so the picture stays readable with motion disabled. + +Reads and live query emits are derived from the query cache rather than from a dedicated event stream, so their counters update once per second. + +## Limitations + +- The devtool needs a DOM. Calling `mountRxDBDevtool()` in Node.js throws the error code `DVT1`. +- Below 640 pixels the rail and the tool panels do not fit. The devtool switches to three stacked screens that are read-only. +- Leadership is only known when the [leader election](./leader-election.md) plugin is added. RxDB does not publish a roster of the other open instances, so the Instances panel reports this instance only. +- The Changes and Replication feeds keep their most recent entries in memory. Nothing the devtool records is written back into the database. +- Tombstone counts and the cleanup button need the [cleanup](./cleanup.md) plugin. + +## FAQ + +
+Does the devtool slow down my app? + +It subscribes to the change stream of the database and polls the query cache once per second. Both are cheap. Ship it behind a flag anyway so it is not bundled into production builds. + +
+ +
+Can I inspect a database that runs on another device? + +Yes. Pass a `connection` describing the remote peer and the devtool shows the connection stages while it pairs, a banner with the transport and the write mode once it is connected, and a diagnosis if it fails. When peer to peer traffic is blocked, export the data with [exportJSON()](./rx-database.md#exportjson) on the device and open the file with the `dump` option instead. + +
+ +
+Why does the Live map use different colours than the Replication panel? + +The map uses one violet for push and pull so that replication reads as a single flow, while the Replication and Changes panels colour each direction separately. The glyphs `↑` and `↓` are the same in both places. + +
+ +## Follow Up + +- Start with the [Quickstart](./quickstart.md). +- Read about [RxQuery](./rx-query.md) to write the selectors the query bar takes. +- Read about [cleanup](./cleanup.md) to understand what the Storage panel purges. +- Read about [dev-mode](./dev-mode.md) for the other checks that run while developing. +- Check the [RxDB GitHub repo](/code/) and leave a star ⭐ diff --git a/docs-src/docs/rx-database.md b/docs-src/docs/rx-database.md index 2b9a434a434..089ea6a3811 100644 --- a/docs-src/docs/rx-database.md +++ b/docs-src/docs/rx-database.md @@ -168,7 +168,7 @@ myDb.$.subscribe(changeEvent => console.dir(changeEvent)); ``` ### exportJSON() -Use this function to create a JSON export from every piece of data in every collection of this database. You can pass `true` as a parameter to decrypt the encrypted data fields of your document. +Use this function to create a JSON export from every piece of data in every collection of this database. You can pass `true` as a parameter to decrypt the encrypted data fields of your document. Such an export can be opened read-only in the [devtool](./devtool.md) when you cannot reach the device the database runs on. Before `exportJSON()` and `importJSON()` can be used, you have to add the `json-dump` plugin. diff --git a/docs-src/docs/rx-query.md b/docs-src/docs/rx-query.md index 65be86a4252..7b8fa6c1116 100644 --- a/docs-src/docs/rx-query.md +++ b/docs-src/docs/rx-query.md @@ -10,7 +10,7 @@ import {Faq, FaqItem} from '@site/src/components/faq'; # RxQuery -To find documents inside of an [RxCollection](./rx-collection.md), RxDB uses the RxQuery interface that handles all query operations: it serves as the main interface for fetching documents, relies on a MongoDB-like [Mango Query Syntax](https://github.com/cloudant/mango), and provides three types of queries: [find()](#find), [findOne()](#findOne) and [count()](#count). By caching and de-duplicating results, RxQuery ensures efficient in-memory handling, and when queries are observed or re-run, the [EventReduce algorithm](https://github.com/pubkey/event-reduce) speeds up updates for a fast real-time experience and queries that run more than once. +To find documents inside of an [RxCollection](./rx-collection.md), RxDB uses the RxQuery interface that handles all query operations: it serves as the main interface for fetching documents, relies on a MongoDB-like [Mango Query Syntax](https://github.com/cloudant/mango), and provides three types of queries: [find()](#find), [findOne()](#findOne) and [count()](#count). By caching and de-duplicating results, RxQuery ensures efficient in-memory handling, and when queries are observed or re-run, the [EventReduce algorithm](https://github.com/pubkey/event-reduce) speeds up updates for a fast real-time experience and queries that run more than once. To try a selector out against real data, and to see which index it used, run it in the [devtool](./devtool.md). ## find() To create a basic `RxQuery`, call `.find()` on a collection and insert selectors. The result-set of normal queries is an array with documents. diff --git a/docs-src/sidebars.js b/docs-src/sidebars.js index abff2d1f072..845f2073a5f 100644 --- a/docs-src/sidebars.js +++ b/docs-src/sidebars.js @@ -482,6 +482,11 @@ const sidebars = { iconAfter: 'premium' } }, + { + type: 'doc', + id: 'devtool', + label: 'Devtool' + }, { type: 'doc', id: 'webmcp', diff --git a/examples/angular/src/app/app.component.html b/examples/angular/src/app/app.component.html index 28f74f94210..1fa0d55e428 100644 --- a/examples/angular/src/app/app.component.html +++ b/examples/angular/src/app/app.component.html @@ -13,4 +13,9 @@

Add Hero +
+ + Database + + diff --git a/examples/angular/src/app/app.component.ts b/examples/angular/src/app/app.component.ts index b530f61725a..8d2177f3241 100644 --- a/examples/angular/src/app/app.component.ts +++ b/examples/angular/src/app/app.component.ts @@ -13,12 +13,13 @@ import { import { MatCard, MatCardSubtitle } from '@angular/material/card'; import { HeroesListComponent } from './components/heroes-list/heroes-list.component'; import { HeroInsertComponent } from './components/hero-insert/hero-insert.component'; +import { DbViewerComponent } from './components/db-viewer/db-viewer.component'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.less'], - imports: [MatCard, MatCardSubtitle, HeroesListComponent, HeroInsertComponent] + imports: [MatCard, MatCardSubtitle, HeroesListComponent, HeroInsertComponent, DbViewerComponent] }) export class AppComponent { title = 'angular'; diff --git a/examples/angular/src/app/components/db-viewer/db-viewer.component.html b/examples/angular/src/app/components/db-viewer/db-viewer.component.html new file mode 100644 index 00000000000..31c71b5f552 --- /dev/null +++ b/examples/angular/src/app/components/db-viewer/db-viewer.component.html @@ -0,0 +1,10 @@ + diff --git a/examples/angular/src/app/components/db-viewer/db-viewer.component.ts b/examples/angular/src/app/components/db-viewer/db-viewer.component.ts new file mode 100644 index 00000000000..bde8171db50 --- /dev/null +++ b/examples/angular/src/app/components/db-viewer/db-viewer.component.ts @@ -0,0 +1,98 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; + +import { mountRxDBDevtool } from 'rxdb/plugins/devtool'; +import type { DevtoolHandle } from 'rxdb/plugins/devtool'; + +import { DatabaseService } from '../../services/database.service'; + +/** + * Opens the RxDB database viewer on top of the app. + * @link https://rxdb.info/devtool.html + */ +@Component({ + selector: 'db-viewer', + templateUrl: './db-viewer.component.html', + providers: [DatabaseService], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButton, MatIcon] +}) +export class DbViewerComponent implements OnDestroy { + + public isOpen = false; + + private devtool?: DevtoolHandle; + private overlay?: HTMLElement; + + constructor( + private dbService: DatabaseService + ) { } + + /** + * The overlay is built outside of the angular template so that it sits + * above the app regardless of the stacking contexts around this component. + */ + open() { + if (this.isOpen) { + return; + } + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'fixed', + inset: '0', + zIndex: '9000', + display: 'flex', + flexDirection: 'column', + background: '#0D0F18' + }); + + const bar = document.createElement('div'); + Object.assign(bar.style, { + display: 'flex', + justifyContent: 'flex-end', + padding: '6px 10px', + background: '#27022D', + borderBottom: '1px solid rgba(255,255,255,0.10)' + }); + const closeButton = document.createElement('button'); + closeButton.textContent = 'Close viewer'; + Object.assign(closeButton.style, { + border: '1px solid rgba(255,255,255,0.25)', + background: 'transparent', + color: '#FFFFFF', + font: '11px system-ui, sans-serif', + padding: '4px 12px', + cursor: 'pointer' + }); + closeButton.addEventListener('click', () => this.close()); + bar.appendChild(closeButton); + + const target = document.createElement('div'); + Object.assign(target.style, { flex: '1', minHeight: '0' }); + + overlay.appendChild(bar); + overlay.appendChild(target); + document.body.appendChild(overlay); + + this.overlay = overlay; + this.devtool = mountRxDBDevtool(this.dbService.db as any, { target }); + this.isOpen = true; + } + + close() { + this.devtool?.destroy(); + this.devtool = undefined; + this.overlay?.remove(); + this.overlay = undefined; + this.isOpen = false; + } + + ngOnDestroy() { + this.close(); + } +} diff --git a/orga/changelog/devtool-database-viewer.md b/orga/changelog/devtool-database-viewer.md new file mode 100644 index 00000000000..27d7faafc34 --- /dev/null +++ b/orga/changelog/devtool-database-viewer.md @@ -0,0 +1 @@ +- Added the `devtool` plugin, a database viewer and editor for a running [RxDatabase](https://rxdb.info/rx-database.html). It renders a data grid and JSON view with a Mango query bar, a document drawer that previews the exact upsert before it runs, and panels for the live activity map, schema analysis, query explain, replication, changes and storage. Mount it with `mountRxDBDevtool(database)` or `database.mountDevtool()`. The [angular example](https://github.com/pubkey/rxdb/tree/master/examples/angular) opens it from a button. Added testcases for the metrics window, the selector parsing, the document diff and the grid column selection. diff --git a/package.json b/package.json index e4c207ea779..58327f48b8c 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,12 @@ "import": "./dist/esm/plugins/crdt/index.js", "default": "./dist/esm/plugins/crdt/index.js" }, + "./plugins/devtool": { + "types": "./dist/types/plugins/devtool/index.d.ts", + "require": "./dist/cjs/plugins/devtool/index.js", + "import": "./dist/esm/plugins/devtool/index.js", + "default": "./dist/esm/plugins/devtool/index.js" + }, "./plugins/dev-mode": { "types": "./dist/types/plugins/dev-mode/index.d.ts", "require": "./dist/cjs/plugins/dev-mode/index.js", diff --git a/src/plugins/dev-mode/error-messages.ts b/src/plugins/dev-mode/error-messages.ts index 4a06cb7fc01..2984f3cabb9 100644 --- a/src/plugins/dev-mode/error-messages.ts +++ b/src/plugins/dev-mode/error-messages.ts @@ -436,6 +436,14 @@ export const ERROR_MESSAGES = { docs: '' }, + // plugins/devtool + DVT1: { + message: 'The devtool can only be mounted where a DOM is available', + cause: 'mountRxDBDevtool() was called in an environment without a document, for example in Node.js.', + fix: 'Mount the devtool from browser code, or connect to the database from a browser instead.', + docs: '' + }, + // plugins/webmcp WMCP1: { message: 'WebMCP Agent attempted to delete a document that does not exist', diff --git a/src/plugins/devtool/devtool.ts b/src/plugins/devtool/devtool.ts new file mode 100644 index 00000000000..0143f949bb9 --- /dev/null +++ b/src/plugins/devtool/devtool.ts @@ -0,0 +1,461 @@ +import type { Subscription } from 'rxjs'; +import type { RxDatabase } from '../../types/index.d.ts'; +import { clear, el } from './dom.ts'; +import { DEVTOOL_CSS, DEVTOOL_NARROW_BREAKPOINT } from './theme.ts'; +import { DevtoolStore } from './store.ts'; +import { renderConnectionBanner, renderTopBar } from './parts/top-bar.ts'; +import { renderRail } from './parts/rail.ts'; +import { CollectionPanel } from './parts/collection-panel.ts'; +import { LivePanel } from './parts/live-panel.ts'; +import { SchemaPanel } from './parts/schema-panel.ts'; +import { QueryLabPanel } from './parts/query-lab-panel.ts'; +import { ReplicationPanel } from './parts/replication-panel.ts'; +import { ChangesPanel } from './parts/changes-panel.ts'; +import { StoragePanel } from './parts/storage-panel.ts'; +import { NarrowPanel } from './parts/narrow-panel.ts'; +import { renderConnectingScreen, renderFailedScreen } from './parts/connection-screens.ts'; +import type { PanelContext } from './parts/context.ts'; +import type { + DevtoolHandle, + DevtoolNavigation, + DevtoolOptions +} from '../../types/index.d.ts'; + +const STYLE_ELEMENT_ID = 'rxdb-devtool-style'; +const DEFAULT_PAGE_SIZE = 100; +/** + * The panels re-render at most this often while events stream in, + * which keeps the animation of the Live map below 3 Hz. + */ +const RENDER_THROTTLE_MS = 400; + +type Panel = { + element: HTMLElement; + render(): HTMLElement; + destroy(): void; +}; + +/** + * The devtool shell: chrome, navigation and the panel that is currently open. + */ +export class RxDBDevtool implements DevtoolHandle { + public readonly element: HTMLElement; + public readonly database: RxDatabase; + + private readonly store: DevtoolStore; + private readonly context: PanelContext; + private readonly ownsElement: boolean; + private readonly onOpenDumpFile: (() => void) | undefined; + + private readonly bodyElement = el('div', { class: 'rxdt-body' }); + private readonly overlayHost = el('div'); + private overlay: HTMLElement | null = null; + + private collectionPanels = new Map(); + private toolPanels = new Map(); + private narrowPanel: NarrowPanel | null = null; + + private subscription: Subscription | null = null; + private renderScheduled = false; + private lastRenderAt = 0; + private destroyed = false; + private resizeObserver: ResizeObserver | null = null; + + constructor(database: RxDatabase, options: DevtoolOptions = {}) { + this.database = database; + this.ownsElement = !options.target; + this.onOpenDumpFile = options.onOpenDumpFile; + + const firstCollection = Object.keys(database.collections).sort()[0]; + this.store = new DevtoolStore(database, { + surface: options.surface ?? (options.dump ? 'dump' : 'tab'), + dump: options.dump ?? null, + pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE, + connection: options.connection ?? { state: 'local' }, + navigation: firstCollection + ? { kind: 'collection', name: firstCollection } + : { kind: 'tool', tool: 'live' } + }); + + this.element = options.target ?? createFullScreenElement(); + this.element.classList.add('rxdt'); + injectStyle(this.element); + + this.context = { + store: this.store, + render: () => this.scheduleRender(), + navigate: navigation => this.navigate(navigation), + setOverlay: node => this.setOverlay(node), + notify: message => this.notify(message) + }; + + this.store.start(); + this.subscription = this.store.changed$.subscribe(() => this.scheduleRender()); + this.observeResize(); + this.render(); + } + + public navigate(navigation: DevtoolNavigation): void { + if (navigation.kind === 'collection') { + this.store.lastCollectionName = navigation.name; + } + this.store.navigation = navigation; + this.setOverlay(null); + this.render(); + } + + public setConnection(connection: DevtoolOptions['connection']): void { + if (connection) { + this.store.connection = connection; + this.render(); + } + } + + public refresh(): void { + this.collectionPanels.forEach(panel => panel.load()); + this.render(); + } + + private setOverlay(node: HTMLElement | null): void { + this.overlay = node; + clear(this.overlayHost); + if (node) { + this.overlayHost.appendChild(node); + } + } + + private notify(message: string): void { + this.setOverlay(el('div', { class: 'rxdt-modal-backdrop' }, [ + el('div', { + class: 'rxdt-modal', + style: { borderTopColor: '#EBCB4B' } + }, [ + el('div', { class: 'rxdt-modal-title', text: 'The action did not run' }), + el('div', { class: 'rxdt-modal-body', text: message }), + el('div', { class: 'rxdt-modal-actions' }, [ + el('button', { + class: 'rxdt-btn', + text: 'Close', + onClick: () => this.setOverlay(null) + }) + ]) + ]) + ])); + } + + private scheduleRender(): void { + if (this.destroyed || this.renderScheduled) { + return; + } + const sinceLastRender = Date.now() - this.lastRenderAt; + if (sinceLastRender >= RENDER_THROTTLE_MS) { + this.render(); + return; + } + this.renderScheduled = true; + setTimeout(() => { + this.renderScheduled = false; + this.render(); + }, RENDER_THROTTLE_MS - sinceLastRender); + } + + private get narrow(): boolean { + const width = this.element.clientWidth; + return width > 0 && width < DEVTOOL_NARROW_BREAKPOINT; + } + + private render(): void { + if (this.destroyed) { + return; + } + this.lastRenderAt = Date.now(); + clear(this.element); + + if (this.narrow) { + this.element.appendChild(this.getNarrowPanel().render()); + this.element.appendChild(this.overlayHost); + return; + } + + this.element.appendChild(renderTopBar(this.store, { + onRefresh: () => this.refresh(), + onCommandPalette: () => this.openCommandPalette(), + onHelp: () => this.openHelp() + })); + const banner = renderConnectionBanner(this.store, () => { + this.store.connection = { state: 'local' }; + this.render(); + }); + if (banner) { + this.element.appendChild(banner); + } + + const connection = this.store.connection; + if (connection.state === 'connecting') { + this.element.appendChild(renderConnectingScreen(connection, () => { + this.store.connection = { state: 'local' }; + this.render(); + })); + this.element.appendChild(this.overlayHost); + return; + } + if (connection.state === 'failed') { + this.element.appendChild(renderFailedScreen(connection, this.onOpenDumpFile)); + this.element.appendChild(this.overlayHost); + return; + } + + clear(this.bodyElement); + this.bodyElement.appendChild(renderRail(this.store, navigation => this.navigate(navigation))); + const navigation = this.store.navigation; + if (navigation.kind === 'collection') { + const panel = this.getCollectionPanel(navigation.name); + this.bodyElement.appendChild(panel.render()); + const drawer = panel.renderDrawer(); + if (drawer) { + this.bodyElement.appendChild(drawer); + } + } else if (navigation.kind === 'replication') { + this.bodyElement.appendChild(this.getToolPanel('replication').render()); + } else if (navigation.kind === 'settings') { + this.bodyElement.appendChild(this.renderSettings()); + } else { + this.bodyElement.appendChild(this.getToolPanel(navigation.tool).render()); + } + this.element.appendChild(this.bodyElement); + this.element.appendChild(this.overlayHost); + } + + private getCollectionPanel(collectionName: string): CollectionPanel { + let panel = this.collectionPanels.get(collectionName); + if (!panel) { + panel = new CollectionPanel(this.context, collectionName); + this.collectionPanels.set(collectionName, panel); + } + return panel; + } + + private getToolPanel(tool: string): Panel { + let panel = this.toolPanels.get(tool); + if (!panel) { + panel = createToolPanel(tool, this.context); + this.toolPanels.set(tool, panel); + } + return panel; + } + + private getNarrowPanel(): NarrowPanel { + if (!this.narrowPanel) { + this.narrowPanel = new NarrowPanel(this.context); + } + return this.narrowPanel; + } + + private renderSettings(): HTMLElement { + const store = this.store; + return el('div', { class: 'rxdt-main rxdt-scroll' }, [ + el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title', text: 'Settings' }) + ]), + el('div', { class: 'rxdt-cards' }, [ + el('div', { class: 'rxdt-card' }, [ + el('div', { class: 'rxdt-section-label', text: 'SURFACE' }), + el('div', { class: 'rxdt-card-value', text: store.surface }) + ]), + el('div', { class: 'rxdt-card' }, [ + el('div', { class: 'rxdt-section-label', text: 'ROWS PER PAGE' }), + el('div', { class: 'rxdt-card-value', text: String(store.pageSize) }) + ]), + el('div', { class: 'rxdt-card' }, [ + el('div', { class: 'rxdt-section-label', text: 'MODE' }), + el('div', { class: 'rxdt-card-value', text: store.readOnly ? 'read-only' : 'read/write' }) + ]) + ]), + el('div', { class: 'rxdt-note' }, [ + el('div', { style: { fontWeight: '700', fontSize: '12px' }, text: 'Recorded feeds' }), + el('div', { + class: 'rxdt-muted', + style: { fontSize: '11.5px', marginTop: '4px', lineHeight: '1.55' }, + text: 'The Changes and Replication feeds keep the most recent entries in memory only. ' + + 'Nothing the devtool records is written back into the database.' + }) + ]) + ]); + } + + private openCommandPalette(): void { + const store = this.store; + const commands: { label: string; run: () => void; }[] = [ + ...store.collectionNames.map(name => ({ + label: 'Open collection ' + name, + run: () => this.navigate({ kind: 'collection', name }) + })), + { label: 'Open Live', run: () => this.navigate({ kind: 'tool', tool: 'live' }) }, + { label: 'Open Schema', run: () => this.navigate({ kind: 'tool', tool: 'schema' }) }, + { label: 'Open Changes', run: () => this.navigate({ kind: 'tool', tool: 'changes' }) }, + { label: 'Open Query lab', run: () => this.navigate({ kind: 'tool', tool: 'querylab' }) }, + { label: 'Open Storage', run: () => this.navigate({ kind: 'tool', tool: 'storage' }) }, + { label: 'Refresh', run: () => this.refresh() } + ]; + const list = el('div'); + const renderCommands = (filter: string) => { + clear(list); + commands + .filter(command => command.label.toLowerCase().includes(filter.toLowerCase())) + .forEach(command => { + list.appendChild(el('div', { + class: 'rxdt-dropdown-row', + text: command.label, + onClick: () => { + this.setOverlay(null); + command.run(); + } + })); + }); + }; + renderCommands(''); + const input = el('input', { + class: 'rxdt-modal-input', + placeholder: 'Type a command…', + onInput: (event: Event) => renderCommands((event.target as HTMLInputElement).value), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.setOverlay(null); + } + } + }); + this.setOverlay(el('div', { + class: 'rxdt-modal-backdrop', + onClick: (event: MouseEvent) => { + if (event.target === event.currentTarget) { + this.setOverlay(null); + } + } + }, [ + el('div', { class: 'rxdt-modal', style: { borderTopColor: '#ED168F' } }, [ + el('div', { class: 'rxdt-modal-title', text: 'Commands' }), + input, + el('div', { style: { marginTop: '10px', maxHeight: '260px', overflow: 'auto' } }, [list]) + ]) + ])); + setTimeout(() => input.focus(), 0); + } + + private openHelp(): void { + this.setOverlay(el('div', { + class: 'rxdt-modal-backdrop', + onClick: (event: MouseEvent) => { + if (event.target === event.currentTarget) { + this.setOverlay(null); + } + } + }, [ + el('div', { class: 'rxdt-modal', style: { borderTopColor: '#ED168F' } }, [ + el('div', { class: 'rxdt-modal-title', text: 'RxDB devtool' }), + el('div', { class: 'rxdt-modal-body' }, [ + document.createTextNode( + 'Inspect and edit the data of a running RxDB database. Rows open in the drawer, ' + + 'the checkbox selects without opening it, and every edit is previewed as the exact ' + + 'upsert before it runs. Results are paginated at ' + this.store.pageSize + ' rows.' + ) + ]), + el('div', { class: 'rxdt-modal-actions' }, [ + el('a', { + href: 'https://rxdb.info/', + target: '_blank', + rel: 'noopener', + style: { fontSize: '11px', alignSelf: 'center' }, + text: 'rxdb.info' + }), + el('button', { + class: 'rxdt-btn', + text: 'Close', + onClick: () => this.setOverlay(null) + }) + ]) + ]) + ])); + } + + private observeResize(): void { + if (typeof ResizeObserver === 'undefined') { + return; + } + let wasNarrow = this.narrow; + this.resizeObserver = new ResizeObserver(() => { + if (this.narrow !== wasNarrow) { + wasNarrow = this.narrow; + this.render(); + } + }); + this.resizeObserver.observe(this.element); + } + + public destroy(): void { + if (this.destroyed) { + return; + } + this.destroyed = true; + this.subscription?.unsubscribe(); + this.subscription = null; + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.collectionPanels.forEach(panel => panel.destroy()); + this.collectionPanels.clear(); + this.toolPanels.forEach(panel => panel.destroy()); + this.toolPanels.clear(); + this.narrowPanel?.destroy(); + this.narrowPanel = null; + this.store.destroy(); + clear(this.element); + if (this.ownsElement && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + } +} + +function createToolPanel(tool: string, context: PanelContext): Panel { + switch (tool) { + case 'schema': + return new SchemaPanel(context); + case 'changes': + return new ChangesPanel(context); + case 'querylab': + return new QueryLabPanel(context); + case 'storage': + return new StoragePanel(context); + case 'replication': + return new ReplicationPanel(context); + default: + return new LivePanel(context); + } +} + +function createFullScreenElement(): HTMLElement { + const element = el('div', { + style: { + position: 'fixed', + inset: '0', + zIndex: '2147483000' + } + }); + document.body.appendChild(element); + return element; +} + +/** + * The stylesheet ships inside the plugin, there are no external files. + * It is injected once per document, including into a shadow root + * when the devtool is mounted inside one. + */ +function injectStyle(element: HTMLElement): void { + const root = element.getRootNode() as Document | ShadowRoot; + const container: ParentNode = (root as Document).head ?? root; + if ((container as Element).querySelector?.('#' + STYLE_ELEMENT_ID)) { + return; + } + const style = document.createElement('style'); + style.id = STYLE_ELEMENT_ID; + style.textContent = DEVTOOL_CSS; + container.appendChild(style); +} diff --git a/src/plugins/devtool/dom.ts b/src/plugins/devtool/dom.ts new file mode 100644 index 00000000000..5ca8cb80b37 --- /dev/null +++ b/src/plugins/devtool/dom.ts @@ -0,0 +1,166 @@ +/** + * Minimal DOM helpers. + * The devtool builds its UI with plain DOM nodes so that it stays + * framework free and works on every surface it is mounted into. + */ + +export type ElementAttributes = { + class?: string; + text?: string; + html?: string; + title?: string; + style?: Partial & { [key: string]: string | undefined; }; + onClick?: (event: MouseEvent) => void; + onInput?: (event: Event) => void; + onKeyDown?: (event: KeyboardEvent) => void; + onFocus?: (event: FocusEvent) => void; + onBlur?: (event: FocusEvent) => void; + [attribute: string]: any; +}; + +const HANDLERS: { [key: string]: string; } = { + onClick: 'click', + onDblClick: 'dblclick', + onInput: 'input', + onChange: 'change', + onKeyDown: 'keydown', + onFocus: 'focus', + onBlur: 'blur', + onMouseMove: 'mousemove', + onMouseLeave: 'mouseleave' +}; + +export function el( + tag: K, + attributes: ElementAttributes = {}, + children: (Node | string | null | undefined | false)[] = [] +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + Object.entries(attributes).forEach(([key, value]) => { + if (value === undefined || value === null || value === false) { + return; + } + if (key === 'class') { + node.className = value as string; + } else if (key === 'text') { + node.textContent = String(value); + } else if (key === 'html') { + node.innerHTML = value as string; + } else if (key === 'style') { + Object.entries(value as object).forEach(([property, styleValue]) => { + if (styleValue !== undefined) { + node.style.setProperty( + property.replace(/[A-Z]/g, match => '-' + match.toLowerCase()), + String(styleValue) + ); + } + }); + } else if (HANDLERS[key]) { + node.addEventListener(HANDLERS[key], value as EventListener); + } else if (key === 'value' && (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement)) { + node.value = String(value); + } else if (key === 'checked' || key === 'disabled') { + (node as any)[key] = Boolean(value); + } else { + node.setAttribute(key, String(value)); + } + }); + children.forEach(child => { + if (child === null || child === undefined || child === false) { + return; + } + node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); + }); + return node; +} + +export function clear(node: HTMLElement): HTMLElement { + while (node.firstChild) { + node.removeChild(node.firstChild); + } + return node; +} + +export function spacer(): HTMLElement { + return el('div', { class: 'rxdt-grow' }); +} + +export function button( + label: string, + onClick: () => void, + options: { variant?: 'secondary' | 'primary' | 'danger' | 'dangerSolid'; small?: boolean; disabled?: boolean; title?: string; } = {} +): HTMLButtonElement { + const variantClass = { + secondary: 'rxdt-btn', + primary: 'rxdt-btn-primary', + danger: 'rxdt-btn-danger', + dangerSolid: 'rxdt-btn-danger-solid' + }[options.variant ?? 'secondary']; + return el('button', { + class: variantClass + (options.small && variantClass === 'rxdt-btn' ? ' rxdt-btn-sm' : ''), + text: label, + title: options.title, + disabled: options.disabled, + onClick: () => onClick() + }); +} + +/** + * The primary buttons track the pointer so that the gradient + * is anchored to the cursor while hovering. + */ +function withCursorGradient(node: HTMLElement): HTMLElement { + node.addEventListener('mousemove', event => { + const rect = node.getBoundingClientRect(); + const x = Math.round(event.clientX - rect.left); + const y = Math.round(event.clientY - rect.top); + node.style.background = 'radial-gradient(circle at ' + x + 'px ' + y + 'px, #B2218B, #ED168F)'; + }); + node.addEventListener('mouseleave', () => { + node.style.background = ''; + }); + return node; +} + +export function primaryButton( + label: string, + onClick: () => void, + options: { disabled?: boolean; title?: string; } = {} +): HTMLButtonElement { + return withCursorGradient( + button(label, onClick, { variant: 'primary', ...options }) + ) as HTMLButtonElement; +} + +/** + * Builds one grid row, `columns` is used as grid-template-columns. + */ +export function gridRow( + columns: string, + cells: (Node | string | null | undefined | false)[], + options: { class?: string; onClick?: (event: MouseEvent) => void; } = {} +): HTMLElement { + return el( + 'div', + { + class: options.class ?? 'rxdt-tr', + style: { gridTemplateColumns: columns }, + onClick: options.onClick + }, + cells.map(cell => (cell instanceof Node || typeof cell === 'string') + ? el('div', {}, [cell]) + : el('div') + ) + ); +} + +export function gridHead( + columns: string, + cells: (Node | string)[] +): HTMLElement { + return el( + 'div', + { class: 'rxdt-thead', style: { gridTemplateColumns: columns } }, + cells.map(cell => el('div', {}, [cell])) + ); +} diff --git a/src/plugins/devtool/format.ts b/src/plugins/devtool/format.ts new file mode 100644 index 00000000000..4324e484798 --- /dev/null +++ b/src/plugins/devtool/format.ts @@ -0,0 +1,307 @@ +import { el } from './dom.ts'; + +export function formatNumber(value: number): string { + return Math.round(value).toLocaleString('en-US'); +} + +export function formatRate(value: number): string { + if (value >= 100) { + return formatNumber(value); + } + return (Math.round(value * 10) / 10).toString(); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return bytes + ' B'; + } + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value = value / 1024; + unitIndex++; + } + return (Math.round(value * 10) / 10) + ' ' + units[unitIndex]; +} + +export function formatClock(timestamp: number): string { + const date = new Date(timestamp); + const pad = (input: number, length = 2) => String(input).padStart(length, '0'); + return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()) + + '.' + pad(Math.floor(date.getMilliseconds() / 100), 1); +} + +export function formatAge(timestamp: number, now = Date.now()): string { + const seconds = Math.max(0, Math.round((now - timestamp) / 1000)); + if (seconds < 60) { + return seconds + 's ago'; + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return minutes + 'm ago'; + } + const hours = Math.round(minutes / 60); + if (hours < 48) { + return hours + 'h ago'; + } + return Math.round(hours / 24) + 'd ago'; +} + +export function shortRevision(revision: string | undefined): string { + if (!revision) { + return ''; + } + const [height, hash] = revision.split('-'); + if (!hash) { + return revision; + } + return height + '-' + hash.slice(0, 6); +} + +/** + * Renders a compact single line preview of any value, + * used for the grid cells and the collapsed drawer fields. + */ +export function previewValue(value: any): string { + if (value === undefined) { + return ''; + } + if (value === null) { + return 'null'; + } + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return 'array [' + value.length + ']'; + } + if (typeof value === 'object') { + return 'object {' + Object.keys(value).length + '}'; + } + return String(value); +} + +export function valueType(value: any): 'string' | 'number' | 'boolean' | 'array' | 'object' | 'null' | 'missing' { + if (value === undefined) { + return 'missing'; + } + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'array'; + } + const type = typeof value; + if (type === 'string' || type === 'number' || type === 'boolean') { + return type; + } + return 'object'; +} + +export function getByPath(source: any, path: string): any { + return path.split('.').reduce( + (accumulator, part) => (accumulator === undefined || accumulator === null) + ? undefined + : accumulator[part], + source + ); +} + +export function setByPath(target: any, path: string, value: any): void { + const parts = path.split('.'); + const lastPart = parts.pop() as string; + let cursor = target; + parts.forEach(part => { + if (typeof cursor[part] !== 'object' || cursor[part] === null) { + cursor[part] = {}; + } + cursor = cursor[part]; + }); + cursor[lastPart] = value; +} + +/** + * Parses what the user typed into a cell back into a JSON value. + * Anything that is not valid JSON is kept as a plain string, + * which is what a user editing a text field expects. + */ +export function parseCellInput(input: string, previous: any): any { + const trimmed = input.trim(); + if (typeof previous === 'string' && !/^[[{"]|^-?\d|^true$|^false$|^null$/.test(trimmed)) { + return input; + } + try { + return JSON.parse(trimmed); + } catch (error) { + return input; + } +} + +/** + * Syntax highlighted, pretty printed JSON. + * Keys are dim, strings green, numbers and booleans yellow. + */ +export function highlightJson(value: any, indent = 2): DocumentFragment { + const fragment = document.createDocumentFragment(); + const source = JSON.stringify(value, null, indent); + if (typeof source !== 'string') { + fragment.appendChild(document.createTextNode('undefined')); + return fragment; + } + const pattern = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; + let lastIndex = 0; + let match = pattern.exec(source); + while (match !== null) { + if (match.index > lastIndex) { + fragment.appendChild(document.createTextNode(source.slice(lastIndex, match.index))); + } + if (match[1] !== undefined && match[2] !== undefined) { + fragment.appendChild(el('span', { class: 'rxdt-json-key', text: match[1] })); + fragment.appendChild(document.createTextNode(match[2])); + } else if (match[1] !== undefined) { + fragment.appendChild(el('span', { class: 'rxdt-json-string', text: match[1] })); + } else { + fragment.appendChild(el('span', { class: 'rxdt-json-literal', text: match[0] })); + } + lastIndex = match.index + match[0].length; + match = pattern.exec(source); + } + if (lastIndex < source.length) { + fragment.appendChild(document.createTextNode(source.slice(lastIndex))); + } + return fragment; +} + +export type DiffLine = { + kind: 'context' | 'added' | 'removed'; + text: string; +}; + +/** + * Line based unified diff of two pretty printed documents. + * A longest-common-subsequence walk keeps unchanged lines aligned. + */ +export function diffJson(before: any, after: any): DiffLine[] { + const beforeLines = before === undefined ? [] : JSON.stringify(before, null, 2).split('\n'); + const afterLines = after === undefined ? [] : JSON.stringify(after, null, 2).split('\n'); + const rows = beforeLines.length; + const columns = afterLines.length; + const table: number[][] = []; + for (let row = 0; row <= rows; row++) { + table.push(new Array(columns + 1).fill(0)); + } + for (let row = rows - 1; row >= 0; row--) { + for (let column = columns - 1; column >= 0; column--) { + table[row][column] = beforeLines[row] === afterLines[column] + ? table[row + 1][column + 1] + 1 + : Math.max(table[row + 1][column], table[row][column + 1]); + } + } + const result: DiffLine[] = []; + let row = 0; + let column = 0; + while (row < rows && column < columns) { + if (beforeLines[row] === afterLines[column]) { + result.push({ kind: 'context', text: beforeLines[row] }); + row++; + column++; + } else if (table[row + 1][column] >= table[row][column + 1]) { + result.push({ kind: 'removed', text: beforeLines[row] }); + row++; + } else { + result.push({ kind: 'added', text: afterLines[column] }); + column++; + } + } + while (row < rows) { + result.push({ kind: 'removed', text: beforeLines[row] }); + row++; + } + while (column < columns) { + result.push({ kind: 'added', text: afterLines[column] }); + column++; + } + return result; +} + +export function renderDiff(lines: DiffLine[]): HTMLElement { + const container = el('div', { class: 'rxdt-diff' }); + lines.forEach(line => { + if (line.kind === 'context') { + container.appendChild(document.createTextNode(' ' + line.text + '\n')); + } else { + container.appendChild(el('span', { + class: line.kind === 'added' ? 'rxdt-diff-add' : 'rxdt-diff-del', + text: (line.kind === 'added' ? '+ ' : '- ') + line.text + })); + } + }); + return container; +} + +export type JsonParseFailure = { + message: string; + position: number; +}; + +/** + * Parses a Mango selector and reports the caret position on failure + * so that the query bar can point at the offending character. + */ +export function parseSelector(input: string): { ok: true; value: any; } | { ok: false; error: JsonParseFailure; } { + const trimmed = input.trim(); + if (trimmed === '') { + return { ok: true, value: {} }; + } + try { + const value = JSON.parse(trimmed); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { + ok: false, + error: { message: 'The selector must be a JSON object', position: 0 } + }; + } + return { ok: true, value }; + } catch (error) { + return { ok: false, error: describeJsonError(trimmed, (error as Error).message) }; + } +} + +/** + * JSON.parse() error messages differ between JavaScript engines and often + * carry no position at all, so the caret position is found by scanning the + * input for the first token that cannot appear there. + */ +function describeJsonError(input: string, engineMessage: string): JsonParseFailure { + const bareWord = findBareWord(input); + if (bareWord) { + return { + message: 'Unexpected token \'' + input[bareWord.position] + '\' at position ' + + bareWord.position + ' — the selector must be valid JSON', + position: bareWord.position + }; + } + const positionMatch = /position (\d+)/.exec(engineMessage); + if (positionMatch) { + return { message: engineMessage, position: Number(positionMatch[1]) }; + } + return { message: engineMessage, position: Math.max(0, input.length - 1) }; +} + +/** + * Finds an unquoted word outside of strings that is not a JSON literal, + * which is what an unquoted value or a misspelled boolean looks like. + */ +function findBareWord(input: string): { position: number; word: string; } | null { + const pattern = /"(?:\\.|[^"\\])*"|([A-Za-z_$][\w$]*)/g; + let match = pattern.exec(input); + while (match !== null) { + const word = match[1]; + if (word !== undefined && word !== 'true' && word !== 'false' && word !== 'null') { + return { position: match.index, word }; + } + match = pattern.exec(input); + } + return null; +} diff --git a/src/plugins/devtool/grid-columns.ts b/src/plugins/devtool/grid-columns.ts new file mode 100644 index 00000000000..6cd0c5bb46f --- /dev/null +++ b/src/plugins/devtool/grid-columns.ts @@ -0,0 +1,61 @@ +import type { RxJsonSchema } from '../../types/index.d.ts'; + +export type GridColumn = { + path: string; + label: string; + width: string; +}; + +const INTERNAL_FIELDS = ['_rev', '_deleted', '_meta', '_attachments']; +const SCALAR_TYPES = ['string', 'number', 'boolean', 'integer']; +const NARROW_COLUMN_COUNT = 2; + +/** + * Chooses the columns of the document grid: the primary key, one wide + * column, up to two more scalar fields, the revision and the last write time. + * + * A filled RxJsonSchema lists its properties alphabetically, so the order a + * developer wrote them in is gone by the time the devtool sees the schema. + * The columns are therefore picked by what they are worth reading: + * + * - The wide column goes to a string without a `maxLength`, because that is + * free text. Bounded strings are usually ids, dates or enums. + * - The remaining slots prefer the fields the schema marks as required. + */ +export function pickGridColumns( + jsonSchema: RxJsonSchema, + primaryPath: string +): GridColumn[] { + const properties: any = jsonSchema.properties ?? {}; + const required: string[] = (jsonSchema.required as string[]) ?? []; + const scalarFields = Object.keys(properties) + .filter(name => name !== primaryPath && !INTERNAL_FIELDS.includes(name)) + .filter(name => SCALAR_TYPES.includes(properties[name].type)); + + const stringFields = scalarFields.filter(name => properties[name].type === 'string'); + const wideField = stringFields.find(name => properties[name].maxLength === undefined) + ?? stringFields + .slice(0) + .sort((a, b) => (properties[b].maxLength ?? 0) - (properties[a].maxLength ?? 0))[0] + ?? scalarFields[0]; + + const columns: GridColumn[] = [ + { path: primaryPath, label: primaryPath, width: '90px' }, + wideField + ? { path: wideField, label: wideField, width: '1fr' } + : { path: '_deleted', label: '_deleted', width: '1fr' } + ]; + scalarFields + .filter(name => name !== wideField) + .sort((a, b) => { + const rankDifference = Number(required.includes(b)) - Number(required.includes(a)); + return rankDifference === 0 ? a.localeCompare(b) : rankDifference; + }) + .slice(0, NARROW_COLUMN_COUNT) + .forEach(name => { + columns.push({ path: name, label: name, width: '90px' }); + }); + columns.push({ path: '_rev', label: '_rev', width: '90px' }); + columns.push({ path: '_meta.lwt', label: 'updated', width: '100px' }); + return columns; +} diff --git a/src/plugins/devtool/index.ts b/src/plugins/devtool/index.ts new file mode 100644 index 00000000000..8ffeea079a5 --- /dev/null +++ b/src/plugins/devtool/index.ts @@ -0,0 +1,55 @@ +import type { RxDatabase, RxPlugin } from '../../types/index.d.ts'; +import { newRxError } from '../../rx-error.ts'; +import { RxDBDevtool } from './devtool.ts'; +import type { DevtoolHandle, DevtoolOptions } from '../../types/index.d.ts'; + +/** + * Re-exported so that consumers can type their own code against the + * devtool without also importing from the rxdb root entry point. + */ +export type * from '../../types/plugins/devtool.d.ts'; + +export * from './theme.ts'; +export * from './format.ts'; +export * from './grid-columns.ts'; +export * from './store.ts'; +export { RxDBDevtool } from './devtool.ts'; + +const DEVTOOL_BY_DATABASE = new WeakMap(); + +/** + * Opens the database viewer for a running RxDatabase. + * Calling it twice for the same database returns the open devtool. + */ +export function mountRxDBDevtool( + database: RxDatabase, + options: DevtoolOptions = {} +): DevtoolHandle { + if (typeof document === 'undefined') { + throw newRxError('DVT1', { database: database.name }); + } + const existing = DEVTOOL_BY_DATABASE.get(database); + if (existing) { + return existing; + } + const devtool = new RxDBDevtool(database, options); + DEVTOOL_BY_DATABASE.set(database, devtool); + const originalDestroy = devtool.destroy.bind(devtool); + devtool.destroy = () => { + DEVTOOL_BY_DATABASE.delete(database); + originalDestroy(); + }; + return devtool; +} + +export const RxDBDevtoolPlugin: RxPlugin = { + name: 'devtool', + rxdb: true, + prototypes: { + RxDatabase: (proto: any) => { + proto.mountDevtool = function (this: RxDatabase, options: DevtoolOptions = {}): DevtoolHandle { + return mountRxDBDevtool(this, options); + }; + } + } +}; diff --git a/src/plugins/devtool/parts/changes-panel.ts b/src/plugins/devtool/parts/changes-panel.ts new file mode 100644 index 00000000000..2e2a4676cd5 --- /dev/null +++ b/src/plugins/devtool/parts/changes-panel.ts @@ -0,0 +1,181 @@ +import { button, clear, el, gridHead, gridRow, spacer } from '../dom.ts'; +import { diffJson, formatClock, formatNumber, renderDiff, shortRevision } from '../format.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import type { PanelContext } from './context.ts'; +import type { DevtoolChangeRecord } from '../../../types/index.d.ts'; + +const COLUMNS = '100px 70px 80px 80px 1fr'; + +const OPERATION_COLORS: { [operation: string]: string; } = { + INSERT: DEVTOOL_COLORS.success, + UPDATE: DEVTOOL_COLORS.warning, + DELETE: DEVTOOL_COLORS.danger +}; + +/** + * A network-tab style list of every write in this session, + * with the unified diff of the selected change next to it. + */ +export class ChangesPanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt-main' }); + + constructor(private readonly context: PanelContext) { } + + public destroy(): void { } + + private get filtered(): DevtoolChangeRecord[] { + const store = this.context.store; + const filter = store.changesFilter.trim().toLowerCase(); + if (filter === '') { + return store.changes; + } + return store.changes.filter(record => + record.collectionName.toLowerCase().includes(filter) || + record.documentId.toLowerCase().includes(filter) + ); + } + + public render(): HTMLElement { + clear(this.element); + const store = this.context.store; + this.element.appendChild(this.renderToolbar()); + + const split = el('div', { style: { flex: '1', display: 'flex', minHeight: '0' } }); + const list = el('div', { + class: 'rxdt-scroll', + style: { borderRight: '1px solid rgba(255,255,255,0.10)' } + }); + list.appendChild(gridHead(COLUMNS, ['time', 'op', 'collection', 'id', 'rev'])); + + const records = this.filtered; + if (records.length === 0) { + list.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '8px 12px', fontSize: '11px' }, + text: store.changes.length === 0 + ? 'No writes yet. This list fills as the app writes documents.' + : 'No change matches the filter.' + })); + } + records.forEach((record, index) => { + list.appendChild(gridRow(COLUMNS, [ + el('span', { class: 'rxdt-mono rxdt-dim', text: formatClock(record.time) }), + el('span', { + class: 'rxdt-mono', + style: { color: OPERATION_COLORS[record.operation], fontWeight: '700' }, + text: record.operation + }), + el('span', { class: 'rxdt-mono', text: record.collectionName }), + el('span', { class: 'rxdt-mono rxdt-muted', text: record.documentId }), + el('span', { + class: 'rxdt-mono rxdt-dim', + text: record.previousRevision + ? shortRevision(record.previousRevision) + ' → ' + shortRevision(record.revision) + : shortRevision(record.revision) + }) + ], { + class: 'rxdt-tr' + (index === store.selectedChangeIndex ? ' rxdt-selected' : ''), + onClick: () => { + store.selectedChangeIndex = index; + this.context.render(); + } + })); + }); + split.appendChild(list); + + const selected = records[store.selectedChangeIndex]; + if (selected) { + split.appendChild(this.renderDetail(selected)); + } + this.element.appendChild(split); + return this.element; + } + + private renderToolbar(): HTMLElement { + const store = this.context.store; + return el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title', text: 'Changes' }), + el('span', { + class: 'rxdt-dot', + style: { background: store.changesPaused ? DEVTOOL_COLORS.fgDim : DEVTOOL_COLORS.success } + }), + el('span', { + class: 'rxdt-dim', + style: { fontSize: '10px' }, + text: (store.changesPaused ? 'paused' : 'recording') + ' · ' + + formatNumber(store.sessionWriteCount) + ' writes this session' + }), + el('div', { class: 'rxdt-query-input-wrap', style: { flex: '0 0 220px' } }, [ + el('input', { + class: 'rxdt-query-input', + value: store.changesFilter, + placeholder: 'filter: collection or id…', + onInput: (event: Event) => { + store.changesFilter = (event.target as HTMLInputElement).value; + store.selectedChangeIndex = 0; + this.context.render(); + } + }) + ]), + spacer(), + button(store.changesPaused ? 'Resume' : 'Pause', () => { + store.changesPaused = !store.changesPaused; + this.context.render(); + }, { small: true }), + button('Clear', () => { + store.changes = []; + store.selectedChangeIndex = 0; + this.context.render(); + }, { small: true }) + ]); + } + + private renderDetail(record: DevtoolChangeRecord): HTMLElement { + const lines = diffJson( + record.previousDocumentData, + record.operation === 'DELETE' ? undefined : record.documentData + ); + return el('div', { class: 'rxdt-detail' }, [ + el('div', { + style: { + display: 'flex', + gap: '8px', + alignItems: 'center', + padding: '8px 12px', + borderBottom: '1px solid rgba(255,255,255,0.08)', + fontSize: '11px' + } + }, [ + el('span', { + class: 'rxdt-mono', + style: { color: OPERATION_COLORS[record.operation], fontWeight: '700' }, + text: record.operation + }), + el('span', { class: 'rxdt-mono', text: record.collectionName + ' / ' + record.documentId }), + el('span', { + class: 'rxdt-mono rxdt-dim', + text: record.previousRevision + ? shortRevision(record.previousRevision) + ' → ' + shortRevision(record.revision) + : shortRevision(record.revision) + }), + spacer(), + el('a', { + style: { fontSize: '10px' }, + text: 'open document', + onClick: () => { + const view = this.context.store.getView(record.collectionName); + view.openDocumentId = record.documentId; + this.context.navigate({ kind: 'collection', name: record.collectionName }); + } + }) + ]), + renderDiff(lines), + el('div', { + class: 'rxdt-dim', + style: { padding: '0 12px 12px', fontSize: '10px' }, + text: 'source: ' + (record.source === 'devtool' ? 'written by this devtool' : 'local write') + + ' · ' + formatClock(record.time) + }) + ]); + } +} diff --git a/src/plugins/devtool/parts/collection-panel.ts b/src/plugins/devtool/parts/collection-panel.ts new file mode 100644 index 00000000000..464d938f708 --- /dev/null +++ b/src/plugins/devtool/parts/collection-panel.ts @@ -0,0 +1,1091 @@ +import type { Subscription } from 'rxjs'; +import type { RxCollection, RxDocumentData } from '../../../types/index.d.ts'; +import { + button, + clear, + el, + gridHead, + gridRow, + primaryButton, + spacer +} from '../dom.ts'; +import { + formatAge, + formatBytes, + formatNumber, + getByPath, + highlightJson, + parseCellInput, + parseSelector, + previewValue, + setByPath, + shortRevision, + valueType +} from '../format.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import { pickGridColumns } from '../grid-columns.ts'; +import type { GridColumn } from '../grid-columns.ts'; +import type { PanelContext } from './context.ts'; +import { downloadJson } from './context.ts'; + +const INTERNAL_FIELDS = ['_rev', '_deleted', '_meta', '_attachments']; +/** + * How long a document keeps the "updated" highlight in the JSON view + * after it changed while observing. + */ +const FRESH_HIGHLIGHT_MS = 30000; + +/** + * Everything scoped to one collection: the content toolbar, the query bar, + * the grid or JSON result, the document drawer and the destructive + * confirmation that guards a bulk delete. + */ +export class CollectionPanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt-main' }); + + private documents: RxDocumentData[] = []; + private matchCount = 0; + private totalCount = 0; + private loading = true; + private loadToken = 0; + private subscription: Subscription | null = null; + /** + * Document id to the time it last changed while observing, + * drives the "← updated 2s ago" highlight in the JSON view. + */ + private freshDocuments = new Map(); + + constructor( + private readonly context: PanelContext, + private readonly collectionName: string + ) { + this.load(); + } + + private get collection(): RxCollection { + return this.context.store.database.collections[this.collectionName]; + } + + private get view() { + return this.context.store.getView(this.collectionName); + } + + public destroy(): void { + this.subscription?.unsubscribe(); + this.subscription = null; + } + + private buildQuery() { + const view = this.view; + return { + selector: view.selector, + sort: [{ [view.sort.field]: view.sort.direction } as any], + skip: view.page * this.context.store.pageSize, + limit: this.context.store.pageSize + }; + } + + public async load(): Promise { + const token = ++this.loadToken; + this.loading = true; + this.subscription?.unsubscribe(); + this.subscription = null; + const collection = this.collection; + if (!collection) { + return; + } + const view = this.view; + try { + const [total, matches] = await Promise.all([ + collection.count().exec(), + collection.count({ selector: view.selector }).exec() + ]); + if (token !== this.loadToken) { + return; + } + this.totalCount = total; + this.matchCount = matches; + const query = collection.find(this.buildQuery()); + if (view.observe) { + this.subscription = query.$.subscribe(documents => { + if (token !== this.loadToken) { + return; + } + const now = Date.now(); + documents.forEach(rxDocument => { + const previous = this.documents.find( + candidate => candidate[collection.schema.primaryPath] === rxDocument.primary + ); + if (previous && previous._rev !== rxDocument.toJSON(true)._rev) { + this.freshDocuments.set(rxDocument.primary, now); + } + }); + this.documents = documents.map(rxDocument => rxDocument.toJSON(true) as RxDocumentData); + this.loading = false; + this.context.render(); + }); + } else { + const documents = await query.exec(); + if (token !== this.loadToken) { + return; + } + this.documents = documents.map(rxDocument => rxDocument.toJSON(true) as RxDocumentData); + } + } catch (error) { + if (token !== this.loadToken) { + return; + } + this.view.queryError = { message: (error as Error).message, position: 0 }; + } + this.loading = false; + this.context.render(); + } + + private get columns(): GridColumn[] { + return pickGridColumns( + this.collection.schema.jsonSchema, + this.collection.schema.primaryPath as string + ); + } + + private get gridTemplate(): string { + return '32px ' + this.columns.map(column => column.width).join(' '); + } + + public render(): HTMLElement { + clear(this.element); + const collection = this.collection; + if (!collection) { + this.element.appendChild(this.renderEmptyDatabase()); + return this.element; + } + this.element.appendChild(this.renderToolbar()); + this.element.appendChild(this.renderQueryBar()); + const view = this.view; + if (view.queryError) { + this.element.appendChild(this.renderQueryError(view.queryError)); + } + if (this.totalCount === 0 && !this.loading) { + this.element.appendChild(this.renderEmptyCollection()); + } else if (this.matchCount === 0 && !this.loading) { + this.element.appendChild(this.renderNoMatches()); + } else if (view.view === 'json') { + this.element.appendChild(this.renderJson()); + } else { + this.element.appendChild(this.renderGrid()); + } + this.element.appendChild(this.renderFooter()); + return this.element; + } + + /** + * Row 1 of the content toolbar. Everything here is scoped to the + * current collection, never to the database. + */ + private renderToolbar(): HTMLElement { + const view = this.view; + const store = this.context.store; + const segment = (label: string, value: 'table' | 'json') => el('div', { + class: view.view === value ? 'rxdt-active' : '', + text: label, + onClick: () => { + view.view = value; + this.context.render(); + } + }); + const observeDisabled = Boolean(store.dump); + return el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title rxdt-mono', text: this.collectionName }), + el('div', { class: 'rxdt-seg' }, [segment('Table', 'table'), segment('JSON', 'json')]), + el('div', { + class: 'rxdt-toggle' + (view.observe ? ' rxdt-on' : ''), + title: observeDisabled ? 'not available on a dump' : 'Subscribe to the current query', + style: observeDisabled ? { opacity: '0.5', cursor: 'not-allowed' } : {}, + onClick: () => { + if (observeDisabled) { + return; + } + view.observe = !view.observe; + this.freshDocuments.clear(); + this.load(); + } + }, [ + el('span', { class: 'rxdt-dot' }), + document.createTextNode(view.observe ? 'Observing' : 'Observe') + ]), + view.observe && el('span', { + class: 'rxdt-dim', + style: { fontSize: '10px' }, + text: 'live — the list updates as documents change' + }), + spacer(), + button('Export', () => this.exportCollection()) + ]); + } + + /** + * Row 2 of the content toolbar: the Mango selector. + */ + private renderQueryBar(): HTMLElement { + const view = this.view; + const input = el('input', { + class: 'rxdt-query-input', + value: view.queryInput, + spellcheck: 'false', + 'aria-label': 'Mango selector', + onInput: event => { + view.queryInput = (event.target as HTMLInputElement).value; + }, + onKeyDown: event => { + if (event.key === 'Enter') { + this.runQuery(); + } else if (event.key === 'Escape' && view.historyOpen) { + view.historyOpen = false; + this.context.render(); + } else if (event.key === 's' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.context.store.toggleFavourite(view.queryInput); + } + }, + onFocus: () => wrap.classList.add('rxdt-focus'), + onBlur: () => wrap.classList.remove('rxdt-focus') + }); + const wrap = el('div', { + class: 'rxdt-query-input-wrap' + (view.queryError ? ' rxdt-invalid' : '') + }, [ + el('span', { class: 'rxdt-dim', text: 'find' }), + input, + el('span', { + class: 'rxdt-history-btn', + text: 'history ▾', + onClick: () => { + view.historyOpen = !view.historyOpen; + this.context.render(); + } + }) + ]); + const bar = el('div', { class: 'rxdt-querybar' }, [ + wrap, + button('Explain', () => { + this.runQuery(); + this.context.navigate({ kind: 'tool', tool: 'querylab' }); + }), + primaryButton('Run', () => this.runQuery(), { disabled: Boolean(view.queryError) }) + ]); + if (view.historyOpen) { + bar.appendChild(this.renderHistoryDropdown()); + } + return bar; + } + + private renderHistoryDropdown(): HTMLElement { + const view = this.view; + const history = this.context.store.queryHistory; + const favourites = history.filter(entry => entry.favourite); + const recent = history.filter(entry => !entry.favourite) + .sort((a, b) => b.usedAt - a.usedAt); + const dropdown = el('div', { class: 'rxdt-dropdown' }); + const applyEntry = (selector: string) => { + view.queryInput = selector; + view.historyOpen = false; + this.runQuery(); + }; + if (favourites.length > 0) { + dropdown.appendChild(el('div', { class: 'rxdt-dropdown-head', text: 'FAVOURITES' })); + favourites.forEach(entry => { + dropdown.appendChild(el('div', { + class: 'rxdt-dropdown-row rxdt-fav', + onClick: () => applyEntry(entry.selector) + }, [ + el('span', { style: { color: DEVTOOL_COLORS.pink }, text: '★' }), + el('span', { class: 'rxdt-dropdown-name', text: entry.name ?? entry.selector }), + el('span', { class: 'rxdt-mono', text: entry.selector }) + ])); + }); + } + if (recent.length > 0) { + dropdown.appendChild(el('div', { class: 'rxdt-dropdown-head', text: 'RECENT' })); + recent.forEach(entry => { + dropdown.appendChild(el('div', { + class: 'rxdt-dropdown-row', + onClick: () => applyEntry(entry.selector) + }, [ + el('span', { class: 'rxdt-dim', text: '↺' }), + el('span', { class: 'rxdt-mono', text: entry.selector }) + ])); + }); + } + if (favourites.length === 0 && recent.length === 0) { + dropdown.appendChild(el('div', { + class: 'rxdt-dropdown-row rxdt-dim', + text: 'No queries yet. Run one to see it here.' + })); + } + dropdown.appendChild(el('div', { + class: 'rxdt-dropdown-foot', + text: '↑↓ navigate · ↵ run · ⌘S save as favourite' + })); + return dropdown; + } + + private renderQueryError(error: { message: string; position: number; }): HTMLElement { + const view = this.view; + const caret = ' '.repeat(Math.max(0, error.position)) + '^'; + return el('div', { class: 'rxdt-query-error' }, [ + el('div', { + class: 'rxdt-mono', + style: { fontSize: '11px', color: DEVTOOL_COLORS.danger }, + text: '✕ ' + error.message + }), + el('div', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '11px', whiteSpace: 'pre', marginTop: '4px' } + }, [ + document.createTextNode(view.queryInput + '\n'), + el('span', { style: { color: DEVTOOL_COLORS.danger }, text: caret }) + ]), + el('div', { + class: 'rxdt-muted', + style: { fontSize: '11px', marginTop: '10px', lineHeight: '1.55' } + }, [ + document.createTextNode('Quote strings, use lowercase '), + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: 'true' }), + document.createTextNode('/'), + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: 'false' }), + document.createTextNode(', and prefix Mango operators with '), + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: '$' }), + document.createTextNode('. The previous results stay visible below.') + ]) + ]); + } + + private runQuery(): void { + const view = this.view; + const parsed = parseSelector(view.queryInput); + if (!parsed.ok) { + view.queryError = parsed.error; + this.context.render(); + return; + } + view.queryError = null; + view.selector = parsed.value; + view.page = 0; + view.historyOpen = false; + view.selection.clear(); + this.context.store.rememberQuery(view.queryInput.trim() === '' ? '{}' : view.queryInput.trim()); + this.load(); + } + + private renderGrid(): HTMLElement { + const view = this.view; + const columns = this.columns; + const template = this.gridTemplate; + const container = el('div', { class: 'rxdt-scroll' }); + + const allSelected = this.documents.length > 0 && + this.documents.every(documentData => view.selection.has(this.idOf(documentData))); + const headCells: (Node | string)[] = [ + el('input', { + type: 'checkbox', + class: 'rxdt-check', + checked: allSelected, + onChange: (event: Event) => { + const checked = (event.target as HTMLInputElement).checked; + this.documents.forEach(documentData => { + if (checked) { + view.selection.add(this.idOf(documentData)); + } else { + view.selection.delete(this.idOf(documentData)); + } + }); + this.context.render(); + } + }) + ]; + columns.forEach(column => { + const sorted = view.sort.field === column.path; + headCells.push(el('span', { + class: 'rxdt-th-click' + (sorted ? ' rxdt-sorted' : ''), + style: sorted ? { color: DEVTOOL_COLORS.fg } : {}, + text: column.label + (sorted ? (view.sort.direction === 'desc' ? ' ↓' : ' ↑') : ''), + onClick: () => { + if (sorted) { + view.sort.direction = view.sort.direction === 'desc' ? 'asc' : 'desc'; + } else { + view.sort = { field: column.path, direction: 'asc' }; + } + this.load(); + } + })); + }); + container.appendChild(gridHead(template, headCells)); + + this.documents.forEach(documentData => { + const documentId = this.idOf(documentData); + const selected = view.selection.has(documentId); + const cells: (Node | string)[] = [ + el('input', { + type: 'checkbox', + class: 'rxdt-check', + checked: selected, + onClick: (event: MouseEvent) => { + event.stopPropagation(); + if (view.selection.has(documentId)) { + view.selection.delete(documentId); + } else { + view.selection.add(documentId); + } + this.context.render(); + } + }) + ]; + columns.forEach(column => { + cells.push(this.renderCell(documentData, column.path)); + }); + const row = gridRow(template, cells, { + class: 'rxdt-tr' + (view.openDocumentId === documentId ? ' rxdt-selected' : ''), + onClick: () => { + view.openDocumentId = documentId; + view.stagedEdits = {}; + this.context.render(); + } + }); + container.appendChild(row); + }); + if (this.loading && this.documents.length === 0) { + container.appendChild(el('div', { + class: 'rxdt-dim rxdt-mono', + style: { padding: '8px 12px', fontSize: '10px' }, + text: 'loading…' + })); + } + return container; + } + + private renderCell(documentData: RxDocumentData, path: string): Node { + const view = this.view; + const documentId = this.idOf(documentData); + const raw = getByPath(documentData, path); + if (path === '_rev') { + return el('span', { class: 'rxdt-mono rxdt-dim', text: shortRevision(raw) }); + } + if (path === '_meta.lwt') { + return el('span', { class: 'rxdt-muted', text: raw ? formatAge(raw) : '' }); + } + const editable = !this.context.store.readOnly && + path !== this.collection.schema.primaryPath && + !path.startsWith('_'); + const isEditing = view.editingCell && + view.editingCell.documentId === documentId && + view.editingCell.field === path; + if (isEditing) { + const input = el('input', { + class: 'rxdt-cell-input', + value: typeof raw === 'string' ? raw : JSON.stringify(raw ?? null), + onClick: (event: MouseEvent) => event.stopPropagation(), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter') { + this.applyCellEdit(documentData, path, (event.target as HTMLInputElement).value); + } else if (event.key === 'Escape') { + view.editingCell = null; + this.context.render(); + } + }, + onBlur: (event: FocusEvent) => { + this.applyCellEdit(documentData, path, (event.target as HTMLInputElement).value); + } + }); + setTimeout(() => input.focus(), 0); + return input; + } + const type = valueType(raw); + const color = path === this.collection.schema.primaryPath + ? DEVTOOL_COLORS.fgMuted + : (type === 'boolean' ? (raw ? DEVTOOL_COLORS.success : DEVTOOL_COLORS.fgMuted) : undefined); + return el('span', { + class: type === 'string' && path !== this.collection.schema.primaryPath ? '' : 'rxdt-mono', + style: color ? { color } : {}, + title: editable ? 'double-click to edit' : undefined, + onDblClick: editable + ? (event: MouseEvent) => { + event.stopPropagation(); + view.editingCell = { documentId, field: path }; + this.context.render(); + } + : undefined, + text: previewValue(raw) + }); + } + + private async applyCellEdit(documentData: RxDocumentData, path: string, input: string): Promise { + const view = this.view; + view.editingCell = null; + const previous = getByPath(documentData, path); + const next = parseCellInput(input, previous); + if (next === previous) { + this.context.render(); + return; + } + const patch: any = {}; + setByPath(patch, path, next); + await this.writeDocument(this.idOf(documentData), patch); + } + + private renderJson(): HTMLElement { + const container = el('div', { class: 'rxdt-json' }); + if (this.documents.length === 0) { + container.appendChild(el('span', { class: 'rxdt-dim', text: '[]' })); + return container; + } + const now = Date.now(); + container.appendChild(document.createTextNode('[\n')); + this.documents.forEach((documentData, index) => { + const freshAt = this.freshDocuments.get(this.idOf(documentData)); + const isFresh = freshAt !== undefined && (now - freshAt) < FRESH_HIGHLIGHT_MS; + const block = el('span', { + class: 'rxdt-json-doc' + (isFresh ? ' rxdt-json-fresh' : '') + }); + block.appendChild(highlightJson(documentData, 2)); + block.appendChild(document.createTextNode( + index === this.documents.length - 1 ? '' : ',' + )); + if (isFresh) { + block.appendChild(el('span', { + class: 'rxdt-json-string', + text: ' ← updated ' + formatAge(freshAt as number, now) + })); + } + container.appendChild(block); + }); + container.appendChild(document.createTextNode(']')); + return container; + } + + private renderFooter(): HTMLElement { + const view = this.view; + const store = this.context.store; + const from = this.matchCount === 0 ? 0 : (view.page * store.pageSize) + 1; + const to = Math.min(this.matchCount, (view.page + 1) * store.pageSize); + const lastPage = Math.max(0, Math.ceil(this.matchCount / store.pageSize) - 1); + const selectionSize = view.selection.size; + + const footer = el('div', { class: 'rxdt-footer' }, [ + el('span', { text: formatNumber(from) + '–' + formatNumber(to) + ' of ' + formatNumber(this.matchCount) }), + el('button', { + class: 'rxdt-pager', + text: '‹', + disabled: view.page === 0, + onClick: () => { + view.page--; + this.load(); + } + }), + el('button', { + class: 'rxdt-pager', + text: '›', + disabled: view.page >= lastPage, + onClick: () => { + view.page++; + this.load(); + } + }) + ]); + if (selectionSize > 0) { + footer.appendChild(el('span', { class: 'rxdt-dim' }, [ + document.createTextNode(formatNumber(selectionSize) + ' selected · '), + el('a', { + text: 'Delete', + onClick: () => this.confirmDelete(Array.from(view.selection)) + }), + document.createTextNode(' · '), + el('a', { + text: 'Export selection', + onClick: () => this.exportSelection() + }) + ])); + } + footer.appendChild(spacer()); + if (!store.readOnly) { + footer.appendChild(primaryButton('+ New document', () => this.createDocument())); + } else { + footer.appendChild(button('+ New document', () => undefined, { + disabled: true, + title: store.dump ? 'not available on a dump' : 'the remote connection is read-only' + })); + } + return footer; + } + + private renderEmptyDatabase(): HTMLElement { + return el('div', { class: 'rxdt-center' }, [ + el('div', { class: 'rxdt-center-inner' }, [ + el('div', { class: 'rxdt-center-title', text: 'No collections yet' }), + el('div', { class: 'rxdt-center-body' }, [ + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: this.context.store.database.name }), + document.createTextNode(' is reachable but empty. Collections are declared in your app code:') + ]), + el('div', { + class: 'rxdt-code', + style: { marginTop: '10px', textAlign: 'left' }, + text: 'await db.addCollections({\n todos: { schema: todoSchema }\n})' + }), + el('div', { style: { marginTop: '10px', fontSize: '11px' } }, [ + el('a', { + href: 'https://rxdb.info/rx-schema.html', + target: '_blank', + rel: 'noopener', + text: 'Schema documentation' + }) + ]) + ]) + ]); + } + + private renderEmptyCollection(): HTMLElement { + const store = this.context.store; + return el('div', { class: 'rxdt-center' }, [ + el('div', { class: 'rxdt-center-inner' }, [ + el('div', { class: 'rxdt-center-title' }, [ + el('span', { class: 'rxdt-mono', text: this.collectionName }), + document.createTextNode(' has no documents') + ]), + el('div', { class: 'rxdt-center-body' }, [ + document.createTextNode('Create the first one here, or insert from the app with '), + el('span', { + class: 'rxdt-code-inline', + text: 'db.' + this.collectionName + '.insert({ … })' + }) + ]), + !store.readOnly && el('div', { class: 'rxdt-center-actions' }, [ + primaryButton('+ New document', () => this.createDocument()) + ]) + ]) + ]); + } + + private renderNoMatches(): HTMLElement { + const view = this.view; + const firstField = Object.keys(view.selector)[0]; + return el('div', { class: 'rxdt-center' }, [ + el('div', { class: 'rxdt-center-inner' }, [ + el('div', { + class: 'rxdt-center-title', + text: '0 of ' + formatNumber(this.totalCount) + ' documents match' + }), + el('div', { class: 'rxdt-center-body' }, [ + document.createTextNode('Values compare case-sensitively and matching is exact'), + firstField + ? document.createTextNode(' — the value you asked for may not exist in ' + firstField + '.') + : document.createTextNode('.') + ]), + el('div', { class: 'rxdt-center-actions' }, [ + button('Clear query', () => { + view.queryInput = '{}'; + this.runQuery(); + }), + firstField && button('Browse ' + firstField + ' values', () => { + view.queryInput = JSON.stringify({ [firstField]: { $exists: true } }); + this.runQuery(); + }) + ]) + ]) + ]); + } + + /** + * The document drawer. It stages every edit and previews the exact + * write in the WILL RUN block before anything runs. + */ + public renderDrawer(): HTMLElement | null { + const view = this.view; + if (!view.openDocumentId) { + return null; + } + const documentData = this.documents.find( + candidate => this.idOf(candidate) === view.openDocumentId + ); + if (!documentData) { + return null; + } + const collection = this.collection; + const primaryPath = collection.schema.primaryPath as string; + const edited = Object.keys(view.stagedEdits).length > 0; + const drawer = el('div', { class: 'rxdt-drawer' }, [ + el('div', { class: 'rxdt-drawer-head' }, [ + el('span', { + class: 'rxdt-mono', + style: { fontWeight: '700', fontSize: '12px' }, + text: view.openDocumentId + }), + edited && el('span', { class: 'rxdt-badge', text: 'edited' }), + spacer(), + el('span', { + class: 'rxdt-close', + text: '×', + onClick: () => { + view.openDocumentId = null; + view.stagedEdits = {}; + this.context.render(); + } + }) + ]), + el('div', { class: 'rxdt-drawer-group rxdt-drawer-group-first', text: 'FIELDS' }) + ]); + + Object.keys(documentData) + .filter(field => !INTERNAL_FIELDS.includes(field)) + .forEach(field => { + this.appendDrawerField(drawer, documentData, field, field === primaryPath); + }); + + drawer.appendChild(el('div', { class: 'rxdt-drawer-group', text: 'INTERNALS' })); + [ + ['_rev', shortRevision(documentData._rev)], + ['_deleted', String(Boolean(documentData._deleted))], + ['_meta.lwt', String(getByPath(documentData, '_meta.lwt') ?? '')] + ].forEach(([label, value]) => { + const lwt = getByPath(documentData, '_meta.lwt'); + drawer.appendChild(el('div', { class: 'rxdt-field rxdt-mono' }, [ + el('span', { class: 'rxdt-field-label', text: label }), + el('span', { class: 'rxdt-field-value', text: value }), + label === '_meta.lwt' && lwt + ? el('span', { class: 'rxdt-dim', text: formatAge(lwt) }) + : null + ])); + }); + + const attachments = (documentData as any)._attachments ?? {}; + const attachmentIds = Object.keys(attachments); + if (attachmentIds.length > 0) { + drawer.appendChild(el('div', { + class: 'rxdt-drawer-group', + text: 'ATTACHMENTS · ' + attachmentIds.length + })); + attachmentIds.forEach(attachmentId => { + drawer.appendChild(this.renderAttachment(attachmentId, attachments[attachmentId])); + }); + } + + if (!this.context.store.readOnly) { + drawer.appendChild(el('div', { + class: 'rxdt-drawer-group rxdt-drawer-group-run', + text: 'WILL RUN' + })); + drawer.appendChild(this.renderWillRun(documentData)); + drawer.appendChild(el('div', { style: { display: 'flex', gap: '8px', padding: '8px 12px 14px' } }, [ + primaryButton('Apply changes', () => this.applyStagedEdits(documentData), { disabled: !edited }), + button('Discard', () => { + view.stagedEdits = {}; + this.context.render(); + }, { disabled: !edited }) + ])); + } + return drawer; + } + + private appendDrawerField( + drawer: HTMLElement, + documentData: RxDocumentData, + field: string, + isPrimary: boolean + ): void { + const view = this.view; + const raw = field in view.stagedEdits ? view.stagedEdits[field] : (documentData as any)[field]; + const type = valueType(raw); + const isContainer = type === 'object' || type === 'array'; + + if (isContainer) { + const expanded = view.expandedFields.has(field); + drawer.appendChild(el('div', { class: 'rxdt-field' }, [ + el('span', { + class: 'rxdt-field-label rxdt-expandable', + text: (expanded ? '▾ ' : '▸ ') + field, + onClick: () => { + if (expanded) { + view.expandedFields.delete(field); + } else { + view.expandedFields.add(field); + } + this.context.render(); + } + }), + el('span', { class: 'rxdt-dim', text: previewValue(raw) }) + ])); + if (expanded) { + Object.keys(raw).forEach(key => { + const childValue = raw[key]; + drawer.appendChild(el('div', { class: 'rxdt-field-child' }, [ + el('span', { text: key }), + el('span', { + style: { color: valueType(childValue) === 'string' ? DEVTOOL_COLORS.success : DEVTOOL_COLORS.fgMuted }, + text: JSON.stringify(childValue) + }) + ])); + }); + } + return; + } + + if (isPrimary || this.context.store.readOnly) { + drawer.appendChild(el('div', { class: 'rxdt-field' }, [ + el('span', { class: 'rxdt-field-label', text: field }), + el('span', { class: 'rxdt-field-value', text: previewValue(raw) }), + isPrimary && el('span', { class: 'rxdt-badge-neutral', text: 'primary' }) + ])); + return; + } + + const isEdited = field in view.stagedEdits; + drawer.appendChild(el('div', { class: 'rxdt-field' }, [ + el('span', { class: 'rxdt-field-label', text: field }), + el('input', { + class: 'rxdt-field-input' + (isEdited ? ' rxdt-edited' : ''), + value: typeof raw === 'string' ? raw : JSON.stringify(raw ?? null), + onChange: (event: Event) => { + const nextValue = parseCellInput( + (event.target as HTMLInputElement).value, + (documentData as any)[field] + ); + if (JSON.stringify(nextValue) === JSON.stringify((documentData as any)[field])) { + delete view.stagedEdits[field]; + } else { + view.stagedEdits[field] = nextValue; + } + this.context.render(); + } + }), + isEdited && el('span', { class: 'rxdt-edited-dot', title: 'modified' }) + ])); + } + + private renderAttachment(attachmentId: string, meta: any): HTMLElement { + const wrapper = el('div', { class: 'rxdt-attachment' }, [ + el('div', { class: 'rxdt-attachment-head' }, [ + el('span', { class: 'rxdt-mono', text: attachmentId }), + el('span', { + class: 'rxdt-dim', + text: (meta.type ?? 'unknown') + ' · ' + formatBytes(meta.length ?? 0) + }), + spacer(), + el('a', { + text: 'download', + style: { fontSize: '10px' }, + onClick: () => this.downloadAttachment(attachmentId) + }) + ]) + ]); + if (typeof meta.type === 'string' && meta.type.startsWith('image/')) { + const image = el('img', { class: 'rxdt-attachment-preview', alt: attachmentId }); + wrapper.appendChild(image); + this.readAttachment(attachmentId).then(blob => { + if (blob) { + image.src = URL.createObjectURL(blob); + } + }).catch(() => { + wrapper.removeChild(image); + }); + } + return wrapper; + } + + private async readAttachment(attachmentId: string): Promise { + const view = this.view; + if (!view.openDocumentId) { + return null; + } + const rxDocument = await this.collection.findOne(view.openDocumentId).exec(); + if (!rxDocument || typeof (rxDocument as any).getAttachment !== 'function') { + return null; + } + const attachment = (rxDocument as any).getAttachment(attachmentId); + return attachment ? attachment.getData() : null; + } + + private async downloadAttachment(attachmentId: string): Promise { + const blob = await this.readAttachment(attachmentId); + if (!blob) { + return; + } + const url = URL.createObjectURL(blob); + const anchor = el('a', { href: url, download: attachmentId }); + anchor.click(); + URL.revokeObjectURL(url); + } + + /** + * Shows the exact upsert that runs on Apply, with the changed + * lines highlighted. Nothing has run when this is drawn. + */ + private renderWillRun(documentData: RxDocumentData): HTMLElement { + const view = this.view; + const merged: any = {}; + Object.keys(documentData) + .filter(field => !INTERNAL_FIELDS.includes(field)) + .forEach(field => { + merged[field] = field in view.stagedEdits + ? view.stagedEdits[field] + : (documentData as any)[field]; + }); + const block = el('div', { class: 'rxdt-will-run' }, [ + el('span', { class: 'rxdt-dim', text: '// applied on save — nothing has run yet\n' }), + document.createTextNode( + 'await ' + this.context.store.database.name + '.' + this.collectionName + '.upsert({\n' + ) + ]); + const fields = Object.keys(merged); + fields.forEach((field, index) => { + const line = ' ' + JSON.stringify(field) + ': ' + JSON.stringify(merged[field]) + + (index === fields.length - 1 ? '' : ',') + '\n'; + if (field in view.stagedEdits) { + block.appendChild(el('span', { class: 'rxdt-will-run-changed', text: line })); + } else { + block.appendChild(document.createTextNode(line)); + } + }); + block.appendChild(document.createTextNode('})')); + return block; + } + + private async applyStagedEdits(documentData: RxDocumentData): Promise { + const view = this.view; + const patch = { ...view.stagedEdits }; + view.stagedEdits = {}; + await this.writeDocument(this.idOf(documentData), patch); + } + + private async writeDocument(documentId: string, patch: any): Promise { + const collection = this.collection; + try { + const rxDocument = await collection.findOne(documentId).exec(); + if (!rxDocument) { + return; + } + this.context.store.markDevtoolWrite(this.collectionName, documentId); + await rxDocument.incrementalPatch(patch); + } catch (error) { + this.context.notify((error as Error).message); + } + await this.load(); + } + + private async createDocument(): Promise { + const collection = this.collection; + const primaryPath = collection.schema.primaryPath as string; + const draft: any = {}; + const properties: any = collection.schema.jsonSchema.properties ?? {}; + Object.keys(properties).forEach(field => { + if (INTERNAL_FIELDS.includes(field)) { + return; + } + const type = properties[field].type; + if (field === primaryPath) { + draft[field] = 'doc_' + Math.random().toString(36).slice(2, 10); + } else if (type === 'string') { + draft[field] = ''; + } else if (type === 'number' || type === 'integer') { + draft[field] = 0; + } else if (type === 'boolean') { + draft[field] = false; + } else if (type === 'array') { + draft[field] = []; + } else if (type === 'object') { + draft[field] = {}; + } + }); + try { + this.context.store.markDevtoolWrite(this.collectionName, draft[primaryPath]); + await collection.insert(draft); + this.view.openDocumentId = draft[primaryPath]; + this.view.stagedEdits = {}; + } catch (error) { + this.context.notify((error as Error).message); + } + await this.load(); + } + + /** + * Deleting many documents at once states the blast radius and + * requires the collection name to be typed before it is enabled. + */ + private confirmDelete(documentIds: string[]): void { + const collection = this.collection; + const confirmInput = el('input', { + class: 'rxdt-modal-input', + placeholder: this.collectionName, + spellcheck: 'false', + onInput: (event: Event) => { + deleteButton.disabled = (event.target as HTMLInputElement).value.trim() !== this.collectionName; + } + }); + const deleteButton = button( + 'Delete ' + formatNumber(documentIds.length) + ' documents', + async () => { + this.context.setOverlay(null); + try { + documentIds.forEach(id => this.context.store.markDevtoolWrite(this.collectionName, id)); + await collection.bulkRemove(documentIds); + } catch (error) { + this.context.notify((error as Error).message); + } + this.view.selection.clear(); + this.view.openDocumentId = null; + await this.load(); + }, + { variant: 'dangerSolid', disabled: true } + ); + const modal = el('div', { class: 'rxdt-modal-backdrop' }, [ + el('div', { class: 'rxdt-modal' }, [ + el('div', { + class: 'rxdt-modal-title', + text: 'Delete ' + formatNumber(documentIds.length) + ' documents?' + }), + el('div', { class: 'rxdt-modal-body' }, [ + document.createTextNode('This removes every selected document in '), + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: this.collectionName }), + document.createTextNode( + ' — ' + formatNumber(documentIds.length) + ' of ' + formatNumber(this.totalCount) + + '. Deletes replicate to all connected peers. Tombstones remain until cleanup.' + ) + ]), + el('div', { + class: 'rxdt-dim', + style: { marginTop: '12px', fontSize: '11px' }, + text: 'Type the collection name to confirm:' + }), + confirmInput, + el('div', { class: 'rxdt-modal-actions' }, [ + button('Cancel', () => this.context.setOverlay(null)), + deleteButton + ]) + ]) + ]); + this.context.setOverlay(modal); + setTimeout(() => confirmInput.focus(), 0); + } + + private async exportCollection(): Promise { + const database = this.context.store.database; + try { + if (typeof database.exportJSON === 'function') { + const dump = await database.exportJSON([this.collectionName] as any); + downloadJson(database.name + '-' + this.collectionName + '.json', dump); + return; + } + } catch (error) { + this.context.notify('exportJSON needs the json-dump plugin, exporting the current page instead.'); + } + downloadJson(database.name + '-' + this.collectionName + '.json', this.documents); + } + + private exportSelection(): void { + const view = this.view; + const selected = this.documents.filter( + documentData => view.selection.has(this.idOf(documentData)) + ); + downloadJson(this.collectionName + '-selection.json', selected); + } + + private idOf(documentData: RxDocumentData): string { + return String((documentData as any)[this.collection.schema.primaryPath as string]); + } +} diff --git a/src/plugins/devtool/parts/connection-screens.ts b/src/plugins/devtool/parts/connection-screens.ts new file mode 100644 index 00000000000..44f861dc810 --- /dev/null +++ b/src/plugins/devtool/parts/connection-screens.ts @@ -0,0 +1,146 @@ +import { button, el, primaryButton } from '../dom.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import type { DevtoolConnection, DevtoolConnectionStage } from '../../../types/index.d.ts'; + +/** + * Connecting and failing are full screens, not toasts, + * because they are the only thing the user can act on at that moment. + */ +export function renderConnectingScreen( + connection: Extract, + onCancel: () => void +): HTMLElement { + return el('div', { class: 'rxdt-center' }, [ + el('div', { style: { width: '420px', maxWidth: '100%' } }, [ + el('div', { class: 'rxdt-row', style: { gap: '10px' } }, [ + el('div', { class: 'rxdt-logo', style: { width: '16px', height: '16px' } }), + el('span', { style: { fontWeight: '800', fontSize: '15px' }, text: 'Connecting to remote database' }) + ]), + el('div', { class: 'rxdt-muted', style: { fontSize: '11.5px', marginTop: '6px' } }, [ + connection.pairingCode + ? el('span', {}, [ + document.createTextNode('Pairing code '), + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: connection.pairingCode }), + document.createTextNode(' · usually under 10 seconds') + ]) + : el('span', { text: 'Usually under 10 seconds' }) + ]), + el('div', { + style: { marginTop: '18px', display: 'flex', flexDirection: 'column', gap: '10px', fontSize: '12px' } + }, connection.stages.map((stage, index) => renderStage( + stage, + index < connection.currentStage ? 'done' : (index === connection.currentStage ? 'current' : 'pending'), + index === connection.currentStage && connection.elapsedSeconds !== undefined + ? connection.elapsedSeconds + 's' + : undefined + ))), + el('div', { + class: 'rxdt-dim', + style: { marginTop: '18px', fontSize: '10.5px', lineHeight: '1.55' }, + text: 'Restrictive networks can block peer-to-peer traffic. If this stalls past 30 seconds it fails with a diagnosis, it will not retry silently.' + }), + el('div', { style: { marginTop: '14px' } }, [button('Cancel', onCancel)]) + ]) + ]); +} + +export function renderFailedScreen( + connection: Extract, + onOpenDump: (() => void) | undefined +): HTMLElement { + const failedStage = connection.stages[connection.failedStage]; + return el('div', { class: 'rxdt-center' }, [ + el('div', { style: { width: '480px', maxWidth: '100%' } }, [ + el('div', { class: 'rxdt-row', style: { gap: '10px' } }, [ + el('span', { + style: { + width: '18px', + height: '18px', + background: 'rgba(253,54,110,0.15)', + border: '1px solid ' + DEVTOOL_COLORS.danger, + color: DEVTOOL_COLORS.danger, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '11px' + }, + text: '✕' + }), + el('span', { style: { fontWeight: '800', fontSize: '15px' }, text: 'Peer connection failed' }) + ]), + el('div', { + class: 'rxdt-muted', + style: { fontSize: '11.5px', marginTop: '6px' }, + text: 'Failed at step ' + (connection.failedStage + 1) + ' of ' + connection.stages.length + + (failedStage ? ' — ' + failedStage.label.toLowerCase() : '') + '.' + }), + el('div', { + style: { marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '12px' } + }, connection.stages.map((stage, index) => renderStage( + stage, + index < connection.failedStage ? 'done' : (index === connection.failedStage ? 'failed' : 'pending') + ))), + el('div', { + style: { + marginTop: '14px', + border: '1px solid rgba(253,54,110,0.4)', + background: 'rgba(253,54,110,0.06)', + padding: '10px 12px', + fontSize: '11.5px', + lineHeight: '1.55', + color: DEVTOOL_COLORS.fgMuted + }, + text: connection.diagnosis + }), + el('div', { + style: { marginTop: '12px', border: '1px solid rgba(255,255,255,0.14)', padding: '10px 12px' } + }, [ + el('div', { style: { fontWeight: '700', fontSize: '12px' }, text: 'Work from an export instead' }), + el('div', { + class: 'rxdt-muted', + style: { fontSize: '11px', marginTop: '4px', lineHeight: '1.55' } + }, [ + document.createTextNode('On the device, run '), + el('span', { class: 'rxdt-code-inline', text: 'await db.exportJSON()' }), + document.createTextNode(', save the result, and open it here. Read-only, frozen at export time.') + ]), + el('div', { class: 'rxdt-row', style: { gap: '10px', marginTop: '10px' } }, [ + primaryButton('Open dump file…', () => onOpenDump?.(), { disabled: !onOpenDump }), + el('a', { + href: 'https://rxdb.info/json-dump.html', + target: '_blank', + rel: 'noopener', + style: { fontSize: '11px' }, + text: 'How to export a dump' + }) + ]) + ]) + ]) + ]); +} + +function renderStage( + stage: DevtoolConnectionStage, + state: 'done' | 'current' | 'failed' | 'pending', + detail?: string +): HTMLElement { + const glyphs = { done: '✓', current: '●', failed: '✕', pending: '○' }; + const colors = { + done: DEVTOOL_COLORS.success, + current: DEVTOOL_COLORS.pink, + failed: DEVTOOL_COLORS.danger, + pending: DEVTOOL_COLORS.fgDim + }; + return el('div', { + class: 'rxdt-stage', + style: state === 'pending' ? { color: DEVTOOL_COLORS.fgDim } : {} + }, [ + el('span', { class: 'rxdt-stage-glyph', style: { color: colors[state] }, text: glyphs[state] }), + el('span', { + style: (state === 'current' || state === 'failed') ? { fontWeight: '700' } : {}, + text: stage.label + }), + stage.detail && el('span', { class: 'rxdt-dim', style: { fontSize: '10px' }, text: stage.detail }), + detail && el('span', { class: 'rxdt-dim', style: { fontSize: '10px' }, text: detail }) + ]); +} diff --git a/src/plugins/devtool/parts/context.ts b/src/plugins/devtool/parts/context.ts new file mode 100644 index 00000000000..ade19bcd6fa --- /dev/null +++ b/src/plugins/devtool/parts/context.ts @@ -0,0 +1,32 @@ +import type { DevtoolStore } from '../store.ts'; +import type { DevtoolNavigation } from '../../../types/index.d.ts'; + +/** + * What every panel needs from the shell it is mounted in. + */ +export type PanelContext = { + store: DevtoolStore; + /** + * Re-renders the whole devtool from the current state. + */ + render: () => void; + navigate: (navigation: DevtoolNavigation) => void; + /** + * Shows a modal or sub panel above the content, `null` closes it. + */ + setOverlay: (node: HTMLElement | null) => void; + /** + * Reports a failed action to the user. + */ + notify: (message: string) => void; +}; + +export function downloadJson(fileName: string, data: any): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/src/plugins/devtool/parts/live-panel.ts b/src/plugins/devtool/parts/live-panel.ts new file mode 100644 index 00000000000..ef02f54aed8 --- /dev/null +++ b/src/plugins/devtool/parts/live-panel.ts @@ -0,0 +1,730 @@ +import type { Subscription } from 'rxjs'; +import { button, clear, el, gridHead, gridRow, spacer } from '../dom.ts'; +import { formatAge, formatNumber, formatRate } from '../format.ts'; +import { DEVTOOL_COLORS, DEVTOOL_NARROW_BREAKPOINT } from '../theme.ts'; +import { METRICS_WINDOW_MS } from '../store.ts'; +import type { CollectionMetrics } from '../store.ts'; +import { replicationGlyph } from './rail.ts'; +import type { PanelContext } from './context.ts'; +import type { DevtoolLiveEvent, RxDatabase } from '../../../types/index.d.ts'; + +/** + * Above this rate a lane stops drawing single particles and + * becomes a moving band whose speed maps log-scale to the rate. + */ +const BURST_EVENTS_PER_SECOND = 200; +/** + * A node border may only pulse four times per second. + */ +const PULSE_INTERVAL_MS = 250; +const PARTICLE_TRAVEL_MS = 2200; + +type ParticleSpec = { + glyph: string; + color: string; + direction: 'right' | 'left'; +}; + +const PARTICLES: { [key in DevtoolLiveEvent['kind']]: ParticleSpec } = { + insert: { glyph: '+', color: DEVTOOL_COLORS.success, direction: 'right' }, + update: { glyph: '~', color: DEVTOOL_COLORS.warning, direction: 'right' }, + delete: { glyph: '-', color: DEVTOOL_COLORS.danger, direction: 'right' }, + query: { glyph: '?', color: DEVTOOL_COLORS.info, direction: 'left' }, + emit: { glyph: '◆', color: DEVTOOL_COLORS.info, direction: 'left' }, + pull: { glyph: '↓', color: DEVTOOL_COLORS.replication, direction: 'left' }, + push: { glyph: '↑', color: DEVTOOL_COLORS.replication, direction: 'right' } +}; + +const LEGEND: { glyph: string; label: string; color: string; }[] = [ + { glyph: '+', label: 'insert', color: DEVTOOL_COLORS.success }, + { glyph: '~', label: 'update', color: DEVTOOL_COLORS.warning }, + { glyph: '-', label: 'delete', color: DEVTOOL_COLORS.danger }, + { glyph: '?', label: 'query', color: DEVTOOL_COLORS.info }, + { glyph: '◆', label: 'live result', color: DEVTOOL_COLORS.info }, + { glyph: '↓', label: 'pull', color: DEVTOOL_COLORS.replication }, + { glyph: '↑', label: 'push', color: DEVTOOL_COLORS.replication } +]; + +/** + * The database drawn as a fixed-position map: app → collections → remote. + * Only names, counts and rates are drawn, never document contents, + * which is what makes this screen safe to screen-share. + */ +export class LivePanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt-main' }); + + private subscription: Subscription | null = null; + private readonly inLanes = new Map(); + private readonly outLanes = new Map(); + private readonly nodes = new Map(); + private readonly lastPulseAt = new Map(); + + constructor(private readonly context: PanelContext) { + this.subscription = this.context.store.liveEvents$.subscribe(event => { + this.onLiveEvent(event); + }); + } + + public destroy(): void { + this.subscription?.unsubscribe(); + this.subscription = null; + } + + private onLiveEvent(event: DevtoolLiveEvent): void { + if (this.context.store.livePaused) { + return; + } + const isOutbound = event.kind === 'pull' || event.kind === 'push'; + const lane = isOutbound + ? this.outLanes.get(event.collectionName) + : this.inLanes.get(event.collectionName); + if (lane) { + this.spawnParticle(lane, PARTICLES[event.kind]); + } + if (event.kind === 'insert' || event.kind === 'update' || event.kind === 'delete') { + this.pulseNode(event.collectionName); + } + } + + private spawnParticle(lane: HTMLElement, spec: ParticleSpec): void { + const track = lane.querySelector('.rxdt-track') as HTMLElement | null; + if (!track) { + return; + } + const particle = el('span', { + class: 'rxdt-particle', + text: spec.glyph, + style: { + color: spec.color, + animation: (spec.direction === 'right' ? 'rxdtFlowR ' : 'rxdtFlowL ') + + PARTICLE_TRAVEL_MS + 'ms linear forwards' + } + }); + track.appendChild(particle); + setTimeout(() => particle.remove(), PARTICLE_TRAVEL_MS + 50); + } + + private pulseNode(collectionName: string): void { + const now = Date.now(); + const last = this.lastPulseAt.get(collectionName) ?? 0; + if (now - last < PULSE_INTERVAL_MS) { + return; + } + this.lastPulseAt.set(collectionName, now); + const node = this.nodes.get(collectionName); + if (!node) { + return; + } + node.classList.remove('rxdt-node-pulse'); + void node.offsetWidth; + node.classList.add('rxdt-node-pulse'); + } + + public render(): HTMLElement { + clear(this.element); + this.inLanes.clear(); + this.outLanes.clear(); + this.nodes.clear(); + + const now = Date.now(); + const narrow = this.element.clientWidth > 0 + ? this.element.clientWidth < DEVTOOL_NARROW_BREAKPOINT + : false; + + this.element.appendChild(this.renderHeader()); + if (narrow) { + this.element.appendChild(this.renderCompactList(now)); + } else { + this.element.appendChild(this.renderMap(now)); + } + this.element.appendChild(this.renderSummary(now)); + return this.element; + } + + private renderHeader(): HTMLElement { + const store = this.context.store; + return el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title', text: 'Live' }), + el('span', { + class: 'rxdt-dot' + (store.livePaused ? '' : ' rxdt-blink'), + style: { background: store.livePaused ? DEVTOOL_COLORS.fgDim : DEVTOOL_COLORS.success } + }), + el('span', { class: 'rxdt-dim rxdt-mono', style: { fontSize: '10px' }, text: '60s window' }), + el('div', { class: 'rxdt-legend' }, LEGEND.map(entry => el('span', {}, [ + el('span', { style: { color: entry.color, fontWeight: '700' }, text: entry.glyph }), + document.createTextNode(' ' + entry.label) + ]))), + spacer(), + button(store.livePaused ? 'Resume' : 'Pause', () => { + store.livePaused = !store.livePaused; + this.context.render(); + }, { small: true }) + ]); + } + + private renderMap(now: number): HTMLElement { + const store = this.context.store; + const collectionNames = store.collectionNames; + const map = el('div', { class: 'rxdt-map' }); + + map.appendChild(this.renderAppColumn(now)); + + const rows = el('div', { class: 'rxdt-map-rows' }); + rows.appendChild(el('div', { + style: { display: 'flex', fontSize: '9px', letterSpacing: '0.09em', color: DEVTOOL_COLORS.fgDim, fontWeight: '600' } + }, [ + spacer(), + el('div', { style: { width: '296px', minWidth: '296px' }, text: 'COLLECTIONS' }), + spacer(), + el('div', { style: { width: '186px', minWidth: '186px' }, text: 'REMOTE' }) + ])); + + if (collectionNames.length === 0) { + rows.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '12px 0', lineHeight: '1.55' }, + text: 'No collections yet. This screen updates as the app reads and writes.' + })); + } + + collectionNames.forEach(collectionName => { + rows.appendChild(this.renderMapRow(collectionName, now)); + }); + map.appendChild(rows); + return map; + } + + private renderAppColumn(now: number): HTMLElement { + const store = this.context.store; + const totalWrites = store.collectionNames.reduce( + (sum, name) => sum + store.getMetrics(name).writes.total(now), 0 + ); + const totalReads = store.collectionNames.reduce( + (sum, name) => sum + store.getMetrics(name).reads.total(now), 0 + ); + const windowSeconds = METRICS_WINDOW_MS / 1000; + const isLeader = readLeadership(store.database); + + return el('div', { class: 'rxdt-map-col' }, [ + el('div', { class: 'rxdt-section-label', text: 'APP' }), + el('div', { class: 'rxdt-node rxdt-node-app' }, [ + el('div', { class: 'rxdt-row', style: { gap: '6px' } }, [ + el('span', { class: 'rxdt-mono', style: { fontWeight: '700', fontSize: '11px' }, text: 'this tab' }), + spacer(), + isLeader === true && el('span', { class: 'rxdt-badge-success', text: 'leader' }) + ]), + el('div', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '9.5px', marginTop: '4px' }, + text: instanceKind() + ' · ' + currentPath() + }), + el('div', { + class: 'rxdt-mono rxdt-muted', + style: { display: 'flex', gap: '8px', marginTop: '6px', fontSize: '9.5px' } + }, [ + el('span', {}, [ + el('span', { style: { color: DEVTOOL_COLORS.pink }, text: 'w' }), + document.createTextNode(' ' + formatRate(totalWrites / windowSeconds) + '/s') + ]), + el('span', {}, [ + el('span', { style: { color: DEVTOOL_COLORS.info }, text: '?' }), + document.createTextNode(' ' + formatRate(totalReads / windowSeconds) + '/s') + ]) + ]) + ]), + el('div', { + class: 'rxdt-node rxdt-node-clickable', + style: { padding: '8px 10px', display: 'flex', alignItems: 'center', gap: '6px' }, + onClick: () => this.openInstances() + }, [ + el('span', { class: 'rxdt-mono', style: { fontSize: '11px' }, text: 'open instances' }), + spacer(), + el('span', { class: 'rxdt-dim', text: '›' }) + ]), + el('div', { class: 'rxdt-node-dashed' }, [ + el('div', { class: 'rxdt-mono', style: { fontSize: '10.5px' }, text: 'viewer writes' }), + el('div', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '9.5px', marginTop: '3px' }, + text: formatNumber(store.viewerWriteCount) + ' total · from this devtool' + }) + ]), + spacer(), + el('div', { + class: 'rxdt-dim', + style: { fontSize: '9.5px', lineHeight: '1.5' }, + text: 'Fixed positions. No document contents are drawn — names, counts and rates only.' + }) + ]); + } + + private renderMapRow(collectionName: string, now: number): HTMLElement { + const store = this.context.store; + const metrics = store.getMetrics(collectionName); + const windowSeconds = METRICS_WINDOW_MS / 1000; + const writes = metrics.writes.total(now); + const reads = metrics.reads.total(now); + const writeRate = writes / windowSeconds; + const liveQueries = store.getLiveQueries(collectionName) + .filter(info => info.subscribers > 0); + const idle = metrics.lastWriteAt === 0 || (now - metrics.lastWriteAt) > METRICS_WINDOW_MS; + + const inLane = this.renderLane( + 'in', + writes / windowSeconds > BURST_EVENTS_PER_SECOND + ? { rate: writeRate, color: DEVTOOL_COLORS.warning, unit: 'w/s' } + : null, + liveQueries.length > 0, + idle + ? (metrics.lastWriteAt === 0 ? 'no events yet' : 'last write ' + formatAge(metrics.lastWriteAt, now)) + : formatRate(writeRate) + ' w/s in · ' + formatRate(reads / windowSeconds) + ' ?/s out' + ); + this.inLanes.set(collectionName, inLane); + + const replicationStates = store.getReplicationStates(collectionName); + const hasRemote = replicationStates.length > 0; + const pulls = metrics.pulls.total(now); + const pushes = metrics.pushes.total(now); + const outLane = this.renderLane( + 'out', + null, + false, + hasRemote + ? '↓ ' + formatNumber(pulls) + ' · ↑ ' + formatNumber(pushes) + ' in 60s' + : '', + store.replicationErrors.has(collectionName) + ); + this.outLanes.set(collectionName, outLane); + + const node = this.renderCollectionNode(collectionName, metrics, now, liveQueries.length); + this.nodes.set(collectionName, node); + + return el('div', { class: 'rxdt-map-row' }, [ + inLane, + node, + outLane, + this.renderRemoteNode(collectionName) + ]); + } + + private renderLane( + side: 'in' | 'out', + band: { rate: number; color: string; unit: string; } | null, + thread: boolean, + label: string, + errored = false + ): HTMLElement { + const lane = el('div', { + class: 'rxdt-lane', + style: side === 'in' ? { paddingRight: '6px' } : { paddingLeft: '6px' } + }); + if (band) { + /** + * Above the burst threshold the exact number always sits next to + * the band, so the picture stays readable with motion disabled. + */ + const durationSeconds = Math.max(0.15, 1.2 - (Math.log10(band.rate) * 0.28)); + lane.appendChild(el('div', { class: 'rxdt-row', style: { gap: '8px' } }, [ + el('div', { + class: 'rxdt-band', + style: { + background: 'repeating-linear-gradient(90deg,' + band.color + ' 0 6px,rgba(255,255,255,0.08) 6px 24px)', + animation: 'rxdtBand ' + durationSeconds.toFixed(2) + 's linear infinite' + } + }), + el('span', { + class: 'rxdt-mono rxdt-muted', + style: { fontSize: '10px' }, + text: formatNumber(band.rate) + ' ' + band.unit + }) + ])); + } else { + lane.appendChild(el('div', { class: 'rxdt-track' }, [ + el('div', { + class: 'rxdt-track-line' + (errored ? ' rxdt-track-line-error' : '') + }) + ])); + } + if (thread) { + lane.appendChild(el('div', { class: 'rxdt-track' }, [ + el('div', { class: 'rxdt-track-line rxdt-track-line-thread' }) + ])); + } + lane.appendChild(el('div', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '9px' }, + text: label + })); + return lane; + } + + private renderCollectionNode( + collectionName: string, + metrics: CollectionMetrics, + now: number, + liveQueryCount: number + ): HTMLElement { + const windowSeconds = METRICS_WINDOW_MS / 1000; + const writeRate = metrics.writes.total(now) / windowSeconds; + const node = el('div', { + class: 'rxdt-node', + style: { width: '296px', minWidth: '296px' } + }, [ + el('div', { class: 'rxdt-row', style: { gap: '8px' } }, [ + el('span', { class: 'rxdt-mono', style: { fontWeight: '700', fontSize: '12px' }, text: collectionName }), + el('span', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '10px' }, + text: formatNumber(metrics.documentCount) + ' docs' + }), + spacer(), + metrics.migration + ? el('span', { class: 'rxdt-badge-warning', text: 'migrating' }) + : el('span', { + class: 'rxdt-mono rxdt-muted', + style: { fontSize: '10px' }, + text: formatRate(writeRate) + ' w/s' + }) + ]) + ]); + + if (metrics.migration) { + const percent = metrics.migration.total === 0 + ? 0 + : Math.round((metrics.migration.done / metrics.migration.total) * 100); + node.appendChild(el('div', { class: 'rxdt-progress' }, [ + el('div', { style: { width: percent + '%' } }) + ])); + node.appendChild(el('div', { + class: 'rxdt-mono rxdt-dim', + style: { display: 'flex', marginTop: '5px', fontSize: '9.5px' } + }, [ + el('span', { + text: 'schema v' + metrics.migration.fromVersion + ' → v' + metrics.migration.toVersion + }), + spacer(), + el('span', { + class: 'rxdt-muted', + text: percent + '% · ' + formatNumber(metrics.migration.done) + + ' of ' + formatNumber(metrics.migration.total) + }) + ])); + return node; + } + + const series = metrics.writes.series(now); + const peak = Math.max(1, ...series); + node.appendChild(el('div', { class: 'rxdt-spark' }, series.map(value => el('div', { + style: { height: Math.round((value / peak) * 100) + '%' }, + title: value + ' writes' + })))); + node.appendChild(el('div', { + class: 'rxdt-mono rxdt-dim', + style: { display: 'flex', gap: '10px', marginTop: '5px', fontSize: '9.5px' } + }, [ + el('span', { text: '60s sparkline' }), + spacer(), + el('span', { + class: 'rxdt-muted', + style: { cursor: 'pointer' }, + text: liveQueryCount + ' live queries ›', + onClick: (event: MouseEvent) => { + event.stopPropagation(); + this.openLiveQueries(collectionName); + } + }) + ])); + return node; + } + + private renderRemoteNode(collectionName: string): HTMLElement { + const store = this.context.store; + const replicationStates = store.getReplicationStates(collectionName); + const error = store.replicationErrors.get(collectionName); + if (replicationStates.length === 0) { + return el('div', { + class: 'rxdt-node', + style: { width: '186px', minWidth: '186px' } + }, [ + el('div', { class: 'rxdt-dim', style: { fontSize: '10px', lineHeight: '1.5' } }, [ + document.createTextNode('no replication configured for '), + el('span', { class: 'rxdt-mono', text: collectionName }) + ]) + ]); + } + const glyph = replicationGlyph(store, collectionName); + const pullState = replicationStates.some(state => state.pull) ? glyph : null; + const pushState = replicationStates.some(state => state.push) ? glyph : null; + return el('div', { + class: 'rxdt-node' + (error ? ' rxdt-node-error' : ''), + style: { width: '186px', minWidth: '186px' } + }, [ + el('div', { + class: 'rxdt-mono', + style: { fontSize: '10.5px', wordBreak: 'break-all' }, + text: replicationStates.map(state => state.replicationIdentifier).join(', ') + }), + el('div', { + class: 'rxdt-mono', + style: { display: 'flex', flexDirection: 'column', gap: '3px', marginTop: '6px', fontSize: '9.5px' } + }, [ + el('div', { style: { display: 'flex' } }, [ + el('span', { class: 'rxdt-dim', text: 'pull' }), + spacer(), + el('span', { + style: { color: pullState ? pullState.color : DEVTOOL_COLORS.fgDim }, + text: pullState ? pullState.glyph + ' ' + pullState.state : '– none' + }) + ]), + el('div', { style: { display: 'flex' } }, [ + el('span', { class: 'rxdt-dim', text: 'push' }), + spacer(), + el('span', { + style: { color: pushState ? pushState.color : DEVTOOL_COLORS.fgDim }, + text: pushState ? pushState.glyph + ' ' + pushState.state : '– none' + }) + ]) + ]) + ]); + } + + /** + * Below 640px the three column map does not fit, + * so the same numbers are shown as one row per collection. + */ + private renderCompactList(now: number): HTMLElement { + const store = this.context.store; + const container = el('div', { class: 'rxdt-scroll' }); + container.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '10px 14px', fontSize: '11px', lineHeight: '1.5', borderBottom: '1px solid rgba(255,255,255,0.08)' }, + text: 'The map needs three columns and does not fit here. Same numbers, one row per collection.' + })); + const rows = store.collectionNames.map(collectionName => { + const metrics = store.getMetrics(collectionName); + return { + collectionName, + metrics, + activity: metrics.writes.total(now) + metrics.reads.total(now) + }; + }).sort((a, b) => b.activity - a.activity); + const peak = Math.max(1, ...rows.map(row => row.activity)); + rows.forEach(row => { + const liveQueryCount = store.getLiveQueries(row.collectionName) + .filter(info => info.subscribers > 0).length; + container.appendChild(el('div', { + style: { padding: '9px 14px', borderBottom: '1px solid rgba(255,255,255,0.06)' } + }, [ + el('div', { class: 'rxdt-row', style: { gap: '8px' } }, [ + el('span', { class: 'rxdt-mono', style: { fontSize: '12px' }, text: row.collectionName }), + spacer(), + el('span', { + class: 'rxdt-mono rxdt-muted', + style: { fontSize: '11px' }, + text: formatNumber(row.metrics.documentCount) + ' docs' + }) + ]), + el('div', { style: { height: '6px', background: DEVTOOL_COLORS.bg, marginTop: '6px' } }, [ + el('div', { + style: { + width: Math.round((row.activity / peak) * 100) + '%', + height: '100%', + background: DEVTOOL_COLORS.pink + } + }) + ]), + el('div', { + class: 'rxdt-mono rxdt-dim', + style: { display: 'flex', gap: '12px', marginTop: '5px', fontSize: '10.5px' } + }, [ + el('span', { text: 'in ' + formatNumber(row.metrics.writes.total(now)) }), + el('span', { text: 'out ' + formatNumber(row.metrics.pushes.total(now)) }), + el('span', { text: '? ' + formatNumber(row.metrics.reads.total(now)) }), + el('span', { text: '◆ ' + liveQueryCount }) + ]) + ])); + }); + return container; + } + + private renderSummary(now: number): HTMLElement { + const store = this.context.store; + const sum = (pick: 'writes' | 'reads' | 'pulls' | 'pushes') => store.collectionNames + .reduce((total, name) => total + store.getMetrics(name)[pick].total(now), 0); + return el('div', { class: 'rxdt-map-summary' }, [ + el('span', { + class: 'rxdt-section-label', + style: { fontFamily: 'inherit' }, + text: 'LAST 60s' + }), + el('span', {}, [ + el('span', { style: { color: DEVTOOL_COLORS.pink }, text: 'writes' }), + document.createTextNode(' ' + formatNumber(sum('writes'))) + ]), + el('span', {}, [ + el('span', { style: { color: DEVTOOL_COLORS.info }, text: 'reads' }), + document.createTextNode(' ' + formatNumber(sum('reads'))) + ]), + el('span', {}, [ + el('span', { style: { color: DEVTOOL_COLORS.replication }, text: 'pulled' }), + document.createTextNode(' ' + formatNumber(sum('pulls'))) + ]), + el('span', {}, [ + el('span', { style: { color: DEVTOOL_COLORS.replication }, text: 'pushed' }), + document.createTextNode(' ' + formatNumber(sum('pushes'))) + ]), + spacer(), + button('Reset counters', () => { + store.metrics.clear(); + store.viewerWriteCount = 0; + this.context.render(); + }, { small: true }) + ]); + } + + private openInstances(): void { + const store = this.context.store; + const isLeader = readLeadership(store.database); + const columns = '70px 1fr 130px'; + const panel = el('div', { class: 'rxdt-subpanel-inner', style: { width: '620px' } }, [ + el('div', { class: 'rxdt-toolbar' }, [ + el('span', { style: { fontWeight: '700', fontSize: '12px' }, text: 'Instances' }), + el('span', { + class: 'rxdt-dim', + style: { fontSize: '10px' }, + text: store.database.multiInstance + ? 'multi-instance is on, other tabs share this database' + : 'multi-instance is off, this is the only instance' + }), + spacer(), + el('span', { class: 'rxdt-close', text: '×', onClick: () => this.context.setOverlay(null) }) + ]), + gridHead(columns, ['kind', 'label', 'state']), + gridRow(columns, [ + el('span', { class: 'rxdt-mono rxdt-muted', text: instanceKind() }), + el('span', { class: 'rxdt-mono', text: 'this tab · ' + currentPath() }), + el('span', { + class: 'rxdt-mono', + style: { color: isLeader === true ? DEVTOOL_COLORS.success : DEVTOOL_COLORS.fgDim }, + text: isLeader === null ? 'unknown' : (isLeader ? 'leader' : 'follower') + }) + ], { class: 'rxdt-tr rxdt-static' }), + el('div', { + class: 'rxdt-dim', + style: { padding: '8px 12px', fontSize: '10.5px', lineHeight: '1.5' }, + text: isLeader === null + ? 'Leadership is unknown because the leader-election plugin is not added. RxDB also does not publish a roster of the other open instances.' + : 'RxDB does not publish a roster of the other open instances. Only the leadership of this instance is known here.' + }) + ]); + this.context.setOverlay(el('div', { class: 'rxdt-subpanel' }, [panel])); + } + + private openLiveQueries(collectionName: string): void { + const store = this.context.store; + const now = Date.now(); + const infos = store.getLiveQueries(collectionName); + const subscribed = infos.filter(info => info.subscribers > 0); + const cached = infos.filter(info => info.subscribers === 0); + const metrics = store.getMetrics(collectionName); + const writes = metrics.writes.total(now); + const columns = '46px 1fr 70px 70px 100px'; + + const panel = el('div', { class: 'rxdt-subpanel-inner' }, [ + el('div', { class: 'rxdt-toolbar' }, [ + el('span', { style: { fontWeight: '700', fontSize: '12px' }, text: 'Live queries' }), + el('span', { + class: 'rxdt-dim rxdt-mono', + style: { fontSize: '10px' }, + text: collectionName + ' · ' + subscribed.length + ' subscribed' + }), + spacer(), + el('span', { class: 'rxdt-close', text: '×', onClick: () => this.context.setOverlay(null) }) + ]), + gridHead(columns, ['subs', 'query', 'results', 're-emits', 'last emit']) + ]); + + let staleCount = 0; + subscribed.forEach(info => { + const stale = writes > 0 && info.emitCount === 0; + if (stale) { + staleCount++; + } + panel.appendChild(gridRow(columns, [ + el('span', { class: 'rxdt-mono', text: String(info.subscribers) }), + el('span', { class: 'rxdt-mono', title: info.stringRepresentation, text: info.stringRepresentation }), + el('span', { class: 'rxdt-mono rxdt-muted', text: formatNumber(info.resultCount) }), + el('span', { class: 'rxdt-mono rxdt-muted', text: formatNumber(info.emitCount) }), + el('span', { + class: 'rxdt-mono', + style: { color: stale ? DEVTOOL_COLORS.warning : DEVTOOL_COLORS.fgMuted }, + text: info.lastEmitAt === 0 ? 'never' : formatAge(info.lastEmitAt, now) + }) + ], { class: 'rxdt-tr rxdt-static' })); + }); + if (subscribed.length === 0) { + panel.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '8px 12px', fontSize: '11px' }, + text: 'Nothing is subscribed to ' + collectionName + ' right now.' + })); + } + if (staleCount > 0) { + panel.appendChild(el('div', { + class: 'rxdt-mono', + style: { + padding: '6px 12px', + fontSize: '10.5px', + color: DEVTOOL_COLORS.warning, + borderBottom: '1px solid rgba(255,255,255,0.05)' + }, + text: '▲ ' + staleCount + ' quer' + (staleCount === 1 ? 'y has' : 'ies have') + + ' not re-emitted while ' + formatNumber(writes) + ' writes landed on ' + collectionName + })); + } + panel.appendChild(el('div', { + class: 'rxdt-mono rxdt-dim', + style: { padding: '6px 12px', fontSize: '10.5px' }, + text: '› ' + cached.length + ' cached queries with no subscribers' + })); + this.context.setOverlay(el('div', { class: 'rxdt-subpanel' }, [panel])); + } +} + +/** + * `isLeader()` throws when the leader-election plugin is not added, + * in which case leadership is simply unknown. + */ +function readLeadership(database: RxDatabase): boolean | null { + try { + return database.isLeader(); + } catch (error) { + return null; + } +} + +function instanceKind(): string { + if (typeof window === 'undefined') { + return 'node'; + } + if (typeof (globalThis as any).WorkerGlobalScope !== 'undefined' && + (globalThis as any).self instanceof (globalThis as any).WorkerGlobalScope) { + return 'worker'; + } + return 'window'; +} + +/** + * Only the tail of the path is drawn, so that a long url cannot + * push the app node out of its column. + */ +function currentPath(): string { + if (typeof location === 'undefined') { + return '-'; + } + const path = location.pathname; + if (path.length <= 24) { + return path; + } + return '…' + path.slice(path.length - 23); +} diff --git a/src/plugins/devtool/parts/narrow-panel.ts b/src/plugins/devtool/parts/narrow-panel.ts new file mode 100644 index 00000000000..11037fcf35b --- /dev/null +++ b/src/plugins/devtool/parts/narrow-panel.ts @@ -0,0 +1,284 @@ +import type { RxDocumentData } from '../../../types/index.d.ts'; +import { clear, el, spacer } from '../dom.ts'; +import { formatAge, formatNumber, previewValue, shortRevision, valueType } from '../format.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import { replicationGlyph } from './rail.ts'; +import type { PanelContext } from './context.ts'; + +type NarrowScreen = + | { kind: 'collections'; } + | { kind: 'documents'; collectionName: string; } + | { kind: 'document'; collectionName: string; documentId: string; }; + +/** + * Below 640px the rail and the tool panels do not fit, so the devtool + * becomes three stacked read-only screens with back navigation. + * Every touch row is at least 44px tall. + */ +export class NarrowPanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt rxdt-narrow' }); + + private screen: NarrowScreen = { kind: 'collections' }; + private documents: RxDocumentData[] = []; + private matchCount = 0; + private page = 0; + + constructor(private readonly context: PanelContext) { } + + public destroy(): void { } + + public render(): HTMLElement { + clear(this.element); + if (this.screen.kind === 'collections') { + this.renderCollections(); + } else if (this.screen.kind === 'documents') { + this.renderDocuments(this.screen.collectionName); + } else { + this.renderDocument(this.screen.collectionName, this.screen.documentId); + } + return this.element; + } + + private renderCollections(): void { + const store = this.context.store; + this.element.appendChild(el('div', { class: 'rxdt-narrow-header' }, [ + el('div', { class: 'rxdt-logo' }), + el('span', { class: 'rxdt-wordmark', text: 'RxDB' }), + el('span', { + class: 'rxdt-mono rxdt-muted', + style: { fontSize: '11px' }, + text: store.database.name + ' / ' + store.database.storage.name + }), + spacer(), + el('span', { + class: 'rxdt-muted', + style: { fontSize: '12px', cursor: 'pointer' }, + text: 'Refresh', + onClick: () => this.context.render() + }) + ])); + + this.element.appendChild(el('div', { class: 'rxdt-narrow-head', text: 'COLLECTIONS' })); + const scroll = el('div', { class: 'rxdt-scroll' }); + store.collectionNames.forEach(collectionName => { + scroll.appendChild(el('div', { + class: 'rxdt-narrow-row', + onClick: () => { + this.screen = { kind: 'documents', collectionName }; + this.page = 0; + this.loadDocuments(collectionName); + } + }, [ + el('span', { class: 'rxdt-mono rxdt-grow', text: collectionName }), + el('span', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '12px' }, + text: formatNumber(store.getMetrics(collectionName).documentCount) + }), + el('span', { class: 'rxdt-dim', text: '›' }) + ])); + }); + + const replicated = store.collectionNames + .filter(name => store.getReplicationStates(name).length > 0); + if (replicated.length > 0) { + scroll.appendChild(el('div', { class: 'rxdt-narrow-head', text: 'REPLICATION' })); + replicated.forEach(collectionName => { + const glyph = replicationGlyph(store, collectionName); + scroll.appendChild(el('div', { + class: 'rxdt-narrow-row', + style: { cursor: 'default', minHeight: '0', padding: '10px 14px' } + }, [ + el('span', { class: 'rxdt-mono rxdt-grow', text: collectionName }), + el('span', { + style: { color: glyph.color, fontSize: '11px' }, + text: glyph.glyph + ' ' + glyph.state + }) + ])); + }); + } + this.element.appendChild(scroll); + this.element.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '12px 14px', borderTop: '1px solid rgba(255,255,255,0.08)', fontSize: '11px' }, + text: 'Tools (Schema, Changes, Query lab, Storage) are desktop-only. Reading data works here.' + })); + } + + private renderDocuments(collectionName: string): void { + const store = this.context.store; + const view = store.getView(collectionName); + const collection = store.database.collections[collectionName]; + const primaryPath = collection.schema.primaryPath as string; + const titleField = Object.keys(collection.schema.jsonSchema.properties ?? {}) + .find(name => name !== primaryPath && !name.startsWith('_')) ?? primaryPath; + + this.element.appendChild(el('div', { class: 'rxdt-narrow-header' }, [ + el('span', { + class: 'rxdt-back', + text: '‹', + onClick: () => { + this.screen = { kind: 'collections' }; + this.context.render(); + } + }), + el('span', { class: 'rxdt-mono', style: { fontWeight: '700' }, text: collectionName }), + el('span', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '11px' }, + text: formatNumber(this.matchCount) + }) + ])); + this.element.appendChild(el('div', { + style: { padding: '10px 14px', borderBottom: '1px solid rgba(255,255,255,0.08)' } + }, [ + el('div', { + class: 'rxdt-mono', + style: { + display: 'flex', + gap: '8px', + background: DEVTOOL_COLORS.bg, + border: '1px solid rgba(255,255,255,0.14)', + padding: '8px 10px', + fontSize: '12px' + } + }, [ + el('span', { class: 'rxdt-dim', text: 'find' }), + el('span', { text: view.queryInput }) + ]) + ])); + + const scroll = el('div', { class: 'rxdt-scroll' }); + this.documents.forEach(documentData => { + const documentId = String((documentData as any)[primaryPath]); + const doneValue = (documentData as any)[titleField]; + scroll.appendChild(el('div', { + class: 'rxdt-narrow-row', + style: { padding: '10px 14px' }, + onClick: () => { + this.screen = { kind: 'document', collectionName, documentId }; + this.context.render(); + } + }, [ + el('div', { class: 'rxdt-grow', style: { minWidth: '0' } }, [ + el('div', { + style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + text: previewValue(doneValue) || documentId + }), + el('div', { + class: 'rxdt-mono rxdt-dim', + style: { fontSize: '10px' }, + text: documentId + ' · ' + shortRevision((documentData as any)._rev) + ' · ' + + formatAge((documentData as any)._meta?.lwt ?? Date.now()) + }) + ]), + el('span', { class: 'rxdt-dim', text: '›' }) + ])); + }); + this.element.appendChild(scroll); + + const store_ = store; + const lastPage = Math.max(0, Math.ceil(this.matchCount / store_.pageSize) - 1); + this.element.appendChild(el('div', { + style: { + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '10px 14px', + borderTop: '1px solid rgba(255,255,255,0.08)', + fontSize: '12px', + color: DEVTOOL_COLORS.fgMuted + } + }, [ + el('span', { + text: formatNumber(this.matchCount === 0 ? 0 : this.page * store_.pageSize + 1) + '–' + + formatNumber(Math.min(this.matchCount, (this.page + 1) * store_.pageSize)) + + ' of ' + formatNumber(this.matchCount) + }), + spacer(), + el('button', { + class: 'rxdt-pager', + style: { padding: '6px 14px', fontSize: '12px' }, + text: '‹', + disabled: this.page === 0, + onClick: () => { + this.page--; + this.loadDocuments(collectionName); + } + }), + el('button', { + class: 'rxdt-pager', + style: { padding: '6px 14px', fontSize: '12px' }, + text: '›', + disabled: this.page >= lastPage, + onClick: () => { + this.page++; + this.loadDocuments(collectionName); + } + }) + ])); + } + + private renderDocument(collectionName: string, documentId: string): void { + const collection = this.context.store.database.collections[collectionName]; + const primaryPath = collection.schema.primaryPath as string; + const documentData = this.documents.find( + candidate => String((candidate as any)[primaryPath]) === documentId + ); + + this.element.appendChild(el('div', { class: 'rxdt-narrow-header' }, [ + el('span', { + class: 'rxdt-back', + text: '‹', + onClick: () => { + this.screen = { kind: 'documents', collectionName }; + this.context.render(); + } + }), + el('span', { class: 'rxdt-mono', style: { fontWeight: '700' }, text: documentId }), + el('span', { class: 'rxdt-dim', style: { fontSize: '11px' }, text: 'in ' + collectionName }) + ])); + + if (!documentData) { + this.element.appendChild(el('div', { class: 'rxdt-center', text: 'Document not on this page.' })); + return; + } + + const scroll = el('div', { class: 'rxdt-scroll' }); + const field = (label: string, value: any) => el('div', { class: 'rxdt-narrow-field' }, [ + el('div', { text: label }), + el('div', { + class: valueType(value) === 'string' ? '' : 'rxdt-mono', + text: typeof value === 'string' ? value : JSON.stringify(value) + }) + ]); + scroll.appendChild(el('div', { class: 'rxdt-narrow-head', text: 'FIELDS' })); + Object.keys(documentData) + .filter(name => !name.startsWith('_')) + .forEach(name => scroll.appendChild(field(name, (documentData as any)[name]))); + scroll.appendChild(el('div', { class: 'rxdt-narrow-head', text: 'INTERNALS' })); + scroll.appendChild(field('_rev', shortRevision((documentData as any)._rev))); + scroll.appendChild(field('_meta.lwt', (documentData as any)._meta?.lwt)); + this.element.appendChild(scroll); + this.element.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '12px 14px', borderTop: '1px solid rgba(255,255,255,0.08)', fontSize: '11px' }, + text: 'Read-only at this width. Editing needs the desktop drawer.' + })); + } + + private async loadDocuments(collectionName: string): Promise { + const store = this.context.store; + const collection = store.database.collections[collectionName]; + const view = store.getView(collectionName); + this.matchCount = await collection.count({ selector: view.selector }).exec(); + const documents = await collection.find({ + selector: view.selector, + sort: [{ [view.sort.field]: view.sort.direction } as any], + skip: this.page * store.pageSize, + limit: store.pageSize + }).exec(); + this.documents = documents.map(document => document.toJSON(true) as RxDocumentData); + this.context.render(); + } +} diff --git a/src/plugins/devtool/parts/query-lab-panel.ts b/src/plugins/devtool/parts/query-lab-panel.ts new file mode 100644 index 00000000000..a93812e911e --- /dev/null +++ b/src/plugins/devtool/parts/query-lab-panel.ts @@ -0,0 +1,356 @@ +import { clear, el, primaryButton, spacer } from '../dom.ts'; +import { formatNumber, parseSelector } from '../format.ts'; +import { INDEX_MAX, INDEX_MIN, getQueryPlan } from '../../../query-planner.ts'; +import { normalizeMangoQuery } from '../../../rx-query-helper.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import type { PanelContext } from './context.ts'; +import type { MaybeReadonly } from '../../../types/index.d.ts'; + +type ExplainResult = { + index: string[]; + bounds: string; + selectorSatisfiedByIndex: boolean; + sortSatisfiedByIndex: boolean; + examined: number; + returned: number; + elapsedMs: number; + usesRegex: boolean; + uncoveredFields: string[]; + descendingSort: boolean; + suggestedIndex: string[]; + suggestedIndexExists: boolean; +}; + +/** + * Runs the current selector and explains what the storage had to do: + * which index was used, how many documents it examined and what it discarded. + */ +export class QueryLabPanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt-main rxdt-scroll' }); + + private result: ExplainResult | null = null; + private error: string | null = null; + private running = false; + + constructor(private readonly context: PanelContext) { } + + public destroy(): void { } + + private get collectionName(): string { + const store = this.context.store; + if (store.navigation.kind === 'collection' || store.navigation.kind === 'replication') { + return store.navigation.name; + } + return store.lastCollectionName ?? store.collectionNames[0] ?? ''; + } + + public render(): HTMLElement { + clear(this.element); + const collectionName = this.collectionName; + if (!collectionName) { + this.element.appendChild(el('div', { class: 'rxdt-center', text: 'No collections to query.' })); + return this.element; + } + const view = this.context.store.getView(collectionName); + const input = el('input', { + class: 'rxdt-query-input', + value: view.queryInput, + spellcheck: 'false', + onInput: (event: Event) => { + view.queryInput = (event.target as HTMLInputElement).value; + }, + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter') { + this.explain(); + } + } + }); + this.element.appendChild(el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title', text: 'Query lab' }), + el('span', { class: 'rxdt-mono rxdt-muted', style: { fontSize: '11px' }, text: collectionName }), + el('div', { class: 'rxdt-query-input-wrap' }, [ + el('span', { class: 'rxdt-dim', text: 'find' }), + input + ]), + el('button', { + class: 'rxdt-btn', + style: { borderColor: DEVTOOL_COLORS.pink, background: 'rgba(237,22,143,0.12)' }, + text: 'Explain', + onClick: () => this.explain() + }), + primaryButton('Run', () => { + this.context.navigate({ kind: 'collection', name: collectionName }); + }) + ])); + + if (this.error) { + this.element.appendChild(el('div', { + class: 'rxdt-callout rxdt-callout-error' + }, [ + el('div', { class: 'rxdt-callout-title', style: { color: DEVTOOL_COLORS.danger }, text: '✕ The query could not run' }), + el('div', { class: 'rxdt-callout-body', text: this.error }) + ])); + return this.element; + } + if (!this.result) { + this.element.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '14px 12px' }, + text: this.running ? 'running…' : 'Press Explain to analyse the selector above.' + })); + return this.element; + } + this.element.appendChild(this.renderCards(this.result)); + this.element.appendChild(this.renderPlan(this.result)); + this.element.appendChild(this.renderFindings(this.result)); + return this.element; + } + + private renderCards(result: ExplainResult): HTMLElement { + const card = (label: string, value: string, color?: string) => el('div', { class: 'rxdt-card' }, [ + el('div', { class: 'rxdt-section-label', text: label }), + el('div', { class: 'rxdt-card-value', style: color ? { color } : {}, text: value }) + ]); + return el('div', { class: 'rxdt-cards' }, [ + card('INDEX USED', JSON.stringify(result.index)), + card('EXAMINED', formatNumber(result.examined), DEVTOOL_COLORS.warning), + card('RETURNED', formatNumber(result.returned), DEVTOOL_COLORS.success), + card('ELAPSED', (Math.round(result.elapsedMs * 10) / 10) + ' ms') + ]); + } + + private renderPlan(result: ExplainResult): HTMLElement { + const discarded = Math.max(0, result.examined - result.returned); + const steps: [string, string, string][] = [ + [ + '1', + 'index scan on ' + JSON.stringify(result.index) + ' — bounds: ' + result.bounds, + formatNumber(result.examined) + ' candidates' + ] + ]; + if (result.selectorSatisfiedByIndex) { + steps.push(['2', 'in-memory filter — skipped, the index covers the whole selector', '0 discarded']); + } else { + steps.push(['2', 'in-memory filter — the remaining selector fields', formatNumber(discarded) + ' discarded']); + } + steps.push([ + '3', + result.sortSatisfiedByIndex ? 'sort — skipped, index order reused' : 'sort — re-sorted in memory', + result.sortSatisfiedByIndex ? '0 ms' : formatNumber(result.returned) + ' rows' + ]); + + const container = el('div'); + container.appendChild(el('div', { + class: 'rxdt-section-label', + style: { padding: '0 12px' }, + text: 'EXECUTION PLAN' + })); + const list = el('div', { + class: 'rxdt-mono', + style: { margin: '6px 12px', border: '1px solid rgba(255,255,255,0.10)', fontSize: '11px' } + }); + steps.forEach(([number, description, count], index) => { + list.appendChild(el('div', { + style: { + display: 'flex', + gap: '12px', + padding: '6px 10px', + borderBottom: index === steps.length - 1 ? '' : '1px solid rgba(255,255,255,0.06)' + } + }, [ + el('span', { class: 'rxdt-dim', style: { width: '14px' }, text: number }), + el('span', { class: 'rxdt-grow', text: description }), + el('span', { class: 'rxdt-muted', text: count }) + ])); + }); + container.appendChild(list); + return container; + } + + private renderFindings(result: ExplainResult): HTMLElement { + const container = el('div'); + container.appendChild(el('div', { + class: 'rxdt-section-label', + style: { padding: '12px 12px 0' }, + text: 'FINDINGS' + })); + const discarded = Math.max(0, result.examined - result.returned); + const discardShare = result.examined === 0 ? 0 : Math.round((discarded / result.examined) * 100); + let found = false; + + if (result.usesRegex) { + found = true; + container.appendChild(el('div', { class: 'rxdt-callout rxdt-callout-error' }, [ + el('div', { + class: 'rxdt-callout-title', + style: { color: DEVTOOL_COLORS.danger }, + text: '✕ This query cannot use an index' + }), + el('div', { class: 'rxdt-callout-body' }, [ + document.createTextNode('$regex selectors always scan the whole collection (' + + formatNumber(result.examined) + ' documents examined). Prefer a prefix match on an indexed field.') + ]) + ])); + } + if (result.uncoveredFields.length > 0 && discardShare >= 50) { + found = true; + container.appendChild(el('div', { class: 'rxdt-callout rxdt-callout-warning' }, [ + el('div', { + class: 'rxdt-callout-title', + style: { color: DEVTOOL_COLORS.warning }, + text: '▲ ' + result.uncoveredFields.join(', ') + ' ' + + (result.uncoveredFields.length === 1 ? 'is' : 'are') + ' not covered by the used index' + }), + el('div', { class: 'rxdt-callout-body' }, [ + document.createTextNode(discardShare + '% of examined documents were discarded after the index scan. '), + result.suggestedIndexExists + ? el('span', {}, [ + document.createTextNode('The schema already declares '), + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: JSON.stringify(result.suggestedIndex) }), + document.createTextNode(result.descendingSort + ? ', but the descending sort forced the planner to scan the sort index instead. Sort ascending on that index to use it.' + : ', but the planner picked the sort index instead. Sorting on a field of that index lets the planner use it.') + ]) + : el('span', {}, [ + document.createTextNode('Add the index '), + el('span', { class: 'rxdt-mono', style: { color: DEVTOOL_COLORS.fg }, text: JSON.stringify(result.suggestedIndex) }), + document.createTextNode(' to the schema to make this query fully indexed.') + ]) + ]) + ])); + } + if (!result.sortSatisfiedByIndex) { + found = true; + container.appendChild(el('div', { class: 'rxdt-callout rxdt-callout-warning' }, [ + el('div', { + class: 'rxdt-callout-title', + style: { color: DEVTOOL_COLORS.warning }, + text: '▲ The results are re-sorted in memory' + }), + el('div', { + class: 'rxdt-callout-body', + text: result.descendingSort + ? 'The sort is descending, and most storages only store ascending indexes, so every matching document is loaded and re-sorted before the page is cut. Sorting ascending on an indexed field avoids that.' + : 'All matching documents are loaded and re-sorted in memory before the page is cut. An index that starts with the sort field avoids that.' + }) + ])); + } + if (!found) { + container.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '6px 12px 16px' }, + text: 'Nothing to report, the index covers this query.' + })); + } else { + container.appendChild(el('div', { style: { height: '16px' } })); + } + return container; + } + + private async explain(): Promise { + const collectionName = this.collectionName; + const collection = this.context.store.database.collections[collectionName]; + const view = this.context.store.getView(collectionName); + const parsed = parseSelector(view.queryInput); + this.error = null; + if (!parsed.ok) { + this.error = parsed.error.message; + this.context.render(); + return; + } + this.running = true; + this.context.render(); + try { + const normalized = normalizeMangoQuery( + collection.schema.jsonSchema, + { selector: parsed.value, sort: [{ [view.sort.field]: view.sort.direction } as any] } + ); + const plan = getQueryPlan(collection.schema.jsonSchema, normalized); + const selectorFields = Object.keys(parsed.value).filter(field => !field.startsWith('$')); + const uncoveredFields = selectorFields.filter(field => !plan.index.includes(field)); + const indexedSelector: any = {}; + selectorFields + .filter(field => plan.index.includes(field)) + .forEach(field => { + indexedSelector[field] = parsed.value[field]; + }); + + const descendingSort = normalized.sort.some( + (sortPart: any) => Object.values(sortPart)[0] === 'desc' + ); + const coveredSelectorFields = selectorFields.filter(field => plan.index.includes(field)); + const suggestedIndex = coveredSelectorFields.concat(uncoveredFields); + const suggestedIndexExists = (collection.schema.indexes ?? []) + .map(index => declaredIndexFields(index)) + .some(fields => suggestedIndex.every((field, position) => fields[position] === field)); + const startedAt = now(); + const documents = await collection.find({ selector: parsed.value }).exec(); + const elapsedMs = now() - startedAt; + const examined = plan.selectorSatisfiedByIndex + ? documents.length + : await collection.count({ selector: indexedSelector }).exec(); + + this.result = { + index: plan.index, + bounds: describeBounds(plan.index, plan.startKeys, plan.endKeys), + selectorSatisfiedByIndex: plan.selectorSatisfiedByIndex, + sortSatisfiedByIndex: plan.sortSatisfiedByIndex, + examined: Math.max(examined, documents.length), + returned: documents.length, + elapsedMs, + usesRegex: JSON.stringify(parsed.value).includes('"$regex"'), + uncoveredFields, + descendingSort, + suggestedIndex, + suggestedIndexExists + }; + } catch (error) { + this.error = (error as Error).message; + this.result = null; + } + this.running = false; + this.context.render(); + } +} + +/** + * The planner fills unbounded index fields with the min and max sentinels, + * which are meaningless to read, so those are reported as a full range. + */ +function describeBounds(index: string[], startKeys: readonly any[], endKeys: readonly any[]): string { + const described = index.map((field, position) => { + const start = startKeys[position]; + const end = endKeys[position]; + if (isMinBound(start) && isMaxBound(end)) { + return null; + } + if (start === end) { + return field + ' = ' + JSON.stringify(start); + } + return field + ' from ' + (isMinBound(start) ? 'start' : JSON.stringify(start)) + + ' to ' + (isMaxBound(end) ? 'end' : JSON.stringify(end)); + }).filter(part => part !== null); + return described.length === 0 ? 'none, the whole index is scanned' : described.join(', '); +} + +/** + * RxDB prefixes every declared index with `_deleted` and appends the + * primary key, so those are stripped before comparing with what a + * developer would actually write into the schema. + */ +function declaredIndexFields(index: MaybeReadonly | string): string[] { + const fields = (Array.isArray(index) ? index.slice(0) : [index]) as string[]; + return fields[0] === '_deleted' ? fields.slice(1) : fields; +} + +function isMinBound(key: any): boolean { + return key === INDEX_MIN || key === '' || key === undefined; +} + +function isMaxBound(key: any): boolean { + return key === INDEX_MAX || key === Number.MAX_SAFE_INTEGER || key === undefined; +} + +function now(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} diff --git a/src/plugins/devtool/parts/rail.ts b/src/plugins/devtool/parts/rail.ts new file mode 100644 index 00000000000..66d668754f2 --- /dev/null +++ b/src/plugins/devtool/parts/rail.ts @@ -0,0 +1,121 @@ +import { el, spacer } from '../dom.ts'; +import { formatNumber } from '../format.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import type { DevtoolStore } from '../store.ts'; +import type { DevtoolNavigation, DevtoolTool } from '../../../types/index.d.ts'; + +const TOOLS: { id: DevtoolTool; label: string; }[] = [ + { id: 'live', label: 'Live' }, + { id: 'schema', label: 'Schema' }, + { id: 'changes', label: 'Changes' }, + { id: 'querylab', label: 'Query lab' }, + { id: 'storage', label: 'Storage' } +]; + +export type ReplicationGlyph = { + glyph: string; + color: string; + state: string; +}; + +/** + * ● running, ○ idle, ▲ error, ■ stopped. + * The glyph carries the state so colour is never the only signal. + */ +export function replicationGlyph(store: DevtoolStore, collectionName: string): ReplicationGlyph { + const states = store.getReplicationStates(collectionName); + if (states.length === 0) { + return { glyph: '○', color: DEVTOOL_COLORS.fgDim, state: 'not configured' }; + } + if (store.replicationErrors.has(collectionName)) { + return { glyph: '▲', color: DEVTOOL_COLORS.danger, state: 'error' }; + } + if (states.some(state => state.subjects.canceled.getValue())) { + return { glyph: '■', color: DEVTOOL_COLORS.fgMuted, state: 'stopped' }; + } + if (states.some(state => state.subjects.active.getValue())) { + return { glyph: '●', color: DEVTOOL_COLORS.success, state: 'running' }; + } + return { glyph: '○', color: DEVTOOL_COLORS.fgDim, state: 'idle' }; +} + +function isActive(navigation: DevtoolNavigation, candidate: DevtoolNavigation): boolean { + if (navigation.kind !== candidate.kind) { + return false; + } + if (navigation.kind === 'collection' && candidate.kind === 'collection') { + return navigation.name === candidate.name; + } + if (navigation.kind === 'replication' && candidate.kind === 'replication') { + return navigation.name === candidate.name; + } + if (navigation.kind === 'tool' && candidate.kind === 'tool') { + return navigation.tool === candidate.tool; + } + return true; +} + +export function renderRail( + store: DevtoolStore, + onNavigate: (navigation: DevtoolNavigation) => void +): HTMLElement { + const rail = el('div', { class: 'rxdt-rail' }); + const collectionNames = store.collectionNames; + + const item = ( + navigation: DevtoolNavigation, + children: (Node | string | false)[] + ) => el('div', { + class: 'rxdt-rail-item' + (isActive(store.navigation, navigation) ? ' rxdt-active' : ''), + onClick: () => onNavigate(navigation) + }, children); + + rail.appendChild(el('div', { class: 'rxdt-rail-head', text: 'COLLECTIONS' })); + if (collectionNames.length === 0) { + rail.appendChild(el('div', { + class: 'rxdt-rail-item rxdt-dim', + style: { cursor: 'default' }, + text: 'none yet' + })); + } + collectionNames.forEach(name => { + rail.appendChild(item({ kind: 'collection', name }, [ + el('span', { class: 'rxdt-rail-label', text: name }), + el('span', { + class: 'rxdt-rail-count', + text: formatNumber(store.getMetrics(name).documentCount) + }) + ])); + }); + + const replicated = collectionNames.filter(name => store.getReplicationStates(name).length > 0); + if (replicated.length > 0) { + rail.appendChild(el('div', { class: 'rxdt-rail-head', text: 'REPLICATION' })); + replicated.forEach(name => { + const glyph = replicationGlyph(store, name); + rail.appendChild(item({ kind: 'replication', name }, [ + el('span', { class: 'rxdt-rail-label', text: name }), + el('span', { + style: { color: glyph.color, fontSize: '10px' }, + title: glyph.state, + text: glyph.glyph + }) + ])); + }); + } + + rail.appendChild(el('div', { class: 'rxdt-rail-head', text: 'TOOLS' })); + TOOLS.forEach(tool => { + rail.appendChild(item({ kind: 'tool', tool: tool.id }, [ + el('span', { class: 'rxdt-rail-label', text: tool.label }) + ])); + }); + + rail.appendChild(spacer()); + rail.appendChild(el('div', { + class: 'rxdt-rail-settings', + text: 'Settings', + onClick: () => onNavigate({ kind: 'settings' }) + })); + return rail; +} diff --git a/src/plugins/devtool/parts/replication-panel.ts b/src/plugins/devtool/parts/replication-panel.ts new file mode 100644 index 00000000000..80997808ce0 --- /dev/null +++ b/src/plugins/devtool/parts/replication-panel.ts @@ -0,0 +1,193 @@ +import { button, clear, el, gridHead, gridRow, spacer } from '../dom.ts'; +import { formatBytes, formatClock, shortRevision } from '../format.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import type { PanelContext } from './context.ts'; +import type { RxReplicationState } from '../../replication/index.ts'; + +const TABLE_COLUMNS = '110px 110px 110px 1fr 1fr'; + +type DirectionState = { + label: string; + color: string; +}; + +/** + * One row per replicating collection plus the feed of documents + * that actually crossed the wire. Pending counts are deliberately absent. + */ +export class ReplicationPanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt-main rxdt-scroll' }); + + constructor(private readonly context: PanelContext) { } + + public destroy(): void { } + + public render(): HTMLElement { + clear(this.element); + const store = this.context.store; + const replicated = store.collectionNames + .filter(name => store.getReplicationStates(name).length > 0); + + this.element.appendChild(el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title', text: 'Replication' }), + el('span', { + class: 'rxdt-muted', + style: { fontSize: '11px' }, + text: replicated.length + ' collection' + (replicated.length === 1 ? '' : 's') + ' replicating' + }) + ])); + + if (replicated.length === 0) { + this.element.appendChild(el('div', { class: 'rxdt-center' }, [ + el('div', { class: 'rxdt-center-inner' }, [ + el('div', { class: 'rxdt-center-title', text: 'No replication is running' }), + el('div', { + class: 'rxdt-center-body', + text: 'Start one with a replication plugin and it shows up here with its state, checkpoint and live feed.' + }) + ]) + ])); + return this.element; + } + + this.element.appendChild(gridHead(TABLE_COLUMNS, [ + 'collection', 'pull', 'push', 'checkpoint', 'last error' + ])); + replicated.forEach(collectionName => { + store.getReplicationStates(collectionName).forEach(replicationState => { + this.element.appendChild(this.renderStateRow(collectionName, replicationState)); + }); + }); + this.element.appendChild(this.renderFeed()); + return this.element; + } + + private renderStateRow(collectionName: string, replicationState: RxReplicationState): HTMLElement { + const store = this.context.store; + const error = store.replicationErrors.get(collectionName); + const canceled = replicationState.subjects.canceled.getValue(); + const active = replicationState.subjects.active.getValue(); + + const direction = (configured: boolean): DirectionState => { + if (!configured) { + return { label: '– none', color: DEVTOOL_COLORS.fgDim }; + } + if (error) { + return { label: '▲ error', color: DEVTOOL_COLORS.danger }; + } + if (canceled) { + return { label: '■ stopped', color: DEVTOOL_COLORS.fgMuted }; + } + if (active) { + return { label: '● streaming', color: DEVTOOL_COLORS.success }; + } + return { label: '○ idle', color: DEVTOOL_COLORS.fgDim }; + }; + const pull = direction(Boolean(replicationState.pull)); + const push = direction(Boolean(replicationState.push)); + + return gridRow(TABLE_COLUMNS, [ + el('span', { class: 'rxdt-mono', text: collectionName }), + el('span', { style: { color: pull.color }, text: pull.label }), + el('span', { style: { color: push.color }, text: push.label }), + el('span', { + class: 'rxdt-mono rxdt-muted', + style: { fontSize: '10.5px' }, + title: describeCheckpoint(replicationState), + text: describeCheckpoint(replicationState) + }), + el('span', { + class: 'rxdt-mono', + style: { fontSize: '10.5px', color: error ? DEVTOOL_COLORS.danger : DEVTOOL_COLORS.fgDim }, + title: error ? error.message : '', + text: error + ? '✕ ' + error.message + ' · ' + formatClock(error.time) + ' · ' + error.attempts + ' attempts' + : 'none' + }) + ], { class: 'rxdt-tr rxdt-static' }); + } + + private renderFeed(): HTMLElement { + const store = this.context.store; + const container = el('div'); + const disabled = Boolean(store.dump); + container.appendChild(el('div', { + style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '14px 12px 4px' } + }, [ + el('span', { class: 'rxdt-section-label', text: 'LIVE FEED' }), + el('span', { + class: 'rxdt-dot', + style: { background: store.replicationFeedPaused ? DEVTOOL_COLORS.fgDim : DEVTOOL_COLORS.success } + }), + el('span', { + class: 'rxdt-dim', + style: { fontSize: '10px' }, + text: disabled + ? 'not available on a dump' + : 'documents received and sent, newest first' + }), + spacer(), + button(store.replicationFeedPaused ? 'Resume' : 'Pause', () => { + store.replicationFeedPaused = !store.replicationFeedPaused; + this.context.render(); + }, { small: true, disabled }) + ])); + + if (store.replicationFeed.length === 0) { + container.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '6px 12px', fontSize: '11px' }, + text: 'Nothing has crossed the wire since the devtool opened.' + })); + return container; + } + + store.replicationFeed.slice(0, store.pageSize).forEach(record => { + container.appendChild(el('div', { + class: 'rxdt-mono', + style: { + display: 'flex', + gap: '12px', + margin: '0 12px', + padding: '4px 10px', + borderBottom: '1px solid rgba(255,255,255,0.05)', + fontSize: '11px', + alignItems: 'center' + } + }, [ + el('span', { + style: { + width: '12px', + fontWeight: '700', + color: record.direction === 'pull' ? DEVTOOL_COLORS.info : DEVTOOL_COLORS.pink + }, + text: record.direction === 'pull' ? '↓' : '↑' + }), + el('span', { class: 'rxdt-dim', style: { width: '90px' }, text: formatClock(record.time) }), + el('span', { style: { width: '70px' }, text: record.collectionName }), + el('span', { class: 'rxdt-muted', style: { width: '70px' }, text: record.documentId }), + el('span', { + class: 'rxdt-dim rxdt-grow', + text: shortRevision(record.revision) + ' · ' + formatBytes(record.bytes) + }) + ])); + }); + return container; + } +} + +function describeCheckpoint(replicationState: RxReplicationState): string { + const internal = replicationState.internalReplicationState; + if (!internal) { + return 'not started'; + } + const checkpoint = internal.lastCheckpointDoc.down ?? internal.lastCheckpointDoc.up; + if (!checkpoint || checkpoint.checkpointData === undefined) { + return 'none yet'; + } + try { + return JSON.stringify(checkpoint.checkpointData); + } catch (error) { + return 'unreadable'; + } +} diff --git a/src/plugins/devtool/parts/schema-panel.ts b/src/plugins/devtool/parts/schema-panel.ts new file mode 100644 index 00000000000..9bf97ab1248 --- /dev/null +++ b/src/plugins/devtool/parts/schema-panel.ts @@ -0,0 +1,308 @@ +import type { RxCollection, RxDocumentData } from '../../../types/index.d.ts'; +import { clear, el, gridHead, gridRow, spacer } from '../dom.ts'; +import { formatNumber, valueType } from '../format.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import type { PanelContext } from './context.ts'; + +const SAMPLE_SIZE = 1000; + +const TYPE_COLORS: { [type: string]: string; } = { + string: DEVTOOL_COLORS.info, + number: DEVTOOL_COLORS.warning, + integer: DEVTOOL_COLORS.warning, + boolean: DEVTOOL_COLORS.success, + array: DEVTOOL_COLORS.pinkDeep, + object: DEVTOOL_COLORS.purple, + null: DEVTOOL_COLORS.neutralBar, + missing: DEVTOOL_COLORS.neutralBar +}; + +type FieldStats = { + name: string; + declaredType: string | undefined; + typeCounts: Map; + present: number; + distinct: Set; + totalStringLength: number; + stringCount: number; + min: number; + max: number; + booleanTrue: number; +}; + +type SchemaViolation = { + documentId: string; + message: string; +}; + +const COLUMNS = '130px 260px 90px 1fr'; + +/** + * Reports what the documents actually contain, next to what the + * schema declares, and lists the documents that disagree with it. + */ +export class SchemaPanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt-main rxdt-scroll' }); + + private fields: FieldStats[] = []; + private violations: SchemaViolation[] = []; + private sampled = 0; + private loading = true; + private analyzedCollection = ''; + + constructor(private readonly context: PanelContext) { } + + public destroy(): void { } + + private get collectionName(): string { + const store = this.context.store; + if (store.navigation.kind === 'collection' || store.navigation.kind === 'replication') { + return store.navigation.name; + } + return store.lastCollectionName ?? store.collectionNames[0] ?? ''; + } + + public render(): HTMLElement { + clear(this.element); + const collectionName = this.collectionName; + if (!collectionName) { + this.element.appendChild(el('div', { + class: 'rxdt-center', + text: 'No collections to analyse.' + })); + return this.element; + } + if (this.analyzedCollection !== collectionName) { + this.analyzedCollection = collectionName; + this.loading = true; + this.analyse(collectionName); + } + const collection = this.context.store.database.collections[collectionName]; + this.element.appendChild(this.renderHeader(collection)); + this.element.appendChild(gridHead(COLUMNS, ['field', 'types', 'presence', 'values'])); + if (this.loading) { + this.element.appendChild(el('div', { + class: 'rxdt-dim', + style: { padding: '8px 12px' }, + text: 'sampling documents…' + })); + return this.element; + } + this.fields.forEach(field => { + this.element.appendChild(this.renderFieldRow(field)); + }); + this.element.appendChild(this.renderViolations(collectionName)); + return this.element; + } + + private renderHeader(collection: RxCollection): HTMLElement { + const legend = ['string', 'number', 'boolean', 'array', 'object', 'missing']; + return el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title', text: 'Schema' }), + el('span', { + class: 'rxdt-mono rxdt-muted', + style: { fontSize: '11px' }, + text: collection.name + ' · declared v' + collection.schema.version + + ' · sampled ' + formatNumber(this.sampled) + ' documents' + }), + spacer(), + el('span', { class: 'rxdt-dim', style: { fontSize: '10px' } }, legend.flatMap((type, index) => [ + document.createTextNode((index === 0 ? '' : ' · ') + type + ' '), + el('span', { class: 'rxdt-swatch', style: { background: TYPE_COLORS[type] } }) + ])) + ]); + } + + private renderFieldRow(field: FieldStats): HTMLElement { + const bar = el('div', { class: 'rxdt-typebar' }); + const missing = this.sampled - field.present; + const shares: [string, number][] = Array.from(field.typeCounts.entries()); + if (missing > 0) { + shares.push(['missing', missing]); + } + shares.forEach(([type, count]) => { + bar.appendChild(el('div', { + style: { + width: ((count / Math.max(1, this.sampled)) * 100) + '%', + background: TYPE_COLORS[type] ?? DEVTOOL_COLORS.neutralBar + }, + title: type + ': ' + formatNumber(count) + })); + }); + const presence = this.sampled === 0 ? 0 : Math.round((field.present / this.sampled) * 100); + return gridRow(COLUMNS, [ + el('span', { class: 'rxdt-mono', text: field.name }), + bar, + el('span', { + class: 'rxdt-mono', + style: { color: presence === 100 ? DEVTOOL_COLORS.success : DEVTOOL_COLORS.warning }, + text: presence + '%' + }), + el('span', { + class: 'rxdt-mono rxdt-muted', + style: { fontSize: '10.5px' }, + text: describeValues(field) + }) + ], { class: 'rxdt-tr rxdt-static' }); + } + + private renderViolations(collectionName: string): HTMLElement { + const container = el('div'); + container.appendChild(el('div', { + style: { margin: '16px 12px 4px', display: 'flex', alignItems: 'center', gap: '8px' } + }, [ + el('span', { style: { fontWeight: '700', fontSize: '12px' }, text: 'Schema violations' }), + this.violations.length > 0 + ? el('span', { + style: { + fontSize: '10px', + background: 'rgba(253,54,110,0.15)', + color: DEVTOOL_COLORS.danger, + border: '1px solid rgba(253,54,110,0.4)', + padding: '1px 7px' + }, + text: formatNumber(this.violations.length) + ' documents' + }) + : el('span', { class: 'rxdt-dim', style: { fontSize: '10px' }, text: 'none in the sample' }) + ])); + this.violations.forEach(violation => { + container.appendChild(el('div', { + style: { + display: 'flex', + gap: '12px', + margin: '0 12px', + padding: '5px 10px', + borderBottom: '1px solid rgba(255,255,255,0.05)', + fontSize: '11px', + alignItems: 'center' + } + }, [ + el('span', { style: { color: DEVTOOL_COLORS.danger }, text: '▲' }), + el('span', { class: 'rxdt-mono rxdt-muted', style: { width: '70px' }, text: violation.documentId }), + el('span', { class: 'rxdt-mono rxdt-grow', text: violation.message }), + el('a', { + style: { fontSize: '10px' }, + text: 'open', + onClick: () => { + const view = this.context.store.getView(collectionName); + view.openDocumentId = violation.documentId; + view.queryInput = '{}'; + view.selector = {}; + this.context.navigate({ kind: 'collection', name: collectionName }); + } + }) + ])); + }); + return container; + } + + private async analyse(collectionName: string): Promise { + const collection = this.context.store.database.collections[collectionName]; + if (!collection) { + return; + } + const documents = await collection.find({ selector: {}, limit: SAMPLE_SIZE }).exec(); + if (this.analyzedCollection !== collectionName) { + return; + } + const rows = documents.map(document => document.toJSON(true) as RxDocumentData); + const properties: any = collection.schema.jsonSchema.properties ?? {}; + const primaryPath = collection.schema.primaryPath as string; + const statsByName = new Map(); + const violations: SchemaViolation[] = []; + + const ensure = (name: string): FieldStats => { + let stats = statsByName.get(name); + if (!stats) { + stats = { + name, + declaredType: properties[name] ? normalizeDeclaredType(properties[name].type) : undefined, + typeCounts: new Map(), + present: 0, + distinct: new Set(), + totalStringLength: 0, + stringCount: 0, + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + booleanTrue: 0 + }; + statsByName.set(name, stats); + } + return stats; + }; + Object.keys(properties) + .filter(name => !name.startsWith('_')) + .forEach(ensure); + + rows.forEach(row => { + Object.keys(row) + .filter(name => !name.startsWith('_')) + .forEach(name => { + const stats = ensure(name); + const value = (row as any)[name]; + const type = valueType(value); + stats.present++; + stats.typeCounts.set(type, (stats.typeCounts.get(type) ?? 0) + 1); + if (stats.distinct.size < 5000) { + stats.distinct.add(JSON.stringify(value)); + } + if (type === 'string') { + stats.stringCount++; + stats.totalStringLength += (value as string).length; + } else if (type === 'number') { + stats.min = Math.min(stats.min, value as number); + stats.max = Math.max(stats.max, value as number); + } else if (type === 'boolean' && value === true) { + stats.booleanTrue++; + } + if (stats.declaredType && stats.declaredType !== type && type !== 'missing') { + if (!(stats.declaredType === 'number' && type === 'number')) { + violations.push({ + documentId: String((row as any)[primaryPath]), + message: name + ': expected ' + stats.declaredType + ', got ' + + type + ' ' + JSON.stringify(value) + }); + } + } + }); + }); + + this.sampled = rows.length; + this.fields = Array.from(statsByName.values()); + this.violations = violations.slice(0, 50); + this.loading = false; + this.context.render(); + } +} + +function normalizeDeclaredType(declared: string | string[] | undefined): string | undefined { + if (Array.isArray(declared)) { + return declared[0] === 'null' ? declared[1] : declared[0]; + } + if (declared === 'integer') { + return 'number'; + } + return declared; +} + +function describeValues(field: FieldStats): string { + const parts: string[] = []; + if (field.present > 0) { + parts.push(formatNumber(field.distinct.size) + ' distinct'); + if (field.distinct.size === field.present) { + parts.push('unique'); + } + } + if (field.stringCount > 0) { + parts.push('avg length ' + Math.round(field.totalStringLength / field.stringCount)); + } + if (field.min !== Number.POSITIVE_INFINITY) { + parts.push('min ' + field.min + ' · max ' + field.max); + } + const booleanCount = field.typeCounts.get('boolean') ?? 0; + if (booleanCount > 0) { + parts.push('true ' + formatNumber(field.booleanTrue) + + ' · false ' + formatNumber(booleanCount - field.booleanTrue)); + } + return parts.join(' · ') || 'no values in the sample'; +} diff --git a/src/plugins/devtool/parts/storage-panel.ts b/src/plugins/devtool/parts/storage-panel.ts new file mode 100644 index 00000000000..28c9a3c17b8 --- /dev/null +++ b/src/plugins/devtool/parts/storage-panel.ts @@ -0,0 +1,189 @@ +import type { RxCollection } from '../../../types/index.d.ts'; +import { RXDB_VERSION } from '../../utils/utils-rxdb-version.ts'; +import { button, clear, el, gridHead, gridRow } from '../dom.ts'; +import { formatBytes, formatNumber } from '../format.ts'; +import { normalizeMangoQuery, prepareQuery } from '../../../rx-query-helper.ts'; +import type { PanelContext } from './context.ts'; + +const COLUMNS = '1fr 130px 130px 170px'; +const TOMBSTONE_MAX_AGE_DAYS = 14; + +type CollectionStorageRow = { + collectionName: string; + documents: number; + tombstones: number; + attachmentBytes: number; +}; + +/** + * Attachment bytes are the only real size figure a storage can report, + * so no estimated on-disk sizes are shown anywhere on this panel. + */ +export class StoragePanel { + public readonly element: HTMLElement = el('div', { class: 'rxdt-main rxdt-scroll' }); + + private rows: CollectionStorageRow[] = []; + private loading = true; + private cleanupRunning = false; + + constructor(private readonly context: PanelContext) { + this.load(); + } + + public destroy(): void { } + + public render(): HTMLElement { + clear(this.element); + const store = this.context.store; + const totalDocuments = this.rows.reduce((sum, row) => sum + row.documents, 0); + const totalTombstones = this.rows.reduce((sum, row) => sum + row.tombstones, 0); + const totalAttachmentBytes = this.rows.reduce((sum, row) => sum + row.attachmentBytes, 0); + + this.element.appendChild(el('div', { class: 'rxdt-toolbar' }, [ + el('span', { class: 'rxdt-panel-title', text: 'Storage' }), + this.loading && el('span', { class: 'rxdt-dim', style: { fontSize: '10px' }, text: 'counting…' }) + ])); + + const card = (label: string, value: Node | string) => el('div', { class: 'rxdt-card' }, [ + el('div', { class: 'rxdt-section-label', text: label }), + el('div', { class: 'rxdt-card-value' }, [value]) + ]); + this.element.appendChild(el('div', { class: 'rxdt-cards' }, [ + card('ENGINE', store.database.storage.name), + card('DATABASE', store.database.name + ' · rxdb v' + RXDB_VERSION), + card('DOCUMENTS', formatNumber(totalDocuments)), + card('ATTACHMENT BYTES', formatBytes(totalAttachmentBytes)) + ])); + + this.element.appendChild(gridHead(COLUMNS, [ + 'collection', 'documents', 'tombstones', 'attachment bytes' + ])); + this.rows.forEach(row => { + this.element.appendChild(gridRow(COLUMNS, [ + el('span', { class: 'rxdt-mono', text: row.collectionName }), + el('span', { class: 'rxdt-mono', text: formatNumber(row.documents) }), + el('span', { class: 'rxdt-mono rxdt-muted', text: formatNumber(row.tombstones) }), + el('span', { class: 'rxdt-mono rxdt-muted', text: formatBytes(row.attachmentBytes) }) + ], { class: 'rxdt-tr rxdt-static' })); + }); + this.element.appendChild(el('div', { + class: 'rxdt-mono', + style: { + display: 'grid', + gridTemplateColumns: COLUMNS, + padding: '6px 12px', + fontSize: '11px', + fontWeight: '700', + borderBottom: '1px solid rgba(255,255,255,0.14)' + } + }, [ + el('div', { text: 'total' }), + el('div', { text: formatNumber(totalDocuments) }), + el('div', { text: formatNumber(totalTombstones) }), + el('div', { text: formatBytes(totalAttachmentBytes) }) + ])); + + this.element.appendChild(this.renderCleanup(totalTombstones)); + return this.element; + } + + private renderCleanup(totalTombstones: number): HTMLElement { + const store = this.context.store; + const canClean = !store.readOnly && + store.collectionNames.some(name => typeof (store.database.collections[name] as any).cleanup === 'function'); + return el('div', { class: 'rxdt-note' }, [ + el('div', { style: { fontWeight: '700', fontSize: '12px' }, text: 'Cleanup' }), + el('div', { + class: 'rxdt-muted', + style: { fontSize: '11.5px', marginTop: '4px', lineHeight: '1.55' }, + text: 'Purges tombstones older than ' + TOMBSTONE_MAX_AGE_DAYS + + ' days. Peers whose replication checkpoint predates the cleanup must re-sync from scratch.' + }), + !canClean && el('div', { + class: 'rxdt-dim', + style: { fontSize: '11px', marginTop: '6px' }, + text: store.readOnly + ? 'Not available in read-only mode.' + : 'Add the cleanup plugin to run this from here.' + }), + canClean && el('div', { style: { marginTop: '10px' } }, [ + button( + this.cleanupRunning + ? 'Running cleanup…' + : 'Run cleanup — purge ' + formatNumber(totalTombstones) + ' tombstones', + () => this.runCleanup(), + { variant: 'danger', disabled: this.cleanupRunning || totalTombstones === 0 } + ) + ]) + ]); + } + + private async runCleanup(): Promise { + const store = this.context.store; + this.cleanupRunning = true; + this.context.render(); + const minimumDeletedTime = TOMBSTONE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000; + try { + await Promise.all(store.collectionNames.map(name => { + const collection = store.database.collections[name] as any; + return typeof collection.cleanup === 'function' + ? collection.cleanup(minimumDeletedTime) + : Promise.resolve(); + })); + } catch (error) { + this.context.notify((error as Error).message); + } + this.cleanupRunning = false; + await this.load(); + } + + private async load(): Promise { + const store = this.context.store; + try { + this.rows = await Promise.all( + store.collectionNames.map(collectionName => this.readCollection(collectionName)) + ); + } catch (error) { + this.context.notify((error as Error).message); + } + this.loading = false; + this.context.render(); + } + + private async readCollection(collectionName: string): Promise { + const collection = this.context.store.database.collections[collectionName]; + const documents = await collection.count().exec(); + const [tombstones, attachmentBytes] = await Promise.all([ + countTombstones(collection), + sumAttachmentBytes(collection) + ]); + return { collectionName, documents, tombstones, attachmentBytes }; + } +} + +/** + * Queries below the RxCollection because RxQuery always + * filters deleted documents out of its results. + */ +async function countTombstones(collection: RxCollection): Promise { + const query = normalizeMangoQuery(collection.schema.jsonSchema, { + selector: { _deleted: { $eq: true } } as any + }); + const prepared = prepareQuery(collection.schema.jsonSchema, query); + const result = await collection.storageInstance.count(prepared); + return result.count; +} + +async function sumAttachmentBytes(collection: RxCollection): Promise { + if (!collection.schema.jsonSchema.attachments) { + return 0; + } + const documents = await collection.find().exec(); + return documents.reduce((sum, document) => { + const attachments = (document.toJSON(true) as any)._attachments ?? {}; + return sum + Object.keys(attachments).reduce( + (inner, key) => inner + (attachments[key].length ?? 0), + 0 + ); + }, 0); +} diff --git a/src/plugins/devtool/parts/top-bar.ts b/src/plugins/devtool/parts/top-bar.ts new file mode 100644 index 00000000000..b8b8573fde2 --- /dev/null +++ b/src/plugins/devtool/parts/top-bar.ts @@ -0,0 +1,95 @@ +import { RXDB_VERSION } from '../../utils/utils-rxdb-version.ts'; +import { button, el, spacer } from '../dom.ts'; +import { DEVTOOL_COLORS } from '../theme.ts'; +import type { DevtoolStore } from '../store.ts'; + +export type TopBarActions = { + onRefresh: () => void; + onCommandPalette: () => void; + onHelp: () => void; + onToggleFullscreen?: () => void; + onDock?: () => void; +}; + +/** + * The global chrome. Only database identity, refresh, the command palette + * and help live here, everything scoped to a collection is in the content toolbar. + */ +export function renderTopBar(store: DevtoolStore, actions: TopBarActions): HTMLElement { + const showWordmark = store.surface !== 'tanstack'; + const identityParts = [ + store.database.name, + store.database.storage.name, + 'v' + RXDB_VERSION + ]; + + return el('div', { class: 'rxdt-topbar' }, [ + showWordmark && el('div', { class: 'rxdt-row', style: { gap: '8px' } }, [ + el('div', { class: 'rxdt-logo' }), + el('span', { class: 'rxdt-wordmark', text: 'RxDB' }) + ]), + showWordmark && el('span', { class: 'rxdt-topbar-divider', text: '|' }), + el('span', { class: 'rxdt-identity', text: identityParts.join(' / ') }), + spacer(), + store.surface === 'embedded' && el('span', { + class: 'rxdt-drag-handle', + title: 'Drag the panel', + text: '⠿' + }), + store.surface === 'embedded' && button('Dock', () => actions.onDock?.(), { small: true, title: 'Change the dock edge' }), + store.surface === 'embedded' && button('⤢', () => actions.onToggleFullscreen?.(), { small: true, title: 'Fullscreen' }), + el('div', { + class: 'rxdt-cmdk', + title: 'Command palette', + onClick: () => actions.onCommandPalette() + }, [ + document.createTextNode('⌘K'), + el('span', { text: 'commands' }) + ]), + button('Refresh', () => actions.onRefresh()), + button('?', () => actions.onHelp(), { title: 'About the RxDB devtool' }) + ]); +} + +export function renderConnectionBanner( + store: DevtoolStore, + onDisconnect: () => void +): HTMLElement | null { + if (store.dump) { + const exported = new Date(store.dump.exportedAt); + return el('div', { class: 'rxdt-banner rxdt-banner-dump' }, [ + el('span', { class: 'rxdt-dot', style: { background: DEVTOOL_COLORS.warning } }), + el('span', {}, [ + document.createTextNode('Reading dump '), + el('span', { class: 'rxdt-mono', style: { fontWeight: '700' }, text: store.dump.fileName }), + document.createTextNode(' · read-only · data as of ' + + exported.getHours() + ':' + String(exported.getMinutes()).padStart(2, '0')) + ]) + ]); + } + const connection = store.connection; + if (connection.state !== 'connected') { + return null; + } + return el('div', { class: 'rxdt-banner rxdt-banner-connected' }, [ + el('span', { class: 'rxdt-dot', style: { background: DEVTOOL_COLORS.success, width: '8px', height: '8px' } }), + el('span', {}, [ + document.createTextNode('Connected to '), + el('span', { class: 'rxdt-mono', style: { fontWeight: '700' }, text: store.database.name }), + document.createTextNode(' on ' + connection.device + ' · ' + connection.transport + ' · '), + el('span', { + style: { color: DEVTOOL_COLORS.success, fontWeight: '700' }, + text: connection.writeable ? 'read/write' : 'read-only' + }) + ]), + connection.roundTripMs !== undefined && el('span', { + class: 'rxdt-dim', + text: 'round-trip ' + Math.round(connection.roundTripMs) + ' ms' + }), + spacer(), + button('Disconnect', () => { + connection.onDisconnect?.(); + onDisconnect(); + }, { small: true }) + ]); +} diff --git a/src/plugins/devtool/store.ts b/src/plugins/devtool/store.ts new file mode 100644 index 00000000000..08852ad65ba --- /dev/null +++ b/src/plugins/devtool/store.ts @@ -0,0 +1,493 @@ +import { Subject, Subscription } from 'rxjs'; +import type { RxCollection, RxDatabase, RxQuery } from '../../types/index.d.ts'; +import { countRxQuerySubscribers } from '../../query-cache.ts'; +import { REPLICATION_STATE_BY_COLLECTION } from '../replication/index.ts'; +import type { RxReplicationState } from '../replication/index.ts'; +import type { + DevtoolChangeRecord, + DevtoolCollectionView, + DevtoolConnection, + DevtoolDumpInfo, + DevtoolLiveEvent, + DevtoolNavigation, + DevtoolQueryEntry, + DevtoolReplicationRecord, + DevtoolSurface +} from '../../types/index.d.ts'; + +export const METRICS_BUCKET_MS = 2000; +export const METRICS_BUCKET_COUNT = 30; +export const METRICS_WINDOW_MS = METRICS_BUCKET_MS * METRICS_BUCKET_COUNT; + +export const CHANGES_BUFFER_SIZE = 500; +export const REPLICATION_BUFFER_SIZE = 500; +export const QUERY_HISTORY_SIZE = 20; + +/** + * A fixed 60 second window of counters, split into 2 second buckets. + * Old buckets are zeroed when time moves on so that the window + * never grows and never needs an array copy per event. + */ +export class RollingWindow { + public buckets: number[] = new Array(METRICS_BUCKET_COUNT).fill(0); + private headIndex = 0; + private headStart = 0; + + constructor(now: number) { + this.headStart = Math.floor(now / METRICS_BUCKET_MS) * METRICS_BUCKET_MS; + } + + public rotate(now: number): void { + const steps = Math.floor((now - this.headStart) / METRICS_BUCKET_MS); + if (steps <= 0) { + return; + } + const toClear = Math.min(steps, METRICS_BUCKET_COUNT); + for (let offset = 1; offset <= toClear; offset++) { + this.buckets[(this.headIndex + offset) % METRICS_BUCKET_COUNT] = 0; + } + this.headIndex = (this.headIndex + steps) % METRICS_BUCKET_COUNT; + this.headStart = this.headStart + (steps * METRICS_BUCKET_MS); + } + + public add(now: number, amount = 1): void { + this.rotate(now); + this.buckets[this.headIndex] += amount; + } + + public total(now: number): number { + this.rotate(now); + return this.buckets.reduce((sum, value) => sum + value, 0); + } + + /** + * Oldest bucket first, which is the order the sparkline draws in. + */ + public series(now: number): number[] { + this.rotate(now); + const result: number[] = []; + for (let offset = 1; offset <= METRICS_BUCKET_COUNT; offset++) { + result.push(this.buckets[(this.headIndex + offset) % METRICS_BUCKET_COUNT]); + } + return result; + } +} + +export type CollectionMetrics = { + writes: RollingWindow; + reads: RollingWindow; + pulls: RollingWindow; + pushes: RollingWindow; + lastWriteAt: number; + documentCount: number; + migration: { done: number; total: number; fromVersion: number; toVersion: number; } | null; +}; + +export type LiveQueryInfo = { + query: RxQuery; + subscribers: number; + stringRepresentation: string; + resultCount: number; + emitCount: number; + lastEmitAt: number; +}; + +export function createCollectionView(): DevtoolCollectionView { + return { + queryInput: '{}', + selector: {}, + queryError: null, + view: 'table', + page: 0, + sort: { field: '_meta.lwt', direction: 'desc' }, + selection: new Set(), + observe: false, + openDocumentId: null, + stagedEdits: {}, + expandedFields: new Set(), + editingCell: null, + historyOpen: false + }; +} + +/** + * Owns everything the panels read: navigation, per collection view state, + * the recorded feeds and the 60 second activity counters. + */ +export class DevtoolStore { + public navigation: DevtoolNavigation; + /** + * The collection the user looked at last. The tool panels are scoped + * to it, so opening Schema or Query lab keeps analysing the collection + * that was on screen instead of jumping to another one. + */ + public lastCollectionName: string | null = null; + public connection: DevtoolConnection; + public readonly surface: DevtoolSurface; + public readonly dump: DevtoolDumpInfo | null; + public readonly pageSize: number; + + public readonly views = new Map(); + public readonly metrics = new Map(); + public readonly queryHistory: DevtoolQueryEntry[] = []; + + public changes: DevtoolChangeRecord[] = []; + public changesPaused = false; + public changesFilter = ''; + public selectedChangeIndex = 0; + + public replicationFeed: DevtoolReplicationRecord[] = []; + public replicationFeedPaused = false; + public replicationErrors = new Map(); + + public livePaused = false; + public liveSubPanel: null | { kind: 'instances'; } | { kind: 'queries'; collectionName: string; } = null; + public viewerWriteCount = 0; + public sessionWriteCount = 0; + /** + * Ids of documents the devtool itself wrote, so that the Live map + * can separate them from the writes the app makes. + */ + private devtoolWrites = new Set(); + + public readonly liveEvents$ = new Subject(); + public readonly changed$ = new Subject(); + + private subscriptions: Subscription[] = []; + private intervals: ReturnType[] = []; + private lastQueryExecCounts = new Map(); + private queryEmitState = new Map(); + private trackedReplications = new WeakSet>(); + + constructor( + public readonly database: RxDatabase, + options: { + surface: DevtoolSurface; + dump: DevtoolDumpInfo | null; + pageSize: number; + connection: DevtoolConnection; + navigation: DevtoolNavigation; + } + ) { + this.surface = options.surface; + this.dump = options.dump; + this.pageSize = options.pageSize; + this.connection = options.connection; + this.navigation = options.navigation; + if (options.navigation.kind === 'collection') { + this.lastCollectionName = options.navigation.name; + } + } + + public get readOnly(): boolean { + if (this.dump) { + return true; + } + return this.connection.state === 'connected' && !this.connection.writeable; + } + + public get collectionNames(): string[] { + return Object.keys(this.database.collections).sort(); + } + + public getView(collectionName: string): DevtoolCollectionView { + let view = this.views.get(collectionName); + if (!view) { + view = createCollectionView(); + this.views.set(collectionName, view); + } + return view; + } + + public getMetrics(collectionName: string): CollectionMetrics { + let metrics = this.metrics.get(collectionName); + if (!metrics) { + const now = Date.now(); + metrics = { + writes: new RollingWindow(now), + reads: new RollingWindow(now), + pulls: new RollingWindow(now), + pushes: new RollingWindow(now), + lastWriteAt: 0, + documentCount: 0, + migration: null + }; + this.metrics.set(collectionName, metrics); + } + return metrics; + } + + public markDevtoolWrite(collectionName: string, documentId: string): void { + this.devtoolWrites.add(collectionName + '|' + documentId); + } + + public rememberQuery(selector: string): void { + const existingIndex = this.queryHistory.findIndex( + entry => entry.selector === selector && !entry.favourite + ); + if (existingIndex >= 0) { + this.queryHistory[existingIndex].usedAt = Date.now(); + } else { + this.queryHistory.unshift({ selector, favourite: false, usedAt: Date.now() }); + } + const recent = this.queryHistory.filter(entry => !entry.favourite); + if (recent.length > QUERY_HISTORY_SIZE) { + const drop = new Set(recent.slice(QUERY_HISTORY_SIZE)); + this.queryHistory.splice( + 0, + this.queryHistory.length, + ...this.queryHistory.filter(entry => !drop.has(entry)) + ); + } + } + + public toggleFavourite(selector: string, name?: string): void { + const entry = this.queryHistory.find(candidate => candidate.selector === selector); + if (entry) { + entry.favourite = !entry.favourite; + entry.name = entry.favourite ? (name ?? entry.name ?? selector) : undefined; + } else { + this.queryHistory.unshift({ + selector, + favourite: true, + name: name ?? selector, + usedAt: Date.now() + }); + } + this.changed$.next(); + } + + public getReplicationStates(collectionName: string): RxReplicationState[] { + const collection = this.database.collections[collectionName]; + if (!collection) { + return []; + } + return REPLICATION_STATE_BY_COLLECTION.get(collection as RxCollection) ?? []; + } + + public getLiveQueries(collectionName: string): LiveQueryInfo[] { + const collection = this.database.collections[collectionName]; + if (!collection) { + return []; + } + const result: LiveQueryInfo[] = []; + collection._queryCache._map.forEach((query, stringRepresentation) => { + const emitState = this.queryEmitState.get(query.id); + result.push({ + query, + subscribers: countRxQuerySubscribers(query), + stringRepresentation, + resultCount: getQueryResultCount(query), + emitCount: emitState ? emitState.count : 0, + lastEmitAt: emitState ? emitState.lastEmitAt : 0 + }); + }); + return result.sort((a, b) => b.subscribers - a.subscribers); + } + + public start(): void { + this.subscriptions.push( + this.database.$.subscribe(changeEvent => { + if (changeEvent.isLocal || !changeEvent.collectionName) { + return; + } + this.recordChange(changeEvent); + }) + ); + this.attachReplications(); + this.intervals.push(setInterval(() => { + this.attachReplications(); + this.pollQueryActivity(); + this.pollDocumentCounts(); + this.changed$.next(); + }, 1000)); + } + + private recordChange(changeEvent: any): void { + const now = Date.now(); + const collectionName: string = changeEvent.collectionName; + const metrics = this.getMetrics(collectionName); + metrics.writes.add(now); + metrics.lastWriteAt = now; + this.sessionWriteCount++; + + const key = collectionName + '|' + changeEvent.documentId; + const fromDevtool = this.devtoolWrites.delete(key); + if (fromDevtool) { + this.viewerWriteCount++; + } + + this.liveEvents$.next({ + kind: changeEvent.operation.toLowerCase() as 'insert' | 'update' | 'delete', + collectionName, + fromDevtool + }); + + if (!this.changesPaused) { + this.changes.unshift({ + time: now, + operation: changeEvent.operation, + collectionName, + documentId: changeEvent.documentId, + previousRevision: changeEvent.previousDocumentData + ? changeEvent.previousDocumentData._rev + : undefined, + revision: changeEvent.documentData ? changeEvent.documentData._rev : '', + documentData: changeEvent.documentData, + previousDocumentData: changeEvent.previousDocumentData, + source: fromDevtool ? 'devtool' : 'local' + }); + if (this.changes.length > CHANGES_BUFFER_SIZE) { + this.changes.length = CHANGES_BUFFER_SIZE; + } + if (this.selectedChangeIndex > 0) { + this.selectedChangeIndex++; + } + } + } + + /** + * Replications can be started at any time, so the set is re-checked + * on every tick and newly seen states get their feeds attached once. + */ + private attachReplications(): void { + this.collectionNames.forEach(collectionName => { + this.getReplicationStates(collectionName).forEach(replicationState => { + if (this.trackedReplications.has(replicationState)) { + return; + } + this.trackedReplications.add(replicationState); + this.subscriptions.push( + replicationState.received$.subscribe(document => { + this.recordReplication('pull', collectionName, document); + }), + replicationState.sent$.subscribe(document => { + this.recordReplication('push', collectionName, document); + }), + replicationState.error$.subscribe(error => { + const previous = this.replicationErrors.get(collectionName); + this.replicationErrors.set(collectionName, { + message: (error as Error).message ?? String(error), + time: Date.now(), + attempts: previous ? previous.attempts + 1 : 1 + }); + this.changed$.next(); + }) + ); + }); + }); + } + + private recordReplication(direction: 'pull' | 'push', collectionName: string, document: any): void { + const now = Date.now(); + const metrics = this.getMetrics(collectionName); + if (direction === 'pull') { + metrics.pulls.add(now); + } else { + metrics.pushes.add(now); + } + this.liveEvents$.next({ kind: direction, collectionName }); + if (this.replicationFeedPaused) { + return; + } + const primaryPath = this.database.collections[collectionName] + ? this.database.collections[collectionName].schema.primaryPath + : 'id'; + this.replicationFeed.unshift({ + time: now, + direction, + collectionName, + documentId: String(document[primaryPath] ?? ''), + revision: document._rev ?? '', + bytes: estimateBytes(document) + }); + if (this.replicationFeed.length > REPLICATION_BUFFER_SIZE) { + this.replicationFeed.length = REPLICATION_BUFFER_SIZE; + } + } + + /** + * RxDB does not emit read events, so reads and live-query emits are + * derived from the query cache: every execution against the storage + * increases `_execOverDatabaseCount`, and a new result object on a + * cached query means that query re-emitted. + */ + private pollQueryActivity(): void { + const now = Date.now(); + const seenQueryIds = new Set(); + this.collectionNames.forEach(collectionName => { + const collection = this.database.collections[collectionName]; + const metrics = this.getMetrics(collectionName); + collection._queryCache._map.forEach(query => { + seenQueryIds.add(query.id); + const previousExecutions = this.lastQueryExecCounts.get(query.id) ?? 0; + const executions = query._execOverDatabaseCount; + if (executions > previousExecutions) { + metrics.reads.add(now, executions - previousExecutions); + this.liveEvents$.next({ kind: 'query', collectionName }); + } + this.lastQueryExecCounts.set(query.id, executions); + + const emitState = this.queryEmitState.get(query.id); + const result = query._result; + if (!emitState) { + this.queryEmitState.set(query.id, { result, count: 0, lastEmitAt: 0 }); + } else if (result && result !== emitState.result) { + emitState.result = result; + emitState.count++; + emitState.lastEmitAt = now; + if (countRxQuerySubscribers(query) > 0) { + this.liveEvents$.next({ kind: 'emit', collectionName }); + } + } + }); + }); + this.lastQueryExecCounts.forEach((_value, queryId) => { + if (!seenQueryIds.has(queryId)) { + this.lastQueryExecCounts.delete(queryId); + this.queryEmitState.delete(queryId); + } + }); + } + + private pollDocumentCounts(): void { + this.collectionNames.forEach(collectionName => { + const metrics = this.getMetrics(collectionName); + this.database.collections[collectionName].count().exec().then(count => { + if (metrics.documentCount !== count) { + metrics.documentCount = count; + } + }).catch(() => { + // a closed collection simply keeps its last known count + }); + }); + } + + public destroy(): void { + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + this.subscriptions = []; + this.intervals.forEach(interval => clearInterval(interval)); + this.intervals = []; + this.liveEvents$.complete(); + this.changed$.complete(); + } +} + +function getQueryResultCount(query: RxQuery): number { + const result: any = query._result; + if (!result) { + return 0; + } + if (Array.isArray(result.docsData)) { + return result.docsData.length; + } + return 0; +} + +export function estimateBytes(document: any): number { + try { + return JSON.stringify(document).length; + } catch (error) { + return 0; + } +} diff --git a/src/plugins/devtool/theme.ts b/src/plugins/devtool/theme.ts new file mode 100644 index 00000000000..8b9a05b9cd3 --- /dev/null +++ b/src/plugins/devtool/theme.ts @@ -0,0 +1,421 @@ +/** + * Design tokens and the single stylesheet of the devtool. + * Everything is self contained, there are no external assets and no font files. + */ + +export const DEVTOOL_COLORS = { + pink: '#ED168F', + pinkDeep: '#B2218B', + purple: '#752A8A', + purpleDeep: '#27022D', + bgDark: '#0D0F18', + bg: '#20293C', + bgCode: '#282330', + bgDrawer: '#10141F', + activeSegment: '#2C3547', + neutralBar: '#3A4256', + fg: '#FFFFFF', + fgMuted: '#B5B5B5', + fgDim: '#6E7688', + success: '#3ECF8E', + danger: '#FD366E', + warning: '#EBCB4B', + info: '#199BF1', + /** + * Only used on the Live activity map for the push/pull particles. + * It is not part of the RxDB brand palette. + */ + replication: '#9B6BFF' +} as const; + +export const DEVTOOL_GRADIENT = 'linear-gradient(90deg,#ED168F,#B2218B,#752A8A)'; + +export const DEVTOOL_FONT_UI = 'system-ui,\'Segoe UI\',Helvetica,Arial,sans-serif'; +export const DEVTOOL_FONT_MONO = 'ui-monospace,Menlo,Consolas,monospace'; + +/** + * Below this width the map and the tool panels do not fit, + * the devtool switches to the stacked read-only layout. + */ +export const DEVTOOL_NARROW_BREAKPOINT = 640; + +const C = DEVTOOL_COLORS; + +export const DEVTOOL_CSS = ` +.rxdt, .rxdt *, .rxdt *::before, .rxdt *::after { box-sizing: border-box; } +.rxdt { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + background: ${C.bgDark}; + color: ${C.fg}; + font-family: ${DEVTOOL_FONT_UI}; + font-size: 12px; + letter-spacing: 0.01em; + line-height: 1.4; +} +.rxdt button { font-family: inherit; border-radius: 0; cursor: pointer; } +.rxdt input, .rxdt textarea { border-radius: 0; } +.rxdt a { color: ${C.fg}; text-decoration: underline; text-decoration-color: ${C.pink}; text-decoration-thickness: 1.5px; text-underline-offset: 3px; cursor: pointer; } +.rxdt a:hover { color: ${C.pink}; } +.rxdt ::-webkit-scrollbar { width: 8px; height: 8px; } +.rxdt ::-webkit-scrollbar-thumb { background: ${C.activeSegment}; border-radius: 4px; } +.rxdt-mono { font-family: ${DEVTOOL_FONT_MONO}; } +.rxdt-grow { flex: 1; min-width: 0; } +.rxdt-dim { color: ${C.fgDim}; } +.rxdt-muted { color: ${C.fgMuted}; } +.rxdt-row { display: flex; align-items: center; } +.rxdt-hidden { display: none !important; } + +/* ---------- buttons ---------- */ +.rxdt-btn { + border: 1px solid rgba(255,255,255,0.25); + background: transparent; + color: ${C.fg}; + font-size: 11px; + padding: 4px 12px; + transition: background 180ms ease-in-out, color 180ms ease-in-out, border-color 180ms ease-in-out; +} +.rxdt-btn:hover:not(:disabled) { background: ${C.fg}; color: ${C.bgDark}; } +.rxdt-btn:active:not(:disabled) { transform: translateY(1px); transition: transform 80ms ease; } +.rxdt-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.rxdt-btn-sm { font-size: 10px; padding: 3px 10px; } +.rxdt-btn-primary { + border: 0; + background: ${DEVTOOL_GRADIENT}; + color: ${C.fg}; + font-weight: 700; + font-size: 11px; + padding: 6px 16px; + transition: background 180ms ease-in-out; +} +.rxdt-btn-primary:hover:not(:disabled) { background: ${DEVTOOL_GRADIENT}; } +.rxdt-btn-primary:active:not(:disabled) { transform: translateY(1px); transition: transform 80ms ease; } +.rxdt-btn-primary:disabled { opacity: 0.4; cursor: not-allowed; } +.rxdt-btn-danger { + border: 1px solid ${C.danger}; + background: transparent; + color: ${C.danger}; + font-weight: 700; + font-size: 11px; + padding: 6px 14px; + transition: background 180ms ease-in-out, color 180ms ease-in-out; +} +.rxdt-btn-danger:hover:not(:disabled) { background: ${C.danger}; color: ${C.fg}; } +.rxdt-btn-danger-solid { border: 0; background: ${C.danger}; color: ${C.fg}; font-weight: 700; font-size: 11px; padding: 6px 14px; } +.rxdt-btn-danger-solid:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ---------- top bar ---------- */ +.rxdt-topbar { + height: 44px; + min-height: 44px; + background: ${C.purpleDeep}; + border-bottom: 1px solid rgba(255,255,255,0.10); + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + font-size: 12px; +} +.rxdt-logo { width: 14px; height: 14px; border-radius: 50%; background: linear-gradient(135deg,${C.pink},${C.purple}); flex: none; } +.rxdt-wordmark { font-weight: 800; letter-spacing: 0.02em; } +.rxdt-topbar-divider { color: rgba(255,255,255,0.25); } +.rxdt-identity { font-family: ${DEVTOOL_FONT_MONO}; font-size: 11px; color: ${C.fgMuted}; } +.rxdt-cmdk { + display: flex; align-items: center; gap: 8px; + border: 1px solid rgba(255,255,255,0.20); + padding: 3px 10px; font-size: 11px; color: ${C.fgMuted}; + font-family: ${DEVTOOL_FONT_MONO}; cursor: pointer; + transition: border-color 180ms ease-in-out; +} +.rxdt-cmdk:hover { border-color: rgba(255,255,255,0.4); } +.rxdt-cmdk span { color: rgba(255,255,255,0.45); } +.rxdt-drag-handle { color: ${C.fgMuted}; cursor: grab; font-size: 13px; letter-spacing: 2px; user-select: none; } + +/* ---------- banner ---------- */ +.rxdt-banner { display: flex; align-items: center; gap: 10px; padding: 6px 12px; font-size: 11px; } +.rxdt-banner-connected { background: rgba(62,207,142,0.08); border-bottom: 1px solid rgba(62,207,142,0.35); } +.rxdt-banner-dump { background: rgba(235,203,75,0.08); border-bottom: 1px solid rgba(235,203,75,0.35); } + +/* ---------- rail ---------- */ +.rxdt-body { flex: 1; display: flex; min-height: 0; } +.rxdt-rail { + width: 200px; min-width: 200px; + background: ${C.bgDark}; + border-right: 1px solid rgba(255,255,255,0.10); + display: flex; flex-direction: column; + font-size: 11px; padding: 10px 0; + overflow-y: auto; +} +.rxdt-rail-head { padding: 4px 12px; font-size: 10px; font-weight: 600; letter-spacing: 0.09em; color: ${C.fgDim}; } +.rxdt-rail-head + .rxdt-rail-head, .rxdt-rail-item + .rxdt-rail-head { padding-top: 14px; } +.rxdt-rail-item { + display: flex; align-items: center; gap: 8px; + padding: 4px 12px 4px 10px; + border-left: 2px solid transparent; + color: ${C.fgMuted}; cursor: pointer; + transition: background 180ms ease-in-out, color 180ms ease-in-out; +} +.rxdt-rail-item:hover { background: rgba(255,255,255,0.05); } +.rxdt-rail-item.rxdt-active { border-left-color: ${C.pink}; background: rgba(237,22,143,0.10); color: ${C.fg}; } +.rxdt-rail-label { flex: 1; font-family: ${DEVTOOL_FONT_MONO}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rxdt-rail-count { color: ${C.fgDim}; font-family: ${DEVTOOL_FONT_MONO}; } +.rxdt-rail-settings { padding: 6px 12px; border-top: 1px solid rgba(255,255,255,0.08); color: ${C.fgMuted}; cursor: pointer; } +.rxdt-rail-settings:hover { color: ${C.fg}; } + +/* ---------- main ---------- */ +.rxdt-main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.rxdt-scroll { flex: 1; overflow: auto; min-height: 0; } +.rxdt-toolbar { + display: flex; align-items: center; gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255,255,255,0.08); + flex: none; +} +.rxdt-panel-title { font-weight: 700; font-size: 13px; } +.rxdt-seg { display: flex; border: 1px solid rgba(255,255,255,0.20); font-size: 11px; } +.rxdt-seg > div { padding: 3px 12px; color: ${C.fgMuted}; cursor: pointer; transition: background 180ms ease-in-out, color 180ms ease-in-out; } +.rxdt-seg > div + div { border-left: 1px solid rgba(255,255,255,0.20); } +.rxdt-seg > div:hover { color: ${C.fg}; } +.rxdt-seg > div.rxdt-active { background: ${C.activeSegment}; color: ${C.fg}; } +.rxdt-toggle { + display: flex; align-items: center; gap: 6px; + border: 1px solid rgba(255,255,255,0.20); + padding: 3px 10px; font-size: 11px; color: ${C.fgMuted}; cursor: pointer; + transition: border-color 180ms ease-in-out, color 180ms ease-in-out; +} +.rxdt-toggle:hover { color: ${C.fg}; } +.rxdt-toggle.rxdt-on { border-color: rgba(62,207,142,0.5); color: ${C.success}; } +.rxdt-dot { width: 7px; height: 7px; border-radius: 50%; background: ${C.fgDim}; flex: none; } +.rxdt-toggle.rxdt-on .rxdt-dot { background: ${C.success}; } +.rxdt-section-label { font-size: 9px; font-weight: 600; letter-spacing: 0.09em; color: ${C.fgDim}; text-transform: uppercase; } + +/* ---------- query bar ---------- */ +.rxdt-querybar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); position: relative; flex: none; } +.rxdt-query-input-wrap { + flex: 1; display: flex; align-items: center; gap: 8px; + background: ${C.bg}; border: 1px solid rgba(255,255,255,0.14); + padding: 5px 10px; font-family: ${DEVTOOL_FONT_MONO}; font-size: 11.5px; + transition: border-color 180ms ease-in-out; +} +.rxdt-query-input-wrap.rxdt-focus { border-color: ${C.pink}; } +.rxdt-query-input-wrap.rxdt-invalid { border-color: ${C.danger}; } +.rxdt-query-input { + flex: 1; background: transparent; border: 0; outline: none; + color: ${C.fg}; font-family: inherit; font-size: inherit; padding: 0; +} +.rxdt-history-btn { color: ${C.fgDim}; font-size: 10px; cursor: pointer; user-select: none; } +.rxdt-history-btn:hover { color: ${C.fg}; } +.rxdt-dropdown { + position: absolute; top: 100%; left: 12px; right: 12px; z-index: 20; + margin-top: 4px; background: ${C.bgCode}; + border: 1px solid rgba(255,255,255,0.14); font-size: 11px; + max-height: 320px; overflow: auto; +} +.rxdt-dropdown-head { padding: 6px 10px 2px; font-size: 9px; font-weight: 600; letter-spacing: 0.09em; color: ${C.fgDim}; } +.rxdt-dropdown-head + .rxdt-dropdown-row { border-top: 0; } +.rxdt-dropdown-row { display: flex; gap: 10px; padding: 4px 10px; cursor: pointer; } +.rxdt-dropdown-row:hover, .rxdt-dropdown-row.rxdt-active { background: rgba(255,255,255,0.05); } +.rxdt-dropdown-row.rxdt-fav.rxdt-active { background: rgba(237,22,143,0.10); } +.rxdt-dropdown-name { width: 110px; color: ${C.fgMuted}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rxdt-dropdown-foot { padding: 5px 10px; border-top: 1px solid rgba(255,255,255,0.08); color: ${C.fgDim}; font-size: 10px; } +.rxdt-query-error { padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); } + +/* ---------- tables ---------- */ +.rxdt-thead { + display: grid; padding: 0 12px; + border-bottom: 1px solid rgba(255,255,255,0.14); + font-size: 10px; font-weight: 600; letter-spacing: 0.07em; + text-transform: uppercase; color: ${C.fgDim}; + flex: none; +} +.rxdt-thead > div { padding: 5px 8px 5px 0; } +.rxdt-thead > div:last-child { padding-right: 0; } +.rxdt-thead > div.rxdt-sorted { color: ${C.fg}; } +.rxdt-th-click { cursor: pointer; } +.rxdt-tr { + display: grid; padding: 0 12px; + border-bottom: 1px solid rgba(255,255,255,0.05); + font-size: 11px; cursor: pointer; + transition: background 180ms ease-in-out; +} +.rxdt-tr > div { padding: 4px 8px 4px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rxdt-tr > div:last-child { padding-right: 0; } +.rxdt-tr:hover { background: rgba(255,255,255,0.04); } +.rxdt-tr.rxdt-selected { background: rgba(237,22,143,0.10); } +.rxdt-tr.rxdt-static { cursor: default; } +.rxdt-check { accent-color: ${C.pink}; width: 12px; height: 12px; margin: 0; cursor: pointer; } +.rxdt-cell-input { + background: ${C.bg}; border: 1px solid ${C.pink}; color: ${C.fg}; + font-size: 11px; font-family: inherit; padding: 1px 6px; width: 90%; outline: none; +} +.rxdt-footer { + display: flex; align-items: center; gap: 12px; + padding: 6px 12px; border-top: 1px solid rgba(255,255,255,0.08); + font-size: 11px; color: ${C.fgMuted}; flex: none; +} +.rxdt-pager { border: 1px solid rgba(255,255,255,0.20); background: transparent; color: ${C.fg}; font-size: 11px; padding: 2px 8px; } +.rxdt-pager:disabled { color: ${C.fgDim}; cursor: not-allowed; } +.rxdt-pager:hover:not(:disabled) { background: ${C.fg}; color: ${C.bgDark}; } + +/* ---------- cards ---------- */ +.rxdt-cards { display: flex; gap: 12px; padding: 14px 12px; flex-wrap: wrap; } +.rxdt-card { flex: 1; min-width: 160px; background: ${C.bg}; border: 1px solid rgba(255,255,255,0.10); padding: 10px 12px; } +.rxdt-card-value { font-family: ${DEVTOOL_FONT_MONO}; font-size: 13px; margin-top: 4px; } +.rxdt-note { border: 1px solid rgba(255,255,255,0.12); padding: 12px; margin: 16px 12px; max-width: 640px; } +.rxdt-callout { margin: 6px 12px; padding: 10px 12px; font-size: 11.5px; } +.rxdt-callout-warning { border: 1px solid rgba(235,203,75,0.4); background: rgba(235,203,75,0.06); } +.rxdt-callout-error { border: 1px solid rgba(253,54,110,0.4); background: rgba(253,54,110,0.06); } +.rxdt-callout-title { font-weight: 700; } +.rxdt-callout-body { color: ${C.fgMuted}; margin-top: 4px; line-height: 1.55; } +.rxdt-code { + background: ${C.bgCode}; padding: 8px 12px; + font-family: ${DEVTOOL_FONT_MONO}; font-size: 11px; + white-space: pre; overflow: auto; line-height: 1.6; +} +.rxdt-code-inline { font-family: ${DEVTOOL_FONT_MONO}; color: ${C.fg}; background: ${C.bgCode}; padding: 1px 5px; } + +/* ---------- json view ---------- */ +.rxdt-json { flex: 1; overflow: auto; padding: 10px 14px; font-family: ${DEVTOOL_FONT_MONO}; font-size: 11px; line-height: 1.65; white-space: pre; } +.rxdt-json-key { color: ${C.fgDim}; } +.rxdt-json-string { color: ${C.success}; } +.rxdt-json-literal { color: ${C.warning}; } +.rxdt-json-doc { display: block; padding-left: 2ch; } +.rxdt-json-fresh { background: rgba(62,207,142,0.08); } + +/* ---------- drawer ---------- */ +.rxdt-drawer { + width: 340px; min-width: 340px; + border-left: 1px solid rgba(255,255,255,0.14); + background: ${C.bgDrawer}; + display: flex; flex-direction: column; + overflow: auto; font-size: 11px; +} +.rxdt-drawer-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); flex: none; } +.rxdt-badge { font-size: 9px; border: 1px solid rgba(237,22,143,0.5); color: ${C.pink}; padding: 1px 6px; } +.rxdt-badge-neutral { font-size: 9px; color: ${C.fgDim}; border: 1px solid rgba(255,255,255,0.15); padding: 0 4px; } +.rxdt-badge-warning { font-size: 9px; border: 1px solid rgba(235,203,75,0.5); color: ${C.warning}; padding: 0 5px; font-family: ${DEVTOOL_FONT_MONO}; } +.rxdt-badge-success { font-size: 9px; border: 1px solid rgba(62,207,142,0.5); color: ${C.success}; padding: 0 5px; font-family: ${DEVTOOL_FONT_MONO}; } +.rxdt-drawer-group { padding: 10px 12px 2px; font-size: 9px; font-weight: 600; letter-spacing: 0.09em; color: ${C.fgDim}; border-top: 1px solid rgba(255,255,255,0.08); margin-top: 8px; } +.rxdt-drawer-group-first { border-top: 0; margin-top: 0; padding-top: 8px; } +.rxdt-drawer-group-run { color: ${C.pink}; } +.rxdt-field { display: flex; gap: 8px; padding: 3px 12px; align-items: center; } +.rxdt-field-label { width: 80px; color: ${C.fgDim}; flex: none; cursor: default; } +.rxdt-field-label.rxdt-expandable { cursor: pointer; } +.rxdt-field-value { font-family: ${DEVTOOL_FONT_MONO}; color: ${C.fgMuted}; overflow: hidden; text-overflow: ellipsis; } +.rxdt-field-child { display: flex; gap: 8px; padding: 2px 12px 2px 28px; font-family: ${DEVTOOL_FONT_MONO}; } +.rxdt-field-child > span:first-child { color: ${C.fgDim}; width: 64px; flex: none; } +.rxdt-field-input { + flex: 1; background: ${C.bg}; border: 1px solid rgba(255,255,255,0.14); color: ${C.fg}; + font-size: 11px; font-family: ${DEVTOOL_FONT_MONO}; padding: 2px 6px; outline: none; + transition: border-color 180ms ease-in-out; +} +.rxdt-field-input.rxdt-edited { border-color: ${C.pink}; } +.rxdt-field-input:focus { border-color: ${C.pink}; } +.rxdt-edited-dot { width: 6px; height: 6px; border-radius: 50%; background: ${C.pink}; flex: none; } +.rxdt-will-run { margin: 4px 12px; background: ${C.bgCode}; padding: 8px 10px; font-family: ${DEVTOOL_FONT_MONO}; font-size: 10.5px; line-height: 1.6; white-space: pre; overflow: auto; } +.rxdt-will-run-changed { background: rgba(237,22,143,0.18); display: block; } +.rxdt-attachment { margin: 4px 12px; border: 1px solid rgba(255,255,255,0.12); } +.rxdt-attachment-head { display: flex; gap: 8px; padding: 4px 8px; align-items: center; } +.rxdt-attachment-preview { max-height: 160px; width: 100%; object-fit: contain; display: block; border-top: 1px solid rgba(255,255,255,0.08); background: ${C.bg}; } +.rxdt-close { color: ${C.fgDim}; cursor: pointer; font-size: 14px; line-height: 1; } +.rxdt-close:hover { color: ${C.fg}; } + +/* ---------- modal ---------- */ +.rxdt-modal-backdrop { + position: absolute; inset: 0; z-index: 50; + background: rgba(9,11,18,0.85); + display: flex; align-items: center; justify-content: center; +} +.rxdt-modal { width: 440px; max-width: calc(100% - 32px); background: ${C.bg}; border: 1px solid rgba(255,255,255,0.20); border-top: 2px solid ${C.danger}; padding: 18px 20px; font-size: 12px; } +.rxdt-modal-title { font-weight: 700; font-size: 14px; } +.rxdt-modal-body { color: ${C.fgMuted}; margin-top: 8px; line-height: 1.55; font-size: 11.5px; } +.rxdt-modal-input { + width: 100%; margin-top: 4px; background: ${C.bgDark}; + border: 1px solid rgba(255,255,255,0.20); color: ${C.fg}; + font-family: ${DEVTOOL_FONT_MONO}; font-size: 12px; padding: 6px 8px; outline: none; +} +.rxdt-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } + +/* ---------- centered states ---------- */ +.rxdt-center { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; min-height: 0; } +.rxdt-center-inner { width: 420px; max-width: 100%; text-align: center; } +.rxdt-center-title { font-weight: 800; font-size: 14px; } +.rxdt-center-body { color: ${C.fgMuted}; font-size: 11.5px; margin-top: 6px; line-height: 1.55; } +.rxdt-center-actions { display: flex; gap: 8px; justify-content: center; margin-top: 14px; } + +/* ---------- live map ---------- */ +.rxdt-map { flex: 1; display: flex; min-height: 0; padding: 14px 12px; overflow: auto; } +.rxdt-map-col { width: 186px; min-width: 186px; display: flex; flex-direction: column; gap: 8px; } +.rxdt-node { border: 1px solid rgba(255,255,255,0.12); background: ${C.bgDrawer}; padding: 9px 10px; } +.rxdt-node-app { border-color: rgba(255,255,255,0.20); background: ${C.bg}; } +.rxdt-node-clickable { cursor: pointer; transition: border-color 180ms ease-in-out; } +.rxdt-node-clickable:hover { border-color: rgba(255,255,255,0.35); } +.rxdt-node-dashed { border: 1px dashed rgba(255,255,255,0.16); padding: 8px 10px; opacity: 0.6; } +.rxdt-node-error { border-color: rgba(253,54,110,0.5); background: rgba(253,54,110,0.07); } +.rxdt-node-pulse { animation: rxdtNodePulse 250ms ease-out; } +.rxdt-map-rows { flex: 1; display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.rxdt-map-row { display: flex; align-items: center; flex: 1; min-height: 0; } +.rxdt-lane { flex: 1; min-width: 70px; display: flex; flex-direction: column; gap: 9px; } +.rxdt-track { position: relative; height: 13px; } +.rxdt-track-line { position: absolute; top: 6px; left: 0; right: 0; height: 1px; background: rgba(255,255,255,0.10); } +.rxdt-track-line-thread { background: repeating-linear-gradient(90deg,rgba(25,155,241,0.55) 0 4px,transparent 4px 8px); animation: rxdtThread 1.2s linear infinite; } +.rxdt-track-line-error { background: repeating-linear-gradient(90deg,rgba(253,54,110,0.6) 0 4px,transparent 4px 8px); } +.rxdt-particle { position: absolute; top: -1px; font-family: ${DEVTOOL_FONT_MONO}; font-size: 12px; font-weight: 700; } +.rxdt-band { flex: 1; height: 9px; } +.rxdt-spark { display: flex; align-items: flex-end; gap: 1.5px; height: 26px; margin-top: 7px; } +.rxdt-spark > div { flex: 1; background: ${C.pink}; min-height: 1px; } +.rxdt-progress { height: 8px; background: ${C.bg}; margin-top: 9px; } +.rxdt-progress > div { height: 100%; background: ${C.warning}; } +.rxdt-map-summary { display: flex; align-items: center; gap: 18px; padding: 7px 12px; border-top: 1px solid rgba(255,255,255,0.10); font-size: 11px; font-family: ${DEVTOOL_FONT_MONO}; flex: none; flex-wrap: wrap; } +.rxdt-blink { animation: rxdtBlink 1.4s ease-in-out infinite; } +.rxdt-legend { display: flex; gap: 10px; font-size: 10px; font-family: ${DEVTOOL_FONT_MONO}; color: ${C.fgDim}; flex-wrap: wrap; } +.rxdt-idle-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.05); opacity: 0.62; } +.rxdt-subpanel { position: absolute; inset: 0; z-index: 40; background: rgba(9,11,18,0.85); display: flex; align-items: center; justify-content: center; padding: 24px; } +.rxdt-subpanel-inner { width: 780px; max-width: 100%; max-height: 100%; overflow: auto; background: ${C.bgDark}; border: 1px solid rgba(255,255,255,0.14); } + +/* ---------- schema ---------- */ +.rxdt-typebar { display: flex; height: 10px; width: 240px; max-width: 100%; background: ${C.neutralBar}; } +.rxdt-swatch { display: inline-block; width: 8px; height: 8px; } + +/* ---------- diff ---------- */ +.rxdt-diff { padding: 10px 12px; font-family: ${DEVTOOL_FONT_MONO}; font-size: 11px; line-height: 1.7; white-space: pre; } +.rxdt-diff-del { background: rgba(253,54,110,0.14); color: ${C.danger}; display: block; } +.rxdt-diff-add { background: rgba(62,207,142,0.12); color: ${C.success}; display: block; } +.rxdt-detail { width: 460px; min-width: 460px; overflow: auto; background: ${C.bgDrawer}; } + +/* ---------- connection ---------- */ +.rxdt-stage { display: flex; gap: 10px; align-items: center; } +.rxdt-stage-glyph { width: 16px; flex: none; } + +/* ---------- narrow ---------- */ +.rxdt-narrow-header { + height: 48px; min-height: 48px; background: ${C.purpleDeep}; + border-bottom: 1px solid rgba(255,255,255,0.10); + display: flex; align-items: center; gap: 10px; padding: 0 14px; flex: none; +} +.rxdt-narrow .rxdt-narrow-row { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.06); + min-height: 44px; cursor: pointer; +} +.rxdt-narrow { font-size: 13px; } +.rxdt-narrow-head { padding: 12px 14px 4px; font-size: 10px; font-weight: 600; letter-spacing: 0.09em; color: ${C.fgDim}; } +.rxdt-narrow-field { padding: 8px 14px; border-bottom: 1px solid rgba(255,255,255,0.06); } +.rxdt-narrow-field > div:first-child { color: ${C.fgDim}; font-size: 10px; } +.rxdt-back { color: ${C.fgMuted}; font-size: 16px; cursor: pointer; } + +@keyframes rxdtFlowR { from { left: -2px; opacity: 0; } 8% { opacity: 1; } 92% { opacity: 1; } to { left: 100%; opacity: 0; } } +@keyframes rxdtFlowL { from { left: 100%; opacity: 0; } 8% { opacity: 1; } 92% { opacity: 1; } to { left: -2px; opacity: 0; } } +@keyframes rxdtBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } } +@keyframes rxdtBand { from { background-position: 0 0; } to { background-position: 24px 0; } } +@keyframes rxdtNodePulse { 0% { border-color: rgba(255,255,255,0.45); } 100% { border-color: rgba(255,255,255,0.12); } } +@keyframes rxdtThread { from { background-position: 0 0; } to { background-position: -16px 0; } } + +@media (prefers-reduced-motion: reduce) { + .rxdt-particle, .rxdt-band, .rxdt-blink, .rxdt-node-pulse, .rxdt-track-line-thread { animation: none !important; } +} +`; diff --git a/src/rx-database.ts b/src/rx-database.ts index 0cdbb6a9643..0d36d0cc497 100644 --- a/src/rx-database.ts +++ b/src/rx-database.ts @@ -31,7 +31,9 @@ import type { RxState, RxCollectionEvent, WebMCPOptions, - WebMCPLogEvent + WebMCPLogEvent, + DevtoolOptions, + DevtoolHandle } from './types/index.d.ts'; import { @@ -683,6 +685,10 @@ export class RxDatabaseBase< registerWebMCP(_options?: WebMCPOptions): { error$: Subject; log$: Subject; } { throw pluginMissing('webmcp'); } + + mountDevtool(_options?: DevtoolOptions): DevtoolHandle { + throw pluginMissing('devtool'); + } } /** diff --git a/src/types/index.d.ts b/src/types/index.d.ts index fa7b9368ab2..7a5d5e57204 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -30,3 +30,4 @@ export type * from './plugins/update.d.ts'; export type * from './plugins/crdt.d.ts'; export type * from './plugins/state.d.ts'; export type * from './plugins/webmcp.d.ts'; +export type * from './plugins/devtool.d.ts'; diff --git a/src/types/plugins/devtool.d.ts b/src/types/plugins/devtool.d.ts new file mode 100644 index 00000000000..e1f975f845f --- /dev/null +++ b/src/types/plugins/devtool.d.ts @@ -0,0 +1,143 @@ +import type { RxDatabase } from '../rx-database.d.ts'; + +export type DevtoolTool = 'live' | 'schema' | 'changes' | 'querylab' | 'storage'; + +export type DevtoolNavigation = + | { kind: 'collection'; name: string; } + | { kind: 'replication'; name: string; } + | { kind: 'tool'; tool: DevtoolTool; } + | { kind: 'settings'; }; + +/** + * The devtool renders the same UI on all of its surfaces. + * The surface only changes the chrome of the top bar and, + * for `dump`, which actions are available. + */ +export type DevtoolSurface = 'tab' | 'embedded' | 'tanstack' | 'dump'; + +export type DevtoolConnectionStage = { + label: string; + detail?: string; +}; + +export type DevtoolConnection = + | { state: 'local'; } + | { + state: 'connecting'; + stages: DevtoolConnectionStage[]; + currentStage: number; + pairingCode?: string; + elapsedSeconds?: number; + } + | { + state: 'connected'; + device: string; + transport: string; + writeable: boolean; + roundTripMs?: number; + onDisconnect?: () => void; + } + | { + state: 'failed'; + stages: DevtoolConnectionStage[]; + failedStage: number; + diagnosis: string; + }; + +export type DevtoolDumpInfo = { + fileName: string; + exportedAt: number; +}; + +export type DevtoolQueryEntry = { + selector: string; + name?: string; + favourite: boolean; + usedAt: number; +}; + +export type DevtoolSort = { + field: string; + direction: 'asc' | 'desc'; +}; + +export type DevtoolCollectionView = { + queryInput: string; + selector: any; + queryError: { message: string; position: number; } | null; + view: 'table' | 'json'; + page: number; + sort: DevtoolSort; + selection: Set; + observe: boolean; + openDocumentId: string | null; + stagedEdits: { [fieldPath: string]: any; }; + expandedFields: Set; + editingCell: { documentId: string; field: string; } | null; + historyOpen: boolean; +}; + +export type DevtoolChangeRecord = { + time: number; + operation: 'INSERT' | 'UPDATE' | 'DELETE'; + collectionName: string; + documentId: string; + previousRevision?: string; + revision: string; + documentData: any; + previousDocumentData: any; + source: 'local' | 'devtool'; +}; + +export type DevtoolReplicationRecord = { + time: number; + direction: 'pull' | 'push'; + collectionName: string; + documentId: string; + revision: string; + bytes: number; +}; + +export type DevtoolLiveEvent = + | { kind: 'insert' | 'update' | 'delete'; collectionName: string; fromDevtool: boolean; } + | { kind: 'query' | 'emit'; collectionName: string; } + | { kind: 'pull' | 'push'; collectionName: string; }; + +export type DevtoolOptions = { + /** + * Where the devtool is mounted. Defaults to `tab`. + */ + surface?: DevtoolSurface; + /** + * Element the devtool is rendered into. + * Defaults to a full screen element appended to `document.body`. + */ + target?: HTMLElement; + /** + * Shown in the top bar next to the database name. + */ + storageName?: string; + connection?: DevtoolConnection; + /** + * Set when the devtool reads a static export instead of a live database. + * Writing actions are disabled in that mode. + */ + dump?: DevtoolDumpInfo; + /** + * Rows per page in every grid and result list. + */ + pageSize?: number; + onOpenDumpFile?: () => void; +}; + +export type DevtoolHandle = { + /** + * The element the devtool renders into. + */ + readonly element: HTMLElement; + readonly database: RxDatabase; + navigate(navigation: DevtoolNavigation): void; + setConnection(connection: DevtoolConnection): void; + refresh(): void; + destroy(): void; +}; diff --git a/test/unit.test.ts b/test/unit.test.ts index fb0c4e00b95..2eb9aedf1f7 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -65,6 +65,7 @@ import './unit/migration-schema.test.ts'; import './unit/attachments.test.ts'; import './unit/attachments-compression.test.ts'; import './unit/migration-storage.test.ts'; +import './unit/devtool.test.ts'; import './unit/webmcp.test.ts'; import './unit/crdt.test.ts'; import './unit/population.test.ts'; diff --git a/test/unit/devtool.test.ts b/test/unit/devtool.test.ts new file mode 100644 index 00000000000..8bf1d7adfe4 --- /dev/null +++ b/test/unit/devtool.test.ts @@ -0,0 +1,230 @@ +import assert from 'assert'; + +import config from './config.ts'; +import { + createRxDatabase, + randomToken +} from '../../plugins/core/index.mjs'; +import { + DEVTOOL_COLORS, + METRICS_BUCKET_COUNT, + METRICS_BUCKET_MS, + RollingWindow, + diffJson, + formatBytes, + getByPath, + mountRxDBDevtool, + pickGridColumns, + parseCellInput, + parseSelector, + setByPath, + shortRevision, + valueType +} from '../../plugins/devtool/index.mjs'; +import { schemas } from '../../plugins/test-utils/index.mjs'; + +describe('devtool.test.ts', () => { + describe('RollingWindow', () => { + it('should sum only the events inside the window', () => { + const start = 1000000; + const window = new RollingWindow(start); + window.add(start); + window.add(start + 1); + window.add(start + METRICS_BUCKET_MS); + assert.strictEqual(window.total(start + METRICS_BUCKET_MS), 3); + }); + it('should drop events that fell out of the window', () => { + const start = 1000000; + const window = new RollingWindow(start); + window.add(start, 5); + const afterWindow = start + (METRICS_BUCKET_MS * METRICS_BUCKET_COUNT) + METRICS_BUCKET_MS; + assert.strictEqual(window.total(afterWindow), 0); + }); + it('should return the series oldest bucket first', () => { + const start = 1000000; + const window = new RollingWindow(start); + window.add(start, 2); + const now = start + METRICS_BUCKET_MS; + window.add(now, 7); + const series = window.series(now); + assert.strictEqual(series.length, METRICS_BUCKET_COUNT); + assert.strictEqual(series[METRICS_BUCKET_COUNT - 1], 7); + assert.strictEqual(series[METRICS_BUCKET_COUNT - 2], 2); + }); + it('should not keep stale counts when many buckets are skipped', () => { + const start = 1000000; + const window = new RollingWindow(start); + window.add(start, 3); + const later = start + (METRICS_BUCKET_MS * 3); + window.add(later, 1); + assert.strictEqual(window.total(later), 4); + const muchLater = later + (METRICS_BUCKET_MS * METRICS_BUCKET_COUNT); + assert.strictEqual(window.total(muchLater), 0); + }); + }); + describe('selector parsing', () => { + it('should treat an empty input as the match-all selector', () => { + const parsed = parseSelector(' '); + assert.ok(parsed.ok); + assert.deepStrictEqual((parsed as any).value, {}); + }); + it('should point the caret at the broken token', () => { + const input = '{ "done": undefined }'; + const parsed = parseSelector(input); + assert.strictEqual(parsed.ok, false); + assert.strictEqual((parsed as any).error.position, input.indexOf('undefined')); + assert.ok((parsed as any).error.message.includes('valid JSON')); + }); + it('should not mistake a quoted value for a broken token', () => { + const parsed = parseSelector('{ "owner.id": "u_102" }'); + assert.ok(parsed.ok); + }); + it('should refuse a selector that is not an object', () => { + const parsed = parseSelector('[1, 2]'); + assert.strictEqual(parsed.ok, false); + }); + }); + describe('value helpers', () => { + it('should read and write nested paths', () => { + const documentData: any = { owner: { name: 'Anna' } }; + assert.strictEqual(getByPath(documentData, 'owner.name'), 'Anna'); + assert.strictEqual(getByPath(documentData, 'owner.missing.deep'), undefined); + setByPath(documentData, 'owner.id', 'u_102'); + assert.strictEqual(documentData.owner.id, 'u_102'); + }); + it('should keep plain text edits of string fields as strings', () => { + assert.strictEqual(parseCellInput('Buy milk (2%)', 'Buy milk'), 'Buy milk (2%)'); + assert.strictEqual(parseCellInput('42', 1), 42); + assert.strictEqual(parseCellInput('false', true), false); + }); + it('should name the type of a value', () => { + assert.strictEqual(valueType(undefined), 'missing'); + assert.strictEqual(valueType(null), 'null'); + assert.strictEqual(valueType([1]), 'array'); + assert.strictEqual(valueType({}), 'object'); + assert.strictEqual(valueType('a'), 'string'); + }); + it('should shorten revisions and format bytes', () => { + assert.strictEqual(shortRevision('1-9f2a4c1234'), '1-9f2a4c'); + assert.strictEqual(formatBytes(512), '512 B'); + assert.strictEqual(formatBytes(1024 * 1024), '1 MB'); + }); + }); + describe('diff', () => { + it('should mark the changed line as removed and added', () => { + const lines = diffJson( + { id: 'a1b2c3', title: 'Buy milk' }, + { id: 'a1b2c3', title: 'Buy milk (2%)' } + ); + const removed = lines.filter(line => line.kind === 'removed'); + const added = lines.filter(line => line.kind === 'added'); + assert.strictEqual(removed.length, 1); + assert.strictEqual(added.length, 1); + assert.ok(removed[0].text.includes('Buy milk')); + assert.ok(added[0].text.includes('Buy milk (2%)')); + assert.ok(lines.some(line => line.kind === 'context' && line.text.includes('a1b2c3'))); + }); + it('should mark every line of a deleted document as removed', () => { + const lines = diffJson({ id: 'a1b2c3' }, undefined); + assert.ok(lines.length > 0); + assert.ok(lines.every(line => line.kind === 'removed')); + }); + }); + describe('pickGridColumns()', () => { + /** + * A filled RxJsonSchema sorts its properties alphabetically, which is + * what these fixtures reproduce. + */ + const todoSchema: any = { + primaryKey: 'id', + properties: { + _attachments: { type: 'object' }, + _deleted: { type: 'boolean' }, + _meta: { type: 'object' }, + _rev: { type: 'string' }, + done: { type: 'boolean' }, + dueDate: { type: 'string', maxLength: 20 }, + id: { type: 'string', maxLength: 40 }, + owner: { type: 'object' }, + priority: { type: 'number' }, + tags: { type: 'array' }, + title: { type: 'string' } + }, + required: ['id', 'title', 'done'] + }; + + it('should give the wide column to the free text field', () => { + const columns = pickGridColumns(todoSchema, 'id'); + const wide = columns.find((column: any) => column.width === '1fr'); + assert.ok(wide); + assert.strictEqual(wide.path, 'title'); + }); + it('should not repeat the primary key or internal fields as data columns', () => { + const columns = pickGridColumns(todoSchema, 'id'); + const dataColumns = columns.slice(1, columns.length - 2).map((column: any) => column.path); + assert.ok(!dataColumns.includes('id')); + assert.ok(!dataColumns.includes('_meta')); + assert.ok(!dataColumns.includes('_attachments')); + }); + it('should prefer required fields for the narrow columns', () => { + const columns = pickGridColumns(todoSchema, 'id'); + const paths = columns.map((column: any) => column.path); + assert.deepStrictEqual(paths, ['id', 'title', 'done', 'dueDate', '_rev', '_meta.lwt']); + }); + it('should skip objects and arrays', () => { + const paths = pickGridColumns(todoSchema, 'id').map((column: any) => column.path); + assert.ok(!paths.includes('tags')); + assert.ok(!paths.includes('owner')); + }); + it('should fall back to the longest bounded string when there is no free text', () => { + const schema: any = { + primaryKey: 'id', + properties: { + code: { type: 'string', maxLength: 8 }, + id: { type: 'string', maxLength: 40 }, + label: { type: 'string', maxLength: 120 } + }, + required: ['id'] + }; + const wide = pickGridColumns(schema, 'id').find((column: any) => column.width === '1fr'); + assert.ok(wide); + assert.strictEqual(wide.path, 'label'); + }); + it('should always end with the revision and the write time', () => { + const schema: any = { + primaryKey: 'id', + properties: { id: { type: 'string', maxLength: 40 } }, + required: ['id'] + }; + const paths = pickGridColumns(schema, 'id').map((column: any) => column.path); + assert.deepStrictEqual(paths.slice(-2), ['_rev', '_meta.lwt']); + assert.strictEqual(paths.length, 4); + }); + }); + describe('design tokens', () => { + it('should use the rxdb.info brand colors', () => { + assert.strictEqual(DEVTOOL_COLORS.pink, '#ED168F'); + assert.strictEqual(DEVTOOL_COLORS.purpleDeep, '#27022D'); + assert.strictEqual(DEVTOOL_COLORS.bgDark, '#0D0F18'); + }); + }); + describe('mountRxDBDevtool()', () => { + it('should throw a readable error when there is no DOM', async () => { + if (typeof document !== 'undefined') { + return; + } + const database = await createRxDatabase({ + name: randomToken(10), + storage: config.storage.getStorage() + }); + await database.addCollections({ + humans: { schema: schemas.human } + }); + assert.throws( + () => mountRxDBDevtool(database), + (error: any) => error.code === 'DVT1' + ); + await database.close(); + }); + }); +});