Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777eb3b95b | ||
|
|
7f21e46a2f | ||
|
|
dbdb5eb113 | ||
|
|
c36a0e8c94
|
||
|
|
72647623fa | ||
|
|
f7b5ffd569 | ||
|
|
f42f76c59b | ||
|
|
68f559dfc8
|
||
|
|
6f2626b04f
|
||
|
|
ad8f9001a6
|
||
|
|
e0d9312aff
|
No files matched your search
@@ -57,7 +57,7 @@ workflows:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget. Design: `docs/DESIGN.md`.
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget on the Runner page; the app's home is a dashboard (`home/`). Design: `docs/DESIGN.md`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -39,9 +39,12 @@ How tests are split:
|
||||
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
|
||||
- `notebook/`: `NotebookService` (CRUD, sharing, versions in one transaction, snapshots), run as the caller's session through the standard table actions; `NotebookVersioning` (one version per change, as the system user).
|
||||
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
|
||||
- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
|
||||
- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, the home dashboard widgets, the Runner child app, and the `sqlWorkbench` app (nav: Runner, then the main tables only).
|
||||
- `home/`: the dashboard data (`SqlWorkbenchHome`, read through core actions in the viewer's session so locks apply), its renderer, and inline-styled HTML helpers.
|
||||
- `SqlWorkbenchIntegrations`: opt-in ESB and Quick Search hooks for the main tables.
|
||||
- `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions.
|
||||
- `processes/`: the record-action steps (Share, Duplicate, Restore Version).
|
||||
- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals).
|
||||
|
||||
**Invariants (do not break):**
|
||||
- Never log SQL text, parameters, or results. Only the audit table holds them.
|
||||
@@ -62,7 +65,7 @@ How tests are split:
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional).
|
||||
- Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4.
|
||||
|
||||
|
||||
@@ -4,7 +4,16 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [1.0.0-RC.3] - Unreleased
|
||||
|
||||
### Added
|
||||
- **App home dashboard.** The `sqlWorkbench` app's home is now an information dashboard of real data, read in the viewer's session (permissions and record locks apply): summary tiles and links, runs per day, recent notebooks, recent runs, slowest and failed queries, most-used notebooks, shared notebooks, committed writes, and (workbench admins only) activity by user. Each widget has an honest empty state.
|
||||
- **Runner.** The notebook editor (`SQL_WORKBENCH` widget) moves to its own nav entry: the `sqlWorkbenchRunner` child app, labeled Runner. Open a notebook with `/app/sqlWorkbenchRunner?notebook=<id>`.
|
||||
- On a notebook's record view: an Open in Runner widget, and a Runs child list (its executions the viewer may see) through the new join `sql_workbench_notebook_join_sql_workbench_execution`.
|
||||
- `SqlWorkbenchIntegrations`: opt-in ESB (`esbTableNames`) and Quick Search (`quickSearchTables`) hooks for the main tables, with no dependency on either module.
|
||||
- **Record developer panel** "SQL Workbench" (`SqlWorkbenchDeveloperPanelProvider`, registered by `SqlWorkbenchDeveloperPanelsMetaDataProducer`): read-only `SELECT`s for the record and its table in the backend's own names, backend details, and links that open them in the Runner as a new cell (`/app/sqlWorkbenchRunner?sql=&backend=`, never run). Requires QQQ 4.1.0-RC.4 (the developer panel contract) and a Next UI with the Runner `?sql=` support.
|
||||
|
||||
### Changed
|
||||
- **The nav lists only the main tables**: Notebooks and the execution audit, after the Runner. Cells, versions, pinned results and shares are child lists on a notebook (still standard tables, reachable by URL and API). The app's widget list is now the dashboard, not the `SQL_WORKBENCH` widget; links to `/app/sqlWorkbench` that expected the editor should point at `/app/sqlWorkbenchRunner`.
|
||||
- The notebook record's header shows the description.
|
||||
- **Names follow the owner rule: lowercase snake_case, prefixed with the qbit's name (metadata only, no DDL).** Tables `sql_workbench_notebook`, `sql_workbench_cell`, `sql_workbench_notebook_version`, `sql_workbench_result_snapshot`, `sql_workbench_notebook_share`, `sql_workbench_execution` (they now equal their backend table names); possible-value source `sql_workbench_share_audience_type`; permissions `sql_workbench.*`, `sql_workbench_admin.hasAccess` and `<table>.read` etc.; security key types `sql_workbench_user` (all-access `sql_workbench_all_access`) and `sql_workbench_audience`; joins `sql_workbench_notebook_join_sql_workbench_*`.
|
||||
- `tableNamePrefix` (and the QBit namespace) is normalized to lowercase snake_case and joined with one `_`.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code when working with code in this reposi
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget. Design: `docs/DESIGN.md`.
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget on the Runner page; the app's home is a dashboard (`home/`). Design: `docs/DESIGN.md`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -39,9 +39,12 @@ How tests are split:
|
||||
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
|
||||
- `notebook/`: `NotebookService` (CRUD, sharing, versions in one transaction, snapshots), run as the caller's session through the standard table actions; `NotebookVersioning` (one version per change, as the system user).
|
||||
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
|
||||
- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
|
||||
- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, the home dashboard widgets, the Runner child app, and the `sqlWorkbench` app (nav: Runner, then the main tables only).
|
||||
- `home/`: the dashboard data (`SqlWorkbenchHome`, read through core actions in the viewer's session so locks apply), its renderer, and inline-styled HTML helpers.
|
||||
- `SqlWorkbenchIntegrations`: opt-in ESB and Quick Search hooks for the main tables.
|
||||
- `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions.
|
||||
- `processes/`: the record-action steps (Share, Duplicate, Restore Version).
|
||||
- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals).
|
||||
|
||||
**Invariants (do not break):**
|
||||
- Never log SQL text, parameters, or results. Only the audit table holds them.
|
||||
@@ -62,7 +65,7 @@ How tests are split:
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional).
|
||||
- Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4.
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
A super-user SQL notebook for any [QQQ](https://github.com/QRun-IO/qqq) application.
|
||||
|
||||
You write SQL and markdown in notebook cells, run them against any RDBMS backend of the instance (Postgres, MySQL, SQLite, H2), and see typed, paged, exportable results, quick charts, and visual EXPLAIN plans. The notebook UI lives in the [Next UI](https://github.com/QRun-IO/qqq-frontend-next) as a `SQL_WORKBENCH` widget on the qbit's app page.
|
||||
You write SQL and markdown in notebook cells, run them against any RDBMS backend of the instance (Postgres, MySQL, SQLite, H2), and see typed, paged, exportable results, quick charts, and visual EXPLAIN plans. The notebook UI (the **Runner**) lives in the [Next UI](https://github.com/QRun-IO/qqq-frontend-next) as a `SQL_WORKBENCH` widget on its own page in the app's nav. The app's home page is an information dashboard over the notebooks and the execution audit.
|
||||
|
||||
- **Read-only by default.** A default session runs one statement inside a database `READ ONLY` transaction, and a statement classifier refuses anything that isn't a read.
|
||||
- **Gated write mode.** Writes need a separate permission and two steps. First a dry run executes in a transaction that is rolled back, so you see the affected-row counts. You then confirm with a single-use token bound to that exact SQL. A row guard, a hard cap, a drift check, and statement timeouts apply.
|
||||
- **Everything is audited.** The `sql_workbench_execution` table records who ran what, when, on which backend, in which mode, how long it took, how many rows it returned or affected, and any error. SQL text and results never go to the logs.
|
||||
- **Notebooks.** Notebooks are personal by default and can be shared, read-only, with users, groups, or everyone (the shared library). They have named `:parameters` (always bound, never spliced), version history, and pinned result snapshots.
|
||||
- **Standard QQQ tables.** Notebooks, cells, versions, pinned results, shares, and the execution audit are ordinary QQQ tables in the app, with the usual query, record, edit, export, audit, and developer-mode screens. Record security locks decide who sees and changes which rows, for every entry point.
|
||||
- **Standard QQQ tables.** Notebooks, cells, versions, pinned results, shares, and the execution audit are ordinary QQQ tables, with the usual query, record, edit, export, audit, and developer-mode screens. The nav lists the main tables (Notebooks and the execution audit); the others are child lists on a notebook's record view and stay reachable by URL and the table API. Record security locks decide who sees and changes which rows, for every entry point.
|
||||
|
||||
> **Raw SQL bypasses QQQ.** It ignores record security locks, table and field permissions, hidden fields, and record locks. It also skips QQQ audits, record-change listeners, automations, ESB events, and search indexing. That is why the workbench is for super-users only. Use QQQ processes for business changes.
|
||||
|
||||
@@ -45,14 +45,43 @@ The producer adds the following to the instance:
|
||||
|
||||
- **Tables:** `sql_workbench_notebook`, `sql_workbench_cell`, `sql_workbench_notebook_version`, `sql_workbench_result_snapshot`, `sql_workbench_notebook_share`, `sql_workbench_execution`, each with record security locks, per-table permissions, and table customizers ([Permissions](#permissions)).
|
||||
- **Security key types** `sql_workbench_user` (all-access key `sql_workbench_all_access`) and `sql_workbench_audience`.
|
||||
- **Joins** from a notebook to its cells, versions, pinned results, and shares, with child-list widgets on the notebook's record view.
|
||||
- **Joins** from a notebook to its cells, versions, pinned results, shares, and runs (execution audit rows), with child-list widgets on the notebook's record view, plus an Open in Runner widget there.
|
||||
- **Possible value sources** over the notebook table and the share audience types.
|
||||
- **Record actions** (processes on a table, never in the nav): Share Notebook, Duplicate Notebook, Restore Version.
|
||||
- **The `sqlWorkbenchWidget` widget** (type `SQL_WORKBENCH`).
|
||||
- **The `sqlWorkbench` app**, which contains the widget plus all six tables.
|
||||
- **The `sqlWorkbenchWidget` widget** (type `SQL_WORKBENCH`): the notebook editor.
|
||||
- **The home dashboard widgets** ([App home](#app-home)).
|
||||
- **The `sqlWorkbench` app.** Its home page is the dashboard. Its nav lists the **Runner** (`sqlWorkbenchRunner`, a child app whose page is the `SQL_WORKBENCH` widget), then the main tables: Notebooks (`sql_workbench_notebook`) and the execution audit (`sql_workbench_execution`). Cells, versions, pinned results and shares are not in the nav; they are child lists on a notebook.
|
||||
- **The `/qqq/v1/sql-workbench/*` routes**, registered through `QJavalinMetaData`, as `qqq-esb` does.
|
||||
|
||||
To move the app under another app (for example an Admin app), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing.
|
||||
To move the app under another app (for example an Admin app), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing; the Runner moves with it. Open a notebook in the Runner with `/app/sqlWorkbenchRunner?notebook=<id>`.
|
||||
|
||||
## App home
|
||||
|
||||
The app's home page is a dashboard of real data, read through QQQ's core actions in the viewer's session. Permissions and record locks therefore decide what each widget counts: a user sees their own runs and the notebooks they can open, and a workbench admin sees everyone's. A widget whose table the user may not read says so rather than showing zeros. Every widget is full width and has an empty state.
|
||||
|
||||
| Widget | Shows |
|
||||
|---|---|
|
||||
| SQL Workbench | Tiles: notebooks (yours, shared with you), runs today, runs and failures in 7 days, committed writes and rows affected in 30 days. Links to the Runner, Notebooks and the execution audit. |
|
||||
| Runs per Day | Runs, failures and writes per day, last 30 days. |
|
||||
| Recent Notebooks | Last changed notebooks, owner, sharing, with Open in Runner links. |
|
||||
| Recent Runs | Latest runs: when, who, SQL (linked to the audit row), backend, notebook, status, duration, rows. |
|
||||
| Slowest Queries, 30 Days | Longest runs, linked. |
|
||||
| Failed Queries, 30 Days | Failed, timed-out, rejected and rolled-back runs with their error, linked. |
|
||||
| Most-Used Notebooks, 30 Days | Notebooks by runs, with users and last run. |
|
||||
| Shared Notebooks | Notebooks shared with users, groups or everyone, and since when. |
|
||||
| Writes | Committed writes (gated write mode): who, what, when, rows affected, result, and whether write mode is on. |
|
||||
| Activity by User, 30 Days | Runs, failures and writes per user. Workbench admins only. |
|
||||
|
||||
The home owns this data; a host's other dashboards should link to it rather than repeat it.
|
||||
|
||||
## ESB and Quick Search
|
||||
|
||||
`SqlWorkbenchIntegrations` is an opt-in hook: it names what to wire, and the host wires it with its own modules (the qbit depends on neither).
|
||||
|
||||
- **ESB:** `esbTableNames(config)` lists the main tables. Publish their insert, update and delete events the usual way, for example `EsbTableMetaData.ofOrWithNew(qInstance.getTable(name)).withPublication(new EsbTablePublication().withDestinationName(...).withEvents(...))`. Execution rows are inserted when a run starts and updated when it ends.
|
||||
- **Quick Search:** `quickSearchTables(config)` lists the main tables with weighted fields, the basepull timestamp field and a hit label. Notebooks: title, description, owner, default backend. Executions: SQL text, execution UUID, user, error, backend. Map each to a `SearchableTableConfig` (with `new SearchableFieldConfig(name).withWeight(w)`). Hits are re-read through the tables' record locks, so a user finds only what they could open. The Quick Search record developer panel then covers these tables too.
|
||||
|
||||
What leaves the database: a notebook event or search document carries the notebook row. An execution event carries the audit row, which includes the SQL text and parameter values, and its search document includes the SQL text. Results are never stored, so they are never published or indexed. Decide whether that suits your broker and search cluster before you opt in.
|
||||
|
||||
## Storage
|
||||
|
||||
@@ -113,11 +142,21 @@ A session without the access permission gets no keys, so it finds no workbench r
|
||||
|
||||
`writeModeEnabled=false` turns write mode off for everyone.
|
||||
|
||||
## Record developer panel
|
||||
|
||||
With QQQ 4.1.0-RC.4 or later, the qbit registers a **SQL Workbench** panel for a record's developer view (`/app/{table}/{id}/dev` in the Next UI). It shows for tables on an RDBMS backend the workbench targets (honoring `allowedBackendNames` / `excludedBackendNames`), and only to users with `sql_workbench.hasAccess`. It contains:
|
||||
|
||||
- A read-only `SELECT` for the record and one for the table (`LIMIT 100`), in the backend's own table and column names (aliased to the QQQ field names), with identifiers quoted and the primary key written as an escaped literal for the dialect.
|
||||
- The backend, dialect, database table, and whether read-only sessions use the backend's read-only replica.
|
||||
- Links that open each query in the workbench (`/app/sqlWorkbenchRunner?sql=...&backend=...`, the Runner, with the table name prefix applied). The Runner opens the SQL as a new cell and does not run it.
|
||||
|
||||
The panel only builds text. What runs is what the user executes, under their workbench permissions; the write permission does not change the panel.
|
||||
|
||||
## Administering through the tables
|
||||
|
||||
Everything the notebook UI does can be done and inspected through the tables under the SQL Workbench app. The same customizers run either way.
|
||||
Everything the Runner does can be done and inspected through the tables. Notebooks and the execution audit are in the SQL Workbench app's nav; the others open from a notebook's record view (or by URL). The same customizers run either way.
|
||||
|
||||
- **Notebooks**: create, rename, describe, delete (which removes its cells, versions, pinned results, and shares). The record view lists the notebook's cells, shares, pinned results, and versions. Record actions: Share Notebook and Duplicate Notebook.
|
||||
- **Notebooks**: create, rename, describe, delete (which removes its cells, versions, pinned results, and shares). The record view has an Open in Runner link and lists the notebook's cells, shares, pinned results, versions, and runs. Record actions: Share Notebook and Duplicate Notebook.
|
||||
- **Cells**: add (appended at the end, with a generated key), edit, reorder (`sortOrder`), delete. Each change saves a new notebook version, exactly like a save in the notebook UI.
|
||||
- **Versions**: read-only history. Restore Version saves the chosen version as a new one.
|
||||
- **Shares**: who can read a notebook. `USER` and `GROUP` shares name their audience, matched without regard to case (so user ids and group names must be case-insensitive in the host); `ALL` publishes the notebook to the shared library. The notebook's `visibility` follows its shares.
|
||||
@@ -201,7 +240,8 @@ A checklist for adding the workbench to a QQQ application. Each row points to th
|
||||
| Secrets | In production, configure `readOnlyCredentials` for each target backend with a SELECT-only database role, and optionally `writeCredentials`. Pass them as `${env.X}` references (the example uses `SQLWB_RO_USER` and `SQLWB_RO_PASSWORD`; the names are yours). The qbit reads no other environment variables. |
|
||||
| Permissions | Grant `config.getUserPermissionNames()` to workbench users, plus `getWritePermissionName()` for write mode, `getSharePermissionNames()` for sharing, and `getAdminPermissionNames()` for administrators and auditors only. See [Permissions](#permissions). |
|
||||
| Security keys | Call `SqlWorkbenchSecurity.applyToSession(qInstance, session, groups)` in your authentication customizer after setting permissions. Pass the user's groups if you want group shares. |
|
||||
| Navigation | The app lists the widget and the six tables. Its processes are record actions on the tables; keep them out of the nav. If your app adds explicit table menus or nav-coverage tests, include the six tables. |
|
||||
| Navigation | The app's home is the dashboard; its nav lists the Runner and the two main tables (Notebooks, the execution audit). The other four tables are child lists on a notebook; keep them out of explicit menus. The processes are record actions on the tables; keep them out of the nav. |
|
||||
| ESB and search | Optional: wire `SqlWorkbenchIntegrations.esbTableNames` and `quickSearchTables` into your ESB and Quick Search config ([ESB and Quick Search](#esb-and-quick-search)). |
|
||||
| Turning writes off | `withWriteModeEnabled(false)` disables write mode for everyone. |
|
||||
| Known limitations | Raw SQL bypasses QQQ security, audits, listeners and automations ([Safety model](#safety-model)). H2 and generic backends have no enforced read-only transaction. Only PostgreSQL DDL ships. Shares are read-only (no shared editing). |
|
||||
| Upgrading | From RC.2: the QQQ names became snake_case (`sql_workbench_*`; no DDL). See [UPGRADE.md](UPGRADE.md) for permissions and stored table names. From RC.1: also run the share-table DDL and backfill, grant the new permission lists (the tables no longer rely on the admin `hasAccess` permission alone), call `applyToSession`, and use QQQ `4.1.0-RC.4` or later. |
|
||||
|
||||
@@ -50,6 +50,8 @@ COMMIT;
|
||||
|
||||
Saved views and reports can also name columns of joined tables (`sqlWorkbenchCell.title`) inside their JSON filter or column lists; search those columns for `sqlWorkbench` and replace the table part. Rebuild any search index whose document ids embed table names.
|
||||
5. **Frontends.** Links to `/app/sqlWorkbench/sqlWorkbenchNotebook/...` become `/app/sqlWorkbench/sql_workbench_notebook/...`.
|
||||
6. **Navigation and the notebook UI.** The app's home (`/app/sqlWorkbench`) is now a dashboard; the notebook editor moved to the Runner (`/app/sqlWorkbenchRunner`, `?notebook=<id>` opens one). Update bookmarks and links that expected the editor on the app page. The nav lists only Notebooks and the execution audit; if your host builds its own menus or nav tests, drop the cell, version, pinned-result and share tables from them (they are child lists on a notebook). Nothing to grant: the Runner and the dashboard use the app's access permission, and Activity by User the admin permission.
|
||||
7. **Optional: ESB and Quick Search.** Wire `SqlWorkbenchIntegrations.esbTableNames` and `quickSearchTables` into your configuration (README, "ESB and Quick Search"). Adding tables to Quick Search needs an index rebuild or a basepull for the existing rows.
|
||||
|
||||
## To 1.0.0-RC.2
|
||||
|
||||
|
||||
@@ -250,9 +250,10 @@
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer, runtime services, Javalin 7 -->
|
||||
<!-- routes). Until qbit-build-parent 2.1.0 imports the released 4.1 BOM, -->
|
||||
<!-- this profile is active by default and imports qqq-bom-pom 4.1.0-RC.4 -->
|
||||
<!-- ahead of the parent's 4.0.0 BOM (same as qbit-quick-search, ADR-0007). -->
|
||||
<!-- routes; the record developer panel contract needs 4.1.0-RC.4). Until -->
|
||||
<!-- qbit-build-parent 2.1.0 imports the released 4.1 BOM, this profile is -->
|
||||
<!-- active by default and imports qqq-bom-pom 4.1.0-RC.4 ahead of the -->
|
||||
<!-- parent's 4.0.0 BOM (same as qbit-quick-search, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-in hooks for wiring the workbench's main tables into a host's ESB and
|
||||
** Quick Search, without the qbit depending on either module: the host reads
|
||||
** these names and field lists and passes them to its own qqq-esb
|
||||
** (EsbTableMetaData publications) and qbit-quick-search
|
||||
** (SearchableTableConfig) configuration.
|
||||
**
|
||||
** The main tables are Notebooks and the Execution audit (the tables in the
|
||||
** app's nav). The sub-tables (cells, versions, pinned results, shares) are
|
||||
** reached through a notebook and are left out on purpose.
|
||||
**
|
||||
** What leaves the database: a notebook event carries the notebook row
|
||||
** (title, description, owner, visibility, default backend, parameter
|
||||
** definitions). An execution event carries the audit row, including the SQL
|
||||
** text and the parameter values; results are never stored, so they are
|
||||
** never published or indexed. Search hits are re-read through the tables'
|
||||
** record locks, so a user only finds what they could open.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchIntegrations
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A table for Quick Search: its fields with weights (most important
|
||||
** first), the field basepull compares, and the label of a hit.
|
||||
**
|
||||
** @param tableName the (prefixed) table name
|
||||
** @param fieldWeights searchable field names and their weights
|
||||
** @param basepullTimestamp field whose value moves when a row changes
|
||||
** @param recordLabelFormat format of a hit's label
|
||||
** @param recordLabelFields fields for recordLabelFormat
|
||||
*******************************************************************************/
|
||||
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchIntegrations()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The main tables (in nav order), with the config's prefix applied.
|
||||
*******************************************************************************/
|
||||
public static List<String> mainTableNames(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
List<String> names = new ArrayList<>();
|
||||
for(String tableName : SqlWorkbenchAppMetaDataProducer.MAIN_TABLES)
|
||||
{
|
||||
names.add(config.applyPrefix(tableName));
|
||||
}
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables whose insert / update / delete events a host may publish to
|
||||
** its ESB (the main tables). Executions are inserted when a run starts and
|
||||
** updated when it ends.
|
||||
*******************************************************************************/
|
||||
public static List<String> esbTableNames(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
return (mainTableNames(config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The main tables as Quick Search tables:
|
||||
** - Notebooks: title, description, owner, default backend; basepull on
|
||||
** modifyDate (cell edits bump it, as each one saves a new version).
|
||||
** - Executions: SQL text, user, execution UUID, error, backend; basepull
|
||||
** on createDate (the audit has no modify date; real-time indexing picks
|
||||
** up the end-of-run update, and the reconcile catches anything missed).
|
||||
*******************************************************************************/
|
||||
public static List<SearchableTable> quickSearchTables(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
Map<String, Integer> notebook = new LinkedHashMap<>();
|
||||
notebook.put("title", 10);
|
||||
notebook.put("description", 5);
|
||||
notebook.put("ownerName", 3);
|
||||
notebook.put("defaultBackendName", 1);
|
||||
|
||||
Map<String, Integer> execution = new LinkedHashMap<>();
|
||||
execution.put("sqlText", 8);
|
||||
execution.put("executionUuid", 5);
|
||||
execution.put("userName", 3);
|
||||
execution.put("errorMessage", 2);
|
||||
execution.put("backendName", 1);
|
||||
|
||||
return (List.of(
|
||||
new SearchableTable(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME), notebook, "modifyDate", "%s", List.of("title")),
|
||||
new SearchableTable(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), execution, "createDate", "SQL run by %s (%s)", List.of("userName", "mode"))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.developer;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds read-only SELECT statements for a QQQ table in its RDBMS backend's
|
||||
** own names (the table's backend table name and each field's backend column
|
||||
** name, as QQQ's RDBMS module resolves them), with identifiers quoted and
|
||||
** the primary-key value written as an escaped literal for the dialect.
|
||||
**
|
||||
** The SQL is only shown to the user (and opened, not run, in the
|
||||
** workbench), so it uses literals rather than parameters.
|
||||
*******************************************************************************/
|
||||
public final class RecordSql
|
||||
{
|
||||
public static final int TABLE_ROW_LIMIT = 100;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private RecordSql()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SELECT the table's columns for one primary-key value.
|
||||
*******************************************************************************/
|
||||
public static String selectRecord(QTableMetaData table, SqlDialect dialect, Serializable primaryKey)
|
||||
{
|
||||
QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField());
|
||||
return (selectColumns(table, dialect)
|
||||
+ "\nWHERE " + quoteIdentifier(dialect, AbstractRDBMSAction.getColumnName(primaryKeyField)) + " = " + literal(dialect, primaryKeyField.getType(), primaryKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SELECT the table's columns, first rows only.
|
||||
*******************************************************************************/
|
||||
public static String selectTable(QTableMetaData table, SqlDialect dialect)
|
||||
{
|
||||
String select = selectColumns(table, dialect);
|
||||
if(dialect == SqlDialect.GENERIC)
|
||||
{
|
||||
return (select + "\nFETCH FIRST " + TABLE_ROW_LIMIT + " ROWS ONLY");
|
||||
}
|
||||
return (select + "\nLIMIT " + TABLE_ROW_LIMIT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SELECT clause listing every field's column (aliased to the QQQ field
|
||||
** name where the names differ) and the FROM clause.
|
||||
*******************************************************************************/
|
||||
static String selectColumns(QTableMetaData table, SqlDialect dialect)
|
||||
{
|
||||
List<String> columns = new ArrayList<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String column = AbstractRDBMSAction.getColumnName(field);
|
||||
columns.add(column.equals(field.getName())
|
||||
? quoteIdentifier(dialect, column)
|
||||
: quoteIdentifier(dialect, column) + " AS " + quoteIdentifier(dialect, field.getName()));
|
||||
}
|
||||
|
||||
return ("SELECT\n " + String.join(",\n ", columns)
|
||||
+ "\nFROM " + quoteIdentifier(dialect, AbstractRDBMSAction.getTableName(table)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quote an identifier: backticks for MySQL, double quotes otherwise (ANSI;
|
||||
** Postgres, SQLite and H2 all accept them). A quote character inside the
|
||||
** identifier is doubled.
|
||||
*******************************************************************************/
|
||||
public static String quoteIdentifier(SqlDialect dialect, String identifier)
|
||||
{
|
||||
String quote = dialect == SqlDialect.MYSQL ? "`" : "\"";
|
||||
return (quote + identifier.replace(quote, quote + quote) + quote);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A value as a SQL literal: NULL; a plain number for numeric fields whose
|
||||
** value parses as one; otherwise a single-quoted string with quotes
|
||||
** doubled (and, for MySQL, backslashes escaped).
|
||||
*******************************************************************************/
|
||||
public static String literal(SqlDialect dialect, QFieldType type, Serializable value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("NULL");
|
||||
}
|
||||
|
||||
if(type == QFieldType.INTEGER || type == QFieldType.LONG || type == QFieldType.DECIMAL || value instanceof Number)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new BigDecimal(value.toString().trim()).toPlainString());
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////
|
||||
// not a number after all: fall through to a string //
|
||||
///////////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
|
||||
return (stringLiteral(dialect, value.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A single-quoted string literal.
|
||||
*******************************************************************************/
|
||||
public static String stringLiteral(SqlDialect dialect, String value)
|
||||
{
|
||||
String escaped = value;
|
||||
if(dialect.isBackslashEscapes())
|
||||
{
|
||||
escaped = escaped.replace("\\", "\\\\");
|
||||
}
|
||||
return ("'" + escaped.replace("'", "''") + "'");
|
||||
}
|
||||
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.developer;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelLink;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelTone;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record developer panel "SQL Workbench": read-only SELECTs for the record
|
||||
** (and its table) in the backend's own table and column names, with links
|
||||
** that open them in the workbench as new cells (never run).
|
||||
**
|
||||
** Available when the workbench is in the instance (its runtime and app), the
|
||||
** user has the workbench access permission (the same check its endpoints
|
||||
** make), and the table's backend is an RDBMS backend the workbench allows.
|
||||
** The panel only builds text: nothing is executed, and the user's write
|
||||
** permission makes no difference.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchDeveloperPanelProvider implements DeveloperPanelProviderInterface
|
||||
{
|
||||
public static final String ID = "sqlWorkbench";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getId()
|
||||
{
|
||||
return (ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getTitle()
|
||||
{
|
||||
return ("SQL Workbench");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer getOrder()
|
||||
{
|
||||
return (450);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getIcon()
|
||||
{
|
||||
return ("terminal");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Available for a table on an allowed RDBMS backend, to users with the
|
||||
** workbench access permission, when the workbench app exists.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isAvailable(DeveloperPanelContext context)
|
||||
{
|
||||
return (resolve(context) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public DeveloperPanel build(DeveloperPanelContext context) throws QException
|
||||
{
|
||||
Resolved resolved = resolve(context);
|
||||
if(resolved == null)
|
||||
{
|
||||
throw (new QNotFoundException("The SQL Workbench is not available for this table."));
|
||||
}
|
||||
|
||||
QTableMetaData table = context.getTable();
|
||||
WorkbenchBackends.Target target = resolved.target();
|
||||
String runnerApp = resolved.config().applyPrefix(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME);
|
||||
Serializable primaryKey = context.getPrimaryKey();
|
||||
|
||||
DeveloperPanel panel = new DeveloperPanel()
|
||||
.withDescription("Read-only SQL for this record on the " + target.name() + " backend, in the database's own table and column names.")
|
||||
.withStatusLabel("Read-only")
|
||||
.withStatusTone(DeveloperPanelTone.INFO);
|
||||
|
||||
DeveloperPanelBlock links = DeveloperPanelBlock.links("Open in SQL Workbench");
|
||||
|
||||
if(primaryKey != null)
|
||||
{
|
||||
String recordSql = RecordSql.selectRecord(table, target.dialect(), primaryKey);
|
||||
panel.withBlock(DeveloperPanelBlock.code("Read-only query for this record", "sql", recordSql));
|
||||
links.withLink(new DeveloperPanelLink()
|
||||
.withLabel("Open in SQL Workbench")
|
||||
.withHref(workbenchHref(runnerApp, recordSql, target.name()))
|
||||
.withDescription("This record's query, as a new cell (not run)"));
|
||||
}
|
||||
|
||||
String tableSql = RecordSql.selectTable(table, target.dialect());
|
||||
panel.withBlock(DeveloperPanelBlock.code("Read-only query for this table (first " + RecordSql.TABLE_ROW_LIMIT + " rows)", "sql", tableSql));
|
||||
links.withLink(new DeveloperPanelLink()
|
||||
.withLabel(primaryKey == null ? "Open in SQL Workbench" : "Open table query in SQL Workbench")
|
||||
.withHref(workbenchHref(runnerApp, tableSql, target.name()))
|
||||
.withDescription("The table's query, as a new cell (not run)"));
|
||||
|
||||
panel.withBlock(DeveloperPanelBlock.keyValues("Backend")
|
||||
.withItem("Backend", target.name())
|
||||
.withItem("Dialect", target.dialect().getUiName())
|
||||
.withItem("Database table", AbstractRDBMSAction.getTableName(table))
|
||||
.withItem("Read-only replica", describeReadOnlySource(resolved)));
|
||||
|
||||
panel.withBlock(links);
|
||||
|
||||
panel.withBlock(DeveloperPanelBlock.text("The workbench runs only what you execute, under your SQL Workbench permissions. "
|
||||
+ "These queries are SELECTs; opening them does not run them.", DeveloperPanelTone.INFO));
|
||||
|
||||
return (panel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runner page with the SQL to open (as a new cell, never run) and its
|
||||
** backend.
|
||||
*******************************************************************************/
|
||||
static String workbenchHref(String appName, String sql, String backendName)
|
||||
{
|
||||
return ("/app/" + encode(appName) + "?sql=" + encode(sql) + "&backend=" + encode(backendName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** URL-encode a query parameter (spaces as %20, not '+').
|
||||
*******************************************************************************/
|
||||
static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where read-only workbench sessions on the target connect, as
|
||||
** WorkbenchBackends.connectionSource resolves it.
|
||||
*******************************************************************************/
|
||||
private String describeReadOnlySource(Resolved resolved)
|
||||
{
|
||||
if(resolved.backends().hasReadOnlyCredentials(resolved.target()))
|
||||
{
|
||||
return ("No (dedicated read-only credentials)");
|
||||
}
|
||||
if(Boolean.TRUE.equals(resolved.config().getUseBackendReadOnlyReplica()) && resolved.target().backend().getReadOnlyBackendMetaData() != null)
|
||||
{
|
||||
return ("Yes (" + resolved.target().backend().getReadOnlyBackendMetaData().getName() + ")");
|
||||
}
|
||||
return ("No");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench config and target for the context's table, or null when
|
||||
** the panel is not available.
|
||||
*******************************************************************************/
|
||||
private Resolved resolve(DeveloperPanelContext context)
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
QSession session = QContext.getQSession();
|
||||
if(qInstance == null || session == null || context == null || context.getTable() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
QTableMetaData table = context.getTable();
|
||||
if(!StringUtils.hasContent(table.getBackendName()) || !StringUtils.hasContent(table.getPrimaryKeyField()) || table.getField(table.getPrimaryKeyField()) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
SqlWorkbenchRuntime runtime;
|
||||
try
|
||||
{
|
||||
runtime = SqlWorkbenchRuntime.get(qInstance);
|
||||
}
|
||||
catch(QNotFoundException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
SqlWorkbenchQBitConfig config = runtime.getConfig();
|
||||
if(qInstance.getApp(config.applyPrefix(SqlWorkbenchAppMetaDataProducer.APP_NAME)) == null || !session.hasPermission(config.getAccessPermissionName()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
WorkbenchBackends backends = new WorkbenchBackends(qInstance, runtime);
|
||||
try
|
||||
{
|
||||
return (new Resolved(config, backends, backends.getTarget(table.getBackendName())));
|
||||
}
|
||||
catch(QNotFoundException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench config, backends and target for a table.
|
||||
*******************************************************************************/
|
||||
private record Resolved(SqlWorkbenchQBitConfig config, WorkbenchBackends backends, WorkbenchBackends.Target target)
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.home;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small, theme-neutral HTML pieces for the home widgets: inline styles only
|
||||
** (no host CSS), colors that read in light and dark themes, text that wraps
|
||||
** at any width (no inner horizontal scroll), and every value escaped.
|
||||
*******************************************************************************/
|
||||
public final class HomeHtml
|
||||
{
|
||||
public static final String OK_COLOR = "#16a34a";
|
||||
public static final String BAD_COLOR = "#dc2626";
|
||||
public static final String WARN_COLOR = "#d97706";
|
||||
public static final String INFO_COLOR = "#2563eb";
|
||||
public static final String OFF_COLOR = "#6b7280";
|
||||
|
||||
private static final String MUTED = "opacity:.75";
|
||||
private static final String WRAP = "overflow-wrap:anywhere;min-width:0";
|
||||
private static final String TILE = "flex:1 1 170px;min-width:0;border:1px solid rgba(127,127,127,.25);border-radius:10px;padding:12px 14px";
|
||||
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("h:mm a", Locale.US);
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("MMM d, h:mm a", Locale.US);
|
||||
private static final DateTimeFormatter FULL = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm a", Locale.US);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One summary tile: a label, a value (linked when href is set) and an
|
||||
** optional note.
|
||||
*******************************************************************************/
|
||||
public record Tile(String label, String value, String note, String href)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private HomeHtml()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTML-escape a value (null is empty).
|
||||
*******************************************************************************/
|
||||
public static String escape(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
String text = String.valueOf(value);
|
||||
StringBuilder escaped = new StringBuilder(text.length() + 16);
|
||||
for(char c : text.toCharArray())
|
||||
{
|
||||
switch(c)
|
||||
{
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '&' -> escaped.append("&");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
default -> escaped.append(c);
|
||||
}
|
||||
}
|
||||
return (escaped.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link in the text color, underlined.
|
||||
*******************************************************************************/
|
||||
public static String link(String href, Object text)
|
||||
{
|
||||
return ("<a href=\"" + escape(href) + "\" style=\"color:inherit;text-decoration:underline;text-underline-offset:2px\">" + escape(text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Muted (secondary) text.
|
||||
*******************************************************************************/
|
||||
public static String muted(Object text)
|
||||
{
|
||||
return ("<span style=\"" + MUTED + ";font-size:13px\">" + escape(text) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A pill with a colored border and text.
|
||||
*******************************************************************************/
|
||||
public static String badge(String text, String color)
|
||||
{
|
||||
return ("<span style=\"display:inline-block;white-space:nowrap;padding:1px 8px;border-radius:999px;font-size:12px;font-weight:600;line-height:1.5;"
|
||||
+ "color:" + color + ";border:1px solid " + color + "\">" + escape(text) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A primary line (already HTML) with muted facts (already HTML) after it,
|
||||
** wrapping onto more lines on narrow screens.
|
||||
*******************************************************************************/
|
||||
public static String card(String primaryHtml, String... factsHtml)
|
||||
{
|
||||
List<String> facts = new ArrayList<>();
|
||||
for(String fact : factsHtml)
|
||||
{
|
||||
if(fact != null && !fact.isBlank())
|
||||
{
|
||||
facts.add(fact);
|
||||
}
|
||||
}
|
||||
StringBuilder html = new StringBuilder("<span style=\"display:flex;flex-wrap:wrap;align-items:baseline;column-gap:10px;row-gap:2px;min-width:0\">");
|
||||
html.append("<span style=\"").append(WRAP).append("\">").append(primaryHtml == null ? "" : primaryHtml).append("</span>");
|
||||
if(!facts.isEmpty())
|
||||
{
|
||||
html.append("<span style=\"").append(WRAP).append(';').append(MUTED).append(";font-size:13px\">").append(String.join(" · ", facts)).append("</span>");
|
||||
}
|
||||
return (html.append("</span>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL (or any long text) as monospace, on one line, cut at maxChars and
|
||||
** wrapped anywhere; the full text is in the title tooltip.
|
||||
*******************************************************************************/
|
||||
public static String code(String text, int maxChars)
|
||||
{
|
||||
String oneLine = text == null ? "" : text.replaceAll("\\s+", " ").trim();
|
||||
String shown = oneLine.length() > maxChars ? oneLine.substring(0, maxChars - 1) + "…" : oneLine;
|
||||
return ("<code title=\"" + escape(oneLine.length() > 2000 ? oneLine.substring(0, 2000) : oneLine) + "\" style=\"font-size:12px;" + WRAP + "\">" + escape(shown) + "</code>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wrapping summary tiles: they grow to fill each row, so there are no holes.
|
||||
*******************************************************************************/
|
||||
public static String tiles(List<Tile> tiles)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-wrap:wrap;gap:12px\">");
|
||||
for(Tile tile : tiles)
|
||||
{
|
||||
html.append("<div style=\"").append(TILE).append("\">");
|
||||
html.append("<div style=\"font-size:12px;").append(MUTED).append(";text-transform:uppercase;letter-spacing:.04em;").append(WRAP).append("\">")
|
||||
.append(escape(tile.label())).append("</div>");
|
||||
String value = tile.href() == null ? escape(tile.value())
|
||||
: "<a href=\"" + escape(tile.href()) + "\" style=\"color:inherit;text-decoration:none\">" + escape(tile.value()) + "</a>";
|
||||
html.append("<div style=\"font-size:26px;font-weight:700;line-height:1.3;").append(WRAP).append("\">").append(value).append("</div>");
|
||||
if(tile.note() != null)
|
||||
{
|
||||
html.append("<div style=\"").append(MUTED).append(";font-size:13px;").append(WRAP).append("\">").append(escape(tile.note())).append("</div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link buttons, wrapping.
|
||||
*******************************************************************************/
|
||||
public static String actions(List<String[]> labelsAndHrefs)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-wrap:wrap;gap:8px;margin-top:12px\">");
|
||||
for(String[] action : labelsAndHrefs)
|
||||
{
|
||||
html.append("<a href=\"").append(escape(action[1]))
|
||||
.append("\" style=\"display:inline-flex;align-items:center;padding:8px 12px;border-radius:8px;border:1px solid rgba(127,127,127,.3);")
|
||||
.append("background:rgba(127,127,127,.08);color:inherit;text-decoration:none;font-size:13px;font-weight:500\">")
|
||||
.append(escape(action[0])).append("</a>");
|
||||
}
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An honest empty or unavailable state.
|
||||
*******************************************************************************/
|
||||
public static String emptyState(String message)
|
||||
{
|
||||
return ("<div style=\"padding:18px 4px;" + MUTED + ";font-size:14px;" + WRAP + "\">" + escape(message) + "</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A muted explanatory note under a widget's content.
|
||||
*******************************************************************************/
|
||||
public static String note(String text)
|
||||
{
|
||||
return ("<div style=\"margin-top:10px;" + MUTED + ";font-size:13px;" + WRAP + "\">" + escape(text) + "</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A whole number with thousands separators.
|
||||
*******************************************************************************/
|
||||
public static String number(long value)
|
||||
{
|
||||
return (String.format(Locale.US, "%,d", value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "1 run", "2 runs".
|
||||
*******************************************************************************/
|
||||
public static String plural(long count, String noun)
|
||||
{
|
||||
return (number(count) + " " + noun + (count == 1 ? "" : "s"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A duration in milliseconds: "850 ms", "4.2 s", "3m 05s".
|
||||
*******************************************************************************/
|
||||
public static String duration(Integer millis)
|
||||
{
|
||||
if(millis == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
if(millis < 1000)
|
||||
{
|
||||
return (millis + " ms");
|
||||
}
|
||||
if(millis < 60_000)
|
||||
{
|
||||
return (String.format(Locale.US, "%.1f s", millis / 1000.0));
|
||||
}
|
||||
long seconds = millis / 1000;
|
||||
return (String.format(Locale.US, "%dm %02ds", seconds / 60, seconds % 60));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An instant, compact: the time today, the date and time this year, the
|
||||
** full date otherwise.
|
||||
*******************************************************************************/
|
||||
public static String when(Instant instant, Instant now, ZoneId zoneId)
|
||||
{
|
||||
if(instant == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
ZonedDateTime when = instant.atZone(zoneId);
|
||||
ZonedDateTime today = now.atZone(zoneId);
|
||||
if(when.toLocalDate().equals(today.toLocalDate()))
|
||||
{
|
||||
return (TIME.format(when));
|
||||
}
|
||||
if(when.getYear() == today.getYear())
|
||||
{
|
||||
return (DATE_TIME.format(when));
|
||||
}
|
||||
return (FULL.format(when));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.home;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionMode;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionStatus;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The data behind the SQL Workbench home dashboard and the notebook's
|
||||
** "Open in Runner" widget.
|
||||
**
|
||||
** Everything is read through QQQ's core actions in the viewer's own session,
|
||||
** so the tables' permissions and record security locks decide what each
|
||||
** widget counts and lists: a user sees their own runs and the notebooks
|
||||
** they can open; a workbench admin (all-access key) sees everyone's. A
|
||||
** widget whose table the user may not read says so instead of showing
|
||||
** zeros. Nothing here writes, and no SQL text is logged.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchHome
|
||||
{
|
||||
public static final String KIND_SUMMARY = "summary";
|
||||
public static final String KIND_RUNS_PER_DAY = "runsPerDay";
|
||||
public static final String KIND_RECENT_NOTEBOOKS = "recentNotebooks";
|
||||
public static final String KIND_RECENT_RUNS = "recentRuns";
|
||||
public static final String KIND_SLOWEST = "slowest";
|
||||
public static final String KIND_FAILED = "failed";
|
||||
public static final String KIND_MOST_USED = "mostUsed";
|
||||
public static final String KIND_SHARED = "shared";
|
||||
public static final String KIND_WRITES = "writes";
|
||||
public static final String KIND_BY_USER = "byUser";
|
||||
public static final String KIND_NOTEBOOK_RUNNER = "notebookRunner";
|
||||
|
||||
static final int DAYS = 30;
|
||||
static final int ROWS = 10;
|
||||
static final int USER_ROWS = 25;
|
||||
static final int SCAN_LIMIT = 20_000;
|
||||
static final int SHARE_LIMIT = 1_000;
|
||||
static final int SQL_CHARS = 140;
|
||||
static final int ERROR_CHARS = 200;
|
||||
|
||||
static final List<String> FAILED_STATUSES = List.of(ExecutionStatus.FAILED.name(), ExecutionStatus.TIMED_OUT.name(),
|
||||
ExecutionStatus.REJECTED.name(), ExecutionStatus.ROLLED_BACK.name());
|
||||
|
||||
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM d", Locale.US);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who is looking, and when: the config, the clock's now, the display time
|
||||
** zone, the viewer's user id, and whether they hold the workbench
|
||||
** all-access key (see everyone's notebooks and runs).
|
||||
*******************************************************************************/
|
||||
record Viewer(SqlWorkbenchQBitConfig config, Instant now, ZoneId zoneId, String userId, boolean allAccess)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The (prefixed) name of one of the qbit's tables.
|
||||
*******************************************************************************/
|
||||
String table(String tableName)
|
||||
{
|
||||
return (config.applyPrefix(tableName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start of the window of the last DAYS days.
|
||||
*******************************************************************************/
|
||||
Instant windowStart()
|
||||
{
|
||||
return (today().minusDays(DAYS - 1L).atStartOfDay(zoneId).toInstant());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Today in the display zone.
|
||||
*******************************************************************************/
|
||||
LocalDate today()
|
||||
{
|
||||
return (now.atZone(zoneId).toLocalDate());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchHome()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The viewer of the current session.
|
||||
*******************************************************************************/
|
||||
static Viewer viewer(SqlWorkbenchQBitConfig config, Instant now)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
SqlWorkbenchSecurity.ensureSessionKeys(config, session);
|
||||
String userId = session == null || session.getUser() == null ? null : session.getUser().getIdReference();
|
||||
return (new Viewer(config, now, ValueUtils.getSessionOrInstanceZoneId(), userId, SqlWorkbenchSecurity.hasAllAccess(config)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load one widget's data.
|
||||
*******************************************************************************/
|
||||
static QWidgetData load(String kind, Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (switch(kind == null ? "" : kind)
|
||||
{
|
||||
case KIND_SUMMARY -> summary(viewer, input);
|
||||
case KIND_RUNS_PER_DAY -> runsPerDay(viewer, input);
|
||||
case KIND_RECENT_NOTEBOOKS -> recentNotebooks(viewer, input);
|
||||
case KIND_RECENT_RUNS -> recentRuns(viewer, input);
|
||||
case KIND_SLOWEST -> slowest(viewer, input);
|
||||
case KIND_FAILED -> failed(viewer, input);
|
||||
case KIND_MOST_USED -> mostUsed(viewer, input);
|
||||
case KIND_SHARED -> shared(viewer, input);
|
||||
case KIND_WRITES -> writes(viewer, input);
|
||||
case KIND_BY_USER -> byUser(viewer, input);
|
||||
case KIND_NOTEBOOK_RUNNER -> notebookRunner(viewer, input);
|
||||
default -> new RawHTML(null, HomeHtml.emptyState("Unknown SQL Workbench widget."));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tiles (notebooks, runs today, runs and failures in 7 days, writes in
|
||||
** DAYS days), what they count, and links to the Runner and the tables.
|
||||
*******************************************************************************/
|
||||
static QWidgetData summary(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String notebooks = viewer.table(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
List<HomeHtml.Tile> tiles = new ArrayList<>();
|
||||
|
||||
if(canRead(input, notebooks))
|
||||
{
|
||||
long total = count(notebooks, new QQueryFilter());
|
||||
long mine = viewer.userId() == null ? 0 : count(notebooks, new QQueryFilter(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, viewer.userId())));
|
||||
tiles.add(new HomeHtml.Tile("Notebooks", HomeHtml.number(total),
|
||||
HomeHtml.number(mine) + " yours, " + HomeHtml.number(total - mine) + (viewer.allAccess() ? " by other users" : " shared with you"), "/app/" + notebooks));
|
||||
}
|
||||
|
||||
if(canRead(input, executions))
|
||||
{
|
||||
Instant startOfToday = viewer.today().atStartOfDay(viewer.zoneId()).toInstant();
|
||||
Instant weekStart = viewer.now().minus(Duration.ofDays(7));
|
||||
Instant window = viewer.windowStart();
|
||||
tiles.add(new HomeHtml.Tile("Runs Today", HomeHtml.number(count(executions, since(startOfToday))), null, "/app/" + executions));
|
||||
tiles.add(new HomeHtml.Tile("Runs, 7 Days", HomeHtml.number(count(executions, since(weekStart))),
|
||||
HomeHtml.number(count(executions, since(weekStart).withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, FAILED_STATUSES)))) + " failed", null));
|
||||
|
||||
Writes writes = writesSince(executions, window);
|
||||
tiles.add(new HomeHtml.Tile("Writes, " + DAYS + " Days", HomeHtml.number(writes.count()),
|
||||
Boolean.TRUE.equals(viewer.config().getWriteModeEnabled()) ? HomeHtml.plural(writes.rowsAffected(), "row") + " affected" : "write mode is off", null));
|
||||
}
|
||||
|
||||
if(tiles.isEmpty())
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("You can't read the notebook or execution tables, so there is nothing to summarize.")));
|
||||
}
|
||||
|
||||
String scope = viewer.allAccess()
|
||||
? "You are a workbench admin: these counts include every user's notebooks and runs."
|
||||
: "These counts include your own runs and the notebooks you can open (yours, and those shared with you).";
|
||||
List<String[]> actions = new ArrayList<>();
|
||||
actions.add(new String[] {"Open the Runner", runnerUrl(viewer, null)});
|
||||
actions.add(new String[] {"Notebooks", "/app/" + notebooks});
|
||||
actions.add(new String[] {"Execution Audit", "/app/" + executions});
|
||||
return (new RawHTML(null, HomeHtml.tiles(tiles) + HomeHtml.actions(actions) + HomeHtml.note(scope)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs, failures and committed writes per day over the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData runsPerDay(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
Map<LocalDate, long[]> perDay = new TreeMap<>();
|
||||
for(LocalDate day = viewer.today().minusDays(DAYS - 1L); !day.isAfter(viewer.today()); day = day.plusDays(1))
|
||||
{
|
||||
perDay.put(day, new long[3]);
|
||||
}
|
||||
|
||||
List<QRecord> runs = query(executions, since(viewer.windowStart()), SCAN_LIMIT, Set.of("id", "startTime", "status", "mode"));
|
||||
long total = 0;
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Instant start = run.getValueInstant("startTime");
|
||||
long[] day = start == null ? null : perDay.get(start.atZone(viewer.zoneId()).toLocalDate());
|
||||
if(day != null)
|
||||
{
|
||||
total++;
|
||||
day[0]++;
|
||||
day[1] += FAILED_STATUSES.contains(run.getValueString("status")) ? 1 : 0;
|
||||
day[2] += ExecutionMode.WRITE_COMMIT.name().equals(run.getValueString("mode")) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> labels = new ArrayList<>();
|
||||
List<Number> all = new ArrayList<>();
|
||||
List<Number> failed = new ArrayList<>();
|
||||
List<Number> writes = new ArrayList<>();
|
||||
perDay.forEach((day, counts) ->
|
||||
{
|
||||
labels.add(DAY.format(day));
|
||||
all.add(counts[0]);
|
||||
failed.add(counts[1]);
|
||||
writes.add(counts[2]);
|
||||
});
|
||||
|
||||
ChartData chart = new ChartData().withChartData(new ChartData.Data()
|
||||
.withLabels(labels)
|
||||
.withDatasets(List.of(
|
||||
new ChartData.Data.Dataset().withLabel("Runs").withData(all).withColor("#0062FF"),
|
||||
new ChartData.Data.Dataset().withLabel("Failed").withData(failed).withColor(HomeHtml.BAD_COLOR),
|
||||
new ChartData.Data.Dataset().withLabel("Writes").withData(writes).withColor(HomeHtml.WARN_COLOR))));
|
||||
String sublabel = total == 0 ? "No runs in the last " + DAYS + " days" : HomeHtml.plural(total, "run") + " in the last " + DAYS + " days, by day";
|
||||
if(runs.size() >= SCAN_LIMIT)
|
||||
{
|
||||
sublabel += " (the latest " + HomeHtml.number(SCAN_LIMIT) + " runs)";
|
||||
}
|
||||
chart.setSublabel(sublabel);
|
||||
return (chart);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notebooks changed most recently.
|
||||
*******************************************************************************/
|
||||
static QWidgetData recentNotebooks(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String notebooks = viewer.table(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
if(!canRead(input, notebooks))
|
||||
{
|
||||
return (noAccess("notebooks"));
|
||||
}
|
||||
|
||||
List<QRecord> recent = query(notebooks, new QQueryFilter().withOrderBy(new QFilterOrderBy("modifyDate", false)).withOrderBy(new QFilterOrderBy("id", false)), ROWS,
|
||||
Set.of("id", "title", "description", "ownerUserId", "ownerName", "modifyDate"));
|
||||
Map<Integer, String> sharing = sharing(viewer, input, recent);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord notebook : recent)
|
||||
{
|
||||
String shared = sharing.getOrDefault(notebook.getValueInteger("id"), "personal");
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("notebook", notebookCard(viewer, notebook, HomeHtml.escape(firstLine(notebook.getValueString("description"), 120))));
|
||||
row.put("owner", HomeHtml.escape(ownerLabel(viewer, notebook)));
|
||||
row.put("shared", HomeHtml.badge(shared, "personal".equals(shared) ? HomeHtml.OFF_COLOR : HomeHtml.INFO_COLOR));
|
||||
row.put("modified", HomeHtml.escape(HomeHtml.when(notebook.getValueInstant("modifyDate"), viewer.now(), viewer.zoneId())));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("Notebook", "notebook"), column("Owner", "owner"), column("Sharing", "shared"), column("Modified", "modified")), rows,
|
||||
"No notebooks yet. Create one in the Runner.");
|
||||
return (table.withLinkText("All notebooks").withLinkURL("/app/" + notebooks));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The latest runs.
|
||||
*******************************************************************************/
|
||||
static QWidgetData recentRuns(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
List<QRecord> runs = query(executions, new QQueryFilter().withOrderBy(new QFilterOrderBy("startTime", false)).withOrderBy(new QFilterOrderBy("id", false)), ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
row.put("result", HomeHtml.card(statusBadge(run), HomeHtml.escape(HomeHtml.duration(run.getValueInteger("durationMs"))), HomeHtml.escape(rowsText(run))));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("When", "when"), column("Who", "who"), column("Query", "query"), column("Result", "result")), rows,
|
||||
"No runs yet. Queries run in the Runner are recorded here.");
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The slowest runs of the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData slowest(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
QQueryFilter filter = since(viewer.windowStart())
|
||||
.withCriteria(new QFilterCriteria("durationMs", QCriteriaOperator.IS_NOT_BLANK))
|
||||
.withCriteria(new QFilterCriteria("mode", QCriteriaOperator.NOT_EQUALS, ExecutionMode.WRITE_PREVIEW.name()))
|
||||
.withOrderBy(new QFilterOrderBy("durationMs", false));
|
||||
List<QRecord> runs = query(executions, filter, ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
row.put("duration", HomeHtml.card("<strong>" + HomeHtml.escape(HomeHtml.duration(run.getValueInteger("durationMs"))) + "</strong>", statusBadge(run)));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("Duration", "duration"), column("Query", "query"), column("Who", "who"), column("When", "when")), rows,
|
||||
"No completed runs in the last " + DAYS + " days.");
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed, timed out, rejected and rolled-back runs of the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData failed(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
QQueryFilter filter = since(viewer.windowStart())
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, FAILED_STATUSES))
|
||||
.withOrderBy(new QFilterOrderBy("startTime", false)).withOrderBy(new QFilterOrderBy("id", false));
|
||||
List<QRecord> runs = query(executions, filter, ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
row.put("error", HomeHtml.card(statusBadge(run), HomeHtml.escape(firstLine(run.getValueString("errorMessage"), ERROR_CHARS))));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("When", "when"), column("Query", "query"), column("Error", "error"), column("Who", "who")), rows,
|
||||
"No failed runs in the last " + DAYS + " days.");
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebooks with the most runs in the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData mostUsed(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
Aggregate runs = new Aggregate("id", AggregateOperator.COUNT);
|
||||
Aggregate users = new Aggregate("userId", AggregateOperator.COUNT_DISTINCT);
|
||||
Aggregate lastRun = new Aggregate("startTime", AggregateOperator.MAX);
|
||||
GroupBy notebook = new GroupBy(QFieldType.INTEGER, "notebookId");
|
||||
List<AggregateResult> results = new AggregateAction().execute(new AggregateInput(executions)
|
||||
.withFilter(since(viewer.windowStart()).withCriteria(new QFilterCriteria("notebookId", QCriteriaOperator.IS_NOT_BLANK)))
|
||||
.withAggregate(runs).withAggregate(users).withAggregate(lastRun).withGroupBy(notebook))
|
||||
.getResults();
|
||||
results.sort(Comparator.comparing((AggregateResult r) -> ValueUtils.getValueAsLong(r.getAggregateValue(runs))).reversed());
|
||||
List<AggregateResult> top = results.subList(0, Math.min(ROWS, results.size()));
|
||||
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
top.forEach(r -> ids.add(ValueUtils.getValueAsInteger(r.getGroupByValue(notebook))));
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, ids);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(AggregateResult result : top)
|
||||
{
|
||||
Integer id = ValueUtils.getValueAsInteger(result.getGroupByValue(notebook));
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("notebook", notebookLink(viewer, id, names.get(id)));
|
||||
row.put("runs", HomeHtml.number(ValueUtils.getValueAsLong(result.getAggregateValue(runs))));
|
||||
row.put("users", HomeHtml.number(ValueUtils.getValueAsLong(result.getAggregateValue(users))));
|
||||
row.put("lastRun", HomeHtml.escape(HomeHtml.when(ValueUtils.getValueAsInstant(result.getAggregateValue(lastRun)), viewer.now(), viewer.zoneId())));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
return (table(List.of(column("Notebook", "notebook"), column("Runs", "runs", "right"), column("Users", "users", "right"), column("Last Run", "lastRun")), rows,
|
||||
"No notebook runs in the last " + DAYS + " days."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebooks shared with users, groups or everyone (the shared library),
|
||||
** most recently shared first.
|
||||
*******************************************************************************/
|
||||
static QWidgetData shared(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String shares = viewer.table(SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
if(!canRead(input, shares))
|
||||
{
|
||||
return (noAccess("notebook shares"));
|
||||
}
|
||||
|
||||
Map<Integer, List<QRecord>> byNotebook = new LinkedHashMap<>();
|
||||
for(QRecord share : query(shares, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)), SHARE_LIMIT, Set.of("id", "notebookId", "audienceType", "audienceId", "createDate")))
|
||||
{
|
||||
Integer notebookId = share.getValueInteger("notebookId");
|
||||
if(notebookId != null && (byNotebook.containsKey(notebookId) || byNotebook.size() < ROWS))
|
||||
{
|
||||
byNotebook.computeIfAbsent(notebookId, k -> new ArrayList<>()).add(share);
|
||||
}
|
||||
}
|
||||
|
||||
Map<Integer, QRecord> notebooks = notebooks(viewer, input, byNotebook.keySet());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
byNotebook.forEach((id, list) ->
|
||||
{
|
||||
List<String> audiences = new ArrayList<>();
|
||||
for(QRecord share : list)
|
||||
{
|
||||
audiences.add(audienceLabel(share));
|
||||
}
|
||||
QRecord notebook = notebooks.get(id);
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("notebook", notebook == null ? notebookLink(viewer, id, null) : notebookCard(viewer, notebook));
|
||||
row.put("owner", HomeHtml.escape(notebook == null ? "" : ownerLabel(viewer, notebook)));
|
||||
row.put("audience", HomeHtml.escape(String.join(", ", audiences)));
|
||||
row.put("since", HomeHtml.escape(HomeHtml.when(list.get(list.size() - 1).getValueInstant("createDate"), viewer.now(), viewer.zoneId())));
|
||||
rows.add(row);
|
||||
});
|
||||
|
||||
return (table(List.of(column("Notebook", "notebook"), column("Owner", "owner"), column("Shared With", "audience"), column("Shared Since", "since")), rows,
|
||||
"No shared notebooks. Share one from its record (Share Notebook) or in the Runner."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Committed writes (gated write mode): who ran what, when, rows affected.
|
||||
*******************************************************************************/
|
||||
static QWidgetData writes(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("mode", QCriteriaOperator.EQUALS, ExecutionMode.WRITE_COMMIT.name()))
|
||||
.withOrderBy(new QFilterOrderBy("startTime", false)).withOrderBy(new QFilterOrderBy("id", false));
|
||||
List<QRecord> runs = query(executions, filter, ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
Integer affected = run.getValueInteger("rowsAffected");
|
||||
row.put("affected", HomeHtml.card("<strong>" + HomeHtml.escape(affected == null ? "-" : HomeHtml.number(affected)) + "</strong>", statusBadge(run)));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
String writeMode = Boolean.TRUE.equals(viewer.config().getWriteModeEnabled())
|
||||
? "Write mode is on: users with the write permission can preview a write, then commit it within the confirmation window. Every preview and commit is audited."
|
||||
: "Write mode is off on this server, so no new writes can be committed.";
|
||||
TableData table = table(List.of(column("When", "when"), column("Who", "who"), column("Query", "query"), column("Rows Affected", "affected")), rows,
|
||||
"No writes have been committed.");
|
||||
table.withFooterHTML(HomeHtml.note(writeMode));
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-user activity over the last DAYS days (the widget is limited to
|
||||
** workbench admins; the locks would show anyone else only themselves).
|
||||
*******************************************************************************/
|
||||
static QWidgetData byUser(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
Map<String, Object[]> users = new LinkedHashMap<>();
|
||||
List<QRecord> runs = query(executions, since(viewer.windowStart()).withOrderBy(new QFilterOrderBy("startTime", false)), SCAN_LIMIT,
|
||||
Set.of("id", "userId", "userName", "status", "mode", "startTime"));
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
String userId = run.getValueString("userId") == null ? "(unknown)" : run.getValueString("userId");
|
||||
Object[] totals = users.computeIfAbsent(userId, k -> new Object[] {run.getValueString("userName"), 0L, 0L, 0L, run.getValueInstant("startTime")});
|
||||
totals[1] = (Long) totals[1] + 1;
|
||||
totals[2] = (Long) totals[2] + (FAILED_STATUSES.contains(run.getValueString("status")) ? 1 : 0);
|
||||
totals[3] = (Long) totals[3] + (ExecutionMode.WRITE_COMMIT.name().equals(run.getValueString("mode")) ? 1 : 0);
|
||||
}
|
||||
|
||||
List<Map.Entry<String, Object[]>> sorted = new ArrayList<>(users.entrySet());
|
||||
sorted.sort(Comparator.comparing((Map.Entry<String, Object[]> e) -> (Long) e.getValue()[1]).reversed());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(Map.Entry<String, Object[]> entry : sorted.subList(0, Math.min(USER_ROWS, sorted.size())))
|
||||
{
|
||||
Object[] totals = entry.getValue();
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
String name = totals[0] == null ? entry.getKey() : String.valueOf(totals[0]);
|
||||
row.put("user", HomeHtml.card(HomeHtml.escape(name), name.equals(entry.getKey()) ? null : HomeHtml.escape(entry.getKey()),
|
||||
HomeHtml.escape("last run " + HomeHtml.when((Instant) totals[4], viewer.now(), viewer.zoneId()))));
|
||||
row.put("runs", HomeHtml.number((Long) totals[1]));
|
||||
row.put("failed", HomeHtml.number((Long) totals[2]));
|
||||
row.put("writes", HomeHtml.number((Long) totals[3]));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("User", "user"), column("Runs", "runs", "right"), column("Failed", "failed", "right"), column("Writes", "writes", "right")),
|
||||
rows, "No runs in the last " + DAYS + " days.");
|
||||
if(!viewer.allAccess())
|
||||
{
|
||||
table.withFooterHTML(HomeHtml.note("Only your own runs are shown; workbench admins see every user's."));
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** On a notebook's record view: open it in the Runner.
|
||||
*******************************************************************************/
|
||||
static QWidgetData notebookRunner(Viewer viewer, RenderWidgetInput input)
|
||||
{
|
||||
Integer id = ValueUtils.getValueAsInteger(input.getQueryParams() == null ? null : input.getQueryParams().get("id"));
|
||||
if(id == null)
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("Save the notebook to open it in the Runner.")));
|
||||
}
|
||||
return (new RawHTML(null, HomeHtml.actions(List.<String[]>of(new String[] {"Open in Runner", runnerUrl(viewer, id)}))
|
||||
+ HomeHtml.note("The Runner edits and runs this notebook's cells. Runs are recorded in the execution audit.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runner's URL, opening a notebook when an id is given.
|
||||
*******************************************************************************/
|
||||
static String runnerUrl(Viewer viewer, Integer notebookId)
|
||||
{
|
||||
String url = "/app/" + viewer.config().applyPrefix(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME);
|
||||
return (notebookId == null ? url : url + "?notebook=" + notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Committed writes since an instant, and the rows they affected.
|
||||
*******************************************************************************/
|
||||
record Writes(long count, long rowsAffected)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Writes writesSince(String executions, Instant since) throws QException
|
||||
{
|
||||
Aggregate count = new Aggregate("id", AggregateOperator.COUNT);
|
||||
Aggregate affected = new Aggregate("rowsAffected", AggregateOperator.SUM);
|
||||
List<AggregateResult> results = new AggregateAction().execute(new AggregateInput(executions)
|
||||
.withFilter(since(since).withCriteria(new QFilterCriteria("mode", QCriteriaOperator.EQUALS, ExecutionMode.WRITE_COMMIT.name())))
|
||||
.withAggregate(count).withAggregate(affected)).getResults();
|
||||
if(results.isEmpty())
|
||||
{
|
||||
return (new Writes(0, 0));
|
||||
}
|
||||
Long total = ValueUtils.getValueAsLong(results.get(0).getAggregateValue(count));
|
||||
Long rows = ValueUtils.getValueAsLong(results.get(0).getAggregateValue(affected));
|
||||
return (new Writes(total == null ? 0 : total, rows == null ? 0 : rows));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cells every run row shares: when, who, and the query (linked to its
|
||||
** audit record, with its backend and notebook).
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> runRow(Viewer viewer, QRecord run, Map<Integer, String> notebookNames)
|
||||
{
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
Integer notebookId = run.getValueInteger("notebookId");
|
||||
String recordUrl = "/app/" + viewer.table(SqlWorkbenchExecution.TABLE_NAME) + "/" + run.getValueInteger("id");
|
||||
row.put("when", HomeHtml.escape(HomeHtml.when(run.getValueInstant("startTime"), viewer.now(), viewer.zoneId())));
|
||||
row.put("who", HomeHtml.escape(userLabel(viewer, run.getValueString("userId"), run.getValueString("userName"))));
|
||||
row.put("query", HomeHtml.card("<a href=\"" + HomeHtml.escape(recordUrl) + "\" style=\"color:inherit;text-decoration:none\">"
|
||||
+ HomeHtml.code(run.getValueString("sqlText"), SQL_CHARS) + "</a>",
|
||||
HomeHtml.escape(modeLabel(run.getValueString("mode"))),
|
||||
HomeHtml.escape(run.getValueString("backendName")),
|
||||
notebookId == null ? null : notebookLink(viewer, notebookId, notebookNames.get(notebookId))));
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook's title linked to its record, with facts (already HTML) and
|
||||
** an "Open in Runner" link.
|
||||
*******************************************************************************/
|
||||
private static String notebookCard(Viewer viewer, QRecord notebook, String... facts)
|
||||
{
|
||||
Integer id = notebook.getValueInteger("id");
|
||||
List<String> all = new ArrayList<>(List.of(facts));
|
||||
all.add(HomeHtml.link(runnerUrl(viewer, id), "Open in Runner"));
|
||||
return (HomeHtml.card(HomeHtml.link("/app/" + viewer.table(SqlWorkbenchNotebook.TABLE_NAME) + "/" + id, notebook.getValueString("title")), all.toArray(new String[0])));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook link by id: its title when the viewer can read it, otherwise
|
||||
** says so (its runs stay visible in the audit after it is unshared or
|
||||
** deleted).
|
||||
*******************************************************************************/
|
||||
private static String notebookLink(Viewer viewer, Integer id, String title)
|
||||
{
|
||||
if(title == null)
|
||||
{
|
||||
return (HomeHtml.muted("Notebook #" + id + " (deleted, or not visible to you)"));
|
||||
}
|
||||
return (HomeHtml.link("/app/" + viewer.table(SqlWorkbenchNotebook.TABLE_NAME) + "/" + id, title));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "you", or the owner's name.
|
||||
*******************************************************************************/
|
||||
private static String ownerLabel(Viewer viewer, QRecord notebook)
|
||||
{
|
||||
return (userLabel(viewer, notebook.getValueString("ownerUserId"), notebook.getValueString("ownerName")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "you", the user's name, or their id.
|
||||
*******************************************************************************/
|
||||
private static String userLabel(Viewer viewer, String userId, String userName)
|
||||
{
|
||||
if(userId != null && userId.equals(viewer.userId()))
|
||||
{
|
||||
return ("you");
|
||||
}
|
||||
return (userName != null && !userName.isBlank() ? userName : (userId == null ? "unknown user" : userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain label of an execution mode.
|
||||
*******************************************************************************/
|
||||
static String modeLabel(String mode)
|
||||
{
|
||||
if(mode == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
return (switch(mode)
|
||||
{
|
||||
case "READ_ONLY" -> "read-only";
|
||||
case "WRITE_PREVIEW" -> "write preview";
|
||||
case "WRITE_COMMIT" -> "write";
|
||||
case "EXPLAIN" -> "explain";
|
||||
case "EXPLAIN_ANALYZE" -> "explain analyze";
|
||||
default -> mode.toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run's status as a colored badge.
|
||||
*******************************************************************************/
|
||||
static String statusBadge(QRecord run)
|
||||
{
|
||||
String status = run.getValueString("status");
|
||||
if(status == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
String color = switch(status)
|
||||
{
|
||||
case "SUCCEEDED" -> HomeHtml.OK_COLOR;
|
||||
case "RUNNING" -> HomeHtml.INFO_COLOR;
|
||||
case "CANCELLED" -> HomeHtml.OFF_COLOR;
|
||||
default -> HomeHtml.BAD_COLOR;
|
||||
};
|
||||
return (HomeHtml.badge(status.toLowerCase(Locale.ROOT).replace('_', ' '), color));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "12 rows" returned, or affected for a write.
|
||||
*******************************************************************************/
|
||||
private static String rowsText(QRecord run)
|
||||
{
|
||||
Integer affected = run.getValueInteger("rowsAffected");
|
||||
if(affected != null)
|
||||
{
|
||||
return (HomeHtml.plural(affected, "row") + " affected");
|
||||
}
|
||||
Integer returned = run.getValueInteger("rowsReturned");
|
||||
return (returned == null ? "" : HomeHtml.plural(returned, "row"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How each notebook is shared, from the share rows the viewer can see:
|
||||
** "everyone" (the shared library), "shared" (users or groups); absent
|
||||
** when it has no visible shares.
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, String> sharing(Viewer viewer, RenderWidgetInput input, List<QRecord> notebooks) throws QException
|
||||
{
|
||||
Map<Integer, String> sharing = new HashMap<>();
|
||||
String shares = viewer.table(SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
notebooks.forEach(n -> ids.add(n.getValueInteger("id")));
|
||||
if(ids.isEmpty() || !canRead(input, shares))
|
||||
{
|
||||
return (sharing);
|
||||
}
|
||||
for(QRecord share : query(shares, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.IN, ids)), SHARE_LIMIT, Set.of("id", "notebookId", "audienceType")))
|
||||
{
|
||||
boolean everyone = ShareAudienceType.ALL.name().equals(share.getValueString("audienceType"));
|
||||
sharing.merge(share.getValueInteger("notebookId"), everyone ? "everyone" : "shared", (a, b) -> "everyone".equals(a) ? a : b);
|
||||
}
|
||||
return (sharing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A share's audience in words.
|
||||
*******************************************************************************/
|
||||
private static String audienceLabel(QRecord share)
|
||||
{
|
||||
String type = share.getValueString("audienceType");
|
||||
if(ShareAudienceType.ALL.name().equals(type))
|
||||
{
|
||||
return ("everyone");
|
||||
}
|
||||
String who = share.getValueString("audienceId");
|
||||
return (ShareAudienceType.GROUP.name().equals(type) ? "group " + who : who);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first line of a text, cut at maxChars.
|
||||
*******************************************************************************/
|
||||
static String firstLine(String text, int maxChars)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String line = text.strip().lines().findFirst().orElse("");
|
||||
return (line.length() > maxChars ? line.substring(0, maxChars - 1) + "…" : line);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Titles of the notebooks the runs belong to, as far as the viewer can read
|
||||
** them.
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, String> notebookTitles(Viewer viewer, RenderWidgetInput input, List<QRecord> runs) throws QException
|
||||
{
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
if(run.getValueInteger("notebookId") != null)
|
||||
{
|
||||
ids.add(run.getValueInteger("notebookId"));
|
||||
}
|
||||
}
|
||||
return (notebookTitles(viewer, input, ids));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, String> notebookTitles(Viewer viewer, RenderWidgetInput input, Set<Integer> ids) throws QException
|
||||
{
|
||||
Map<Integer, String> titles = new HashMap<>();
|
||||
notebooks(viewer, input, ids).forEach((id, notebook) -> titles.put(id, notebook.getValueString("title")));
|
||||
return (titles);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The readable notebooks among some ids (through the notebook locks).
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, QRecord> notebooks(Viewer viewer, RenderWidgetInput input, Set<Integer> ids) throws QException
|
||||
{
|
||||
Map<Integer, QRecord> notebooks = new HashMap<>();
|
||||
String table = viewer.table(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
if(ids.isEmpty() || !canRead(input, table))
|
||||
{
|
||||
return (notebooks);
|
||||
}
|
||||
for(QRecord notebook : query(table, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<Serializable>(ids))), ids.size(),
|
||||
Set.of("id", "title", "ownerUserId", "ownerName")))
|
||||
{
|
||||
notebooks.put(notebook.getValueInteger("id"), notebook);
|
||||
}
|
||||
return (notebooks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the session may read a table.
|
||||
*******************************************************************************/
|
||||
static boolean canRead(RenderWidgetInput input, String tableName)
|
||||
{
|
||||
return (QContext.getQInstance().getTable(tableName) != null && PermissionsHelper.hasTablePermission(input, tableName, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "you can't read this" state.
|
||||
*******************************************************************************/
|
||||
private static QWidgetData noAccess(String what)
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("You don't have permission to read " + what + ".")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A filter on runs started at or after an instant.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter since(Instant since)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria("startTime", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count through the table's locks.
|
||||
*******************************************************************************/
|
||||
private static long count(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
Integer count = new CountAction().execute(new CountInput(tableName).withFilter(filter)).getCount();
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query through the table's locks.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String tableName, QQueryFilter filter, int limit, Set<String> fields) throws QException
|
||||
{
|
||||
QueryInput queryInput = new QueryInput(tableName).withFilter(filter.withLimit(limit));
|
||||
if(fields != null)
|
||||
{
|
||||
queryInput.withFieldNamesToInclude(fields);
|
||||
}
|
||||
return (new QueryAction().execute(queryInput).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table column of HTML cells, top-aligned.
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String header, String accessor)
|
||||
{
|
||||
return (column(header, accessor, "left"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String header, String accessor, String align)
|
||||
{
|
||||
return (new TableData.Column("html", header, accessor, null, align).withVerticalAlign("top"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table without pagination controls, with an honest empty state.
|
||||
*******************************************************************************/
|
||||
private static TableData table(List<TableData.Column> columns, List<Map<String, Object>> rows, String empty)
|
||||
{
|
||||
return (new TableData(null, columns, rows)
|
||||
.withRowsPerPage(Math.max(ROWS, rows.size()))
|
||||
.withHidePaginationDropdown(true)
|
||||
.withNoRowsFoundHTML(HomeHtml.emptyState(empty)));
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench.home;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the SQL Workbench home widgets (and the notebook's "Open in
|
||||
** Runner" widget). Which one is in the widget's "kind" default value; the
|
||||
** data is read in the viewer's session, so permissions and record locks
|
||||
** apply (see SqlWorkbenchHome). The widgets' own permission rules (the
|
||||
** middleware checks them, and the instance metadata hides denied ones)
|
||||
** decide who gets each widget at all.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchHomeWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final String KIND = "kind";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
SqlWorkbenchRuntime runtime = SqlWorkbenchRuntime.get();
|
||||
String kind = null;
|
||||
if(input.getWidgetMetaData() instanceof QWidgetMetaData widget)
|
||||
{
|
||||
Map<String, Serializable> defaults = widget.getDefaultValues();
|
||||
kind = defaults == null || defaults.get(KIND) == null ? null : String.valueOf(defaults.get(KIND));
|
||||
}
|
||||
SqlWorkbenchHome.Viewer viewer = SqlWorkbenchHome.viewer(runtime.getConfig(), runtime.getClock().instant());
|
||||
return (new RenderWidgetOutput(SqlWorkbenchHome.load(kind, viewer, input)));
|
||||
}
|
||||
|
||||
}
|
||||
+26
-15
@@ -17,6 +17,7 @@
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -27,29 +28,34 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaData
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sqlWorkbench app: the workbench widget (rendered by the Next UI as the
|
||||
** notebook UI), plus every workbench table as a child - notebooks, cells,
|
||||
** versions, pinned results, shares and the execution audit - so the data
|
||||
** is administered through the standard QQQ table and record screens. Gated
|
||||
** by the access permission; each table also needs its own read permission
|
||||
** to appear. No processes in the nav (record actions live on the tables).
|
||||
** The sqlWorkbench app. Its home page is an information dashboard (see
|
||||
** SqlWorkbenchHomeWidgetsMetaDataProducer); its nav lists:
|
||||
**
|
||||
** - Runner: the interactive notebook editor (the SQL_WORKBENCH widget), a
|
||||
** child app of its own;
|
||||
** - the main tables people use directly: Notebooks and the Execution
|
||||
** audit.
|
||||
**
|
||||
** The sub-tables (cells, versions, pinned results, shares) are not in the
|
||||
** nav: they are child lists and relations on a notebook's record view, and
|
||||
** stay standard QQQ tables reachable by URL and the table API. Gated by the
|
||||
** access permission; each table also needs its own read permission to
|
||||
** appear. No processes in the nav (record actions live on the tables).
|
||||
** Hosts can re-parent it (e.g. under an Admin app); its name stays the same.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String APP_NAME = "sqlWorkbench";
|
||||
|
||||
static final List<String> TABLES_IN_NAV = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the main tables, in nav order; the rest are reached from a notebook //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
public static final List<String> MAIN_TABLES = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
@@ -66,12 +72,17 @@ public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProduc
|
||||
.withLabel("SQL Workbench")
|
||||
.withIcon(new QIcon("terminal"))
|
||||
.withPermissionRules(config.getAccessPermissionRules())
|
||||
.withWidgets(List.of(config.applyPrefix(SqlWorkbenchWidgetMetaDataProducer.NAME)));
|
||||
.withWidgets(new ArrayList<>(SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames().stream().map(config::applyPrefix).toList()));
|
||||
|
||||
MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput();
|
||||
if(produced != null)
|
||||
{
|
||||
for(String tableName : TABLES_IN_NAV)
|
||||
QAppMetaData runner = produced.get(QAppMetaData.class, config.applyPrefix(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME));
|
||||
if(runner != null)
|
||||
{
|
||||
app.withChild(runner);
|
||||
}
|
||||
for(String tableName : MAIN_TABLES)
|
||||
{
|
||||
QTableMetaData table = produced.get(QTableMetaData.class, config.applyPrefix(tableName));
|
||||
if(table != null)
|
||||
@@ -86,7 +97,7 @@ public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProduc
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** last, so the tables and widget exist in the output
|
||||
** last, so the tables, widgets and Runner app exist in the output
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.developer.SqlWorkbenchDeveloperPanelProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers the "SQL Workbench" record developer panel
|
||||
** ({@link SqlWorkbenchDeveloperPanelProvider}) in the instance's developer
|
||||
** panels (idempotent). The provider decides per table and user whether it
|
||||
** shows.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchDeveloperPanelsMetaDataProducer extends QBitComponentMetaDataProducer<DeveloperPanelsMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public DeveloperPanelsMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (DeveloperPanelsMetaData.ofOrWithNew(qInstance).withProvider(new QCodeReference(SqlWorkbenchDeveloperPanelProvider.class)));
|
||||
}
|
||||
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.home.SqlWorkbenchHome;
|
||||
import com.kingsrook.qbits.sqlworkbench.home.SqlWorkbenchHomeWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sqlWorkbench app's home dashboard, in page order, all full width:
|
||||
** summary tiles and links, runs per day, recent notebooks, recent runs,
|
||||
** slowest and failed queries, most-used notebooks, shared notebooks,
|
||||
** committed writes, and (workbench admins only) activity by user. Plus
|
||||
** the "Open in Runner" widget on a notebook's record view.
|
||||
**
|
||||
** The home owns this data; other dashboards should link here rather than
|
||||
** repeat it.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String SUMMARY = "sqlWorkbenchHomeSummary";
|
||||
public static final String RUNS_PER_DAY = "sqlWorkbenchHomeRunsPerDay";
|
||||
public static final String RECENT_NOTEBOOKS = "sqlWorkbenchHomeRecentNotebooks";
|
||||
public static final String RECENT_RUNS = "sqlWorkbenchHomeRecentRuns";
|
||||
public static final String SLOWEST = "sqlWorkbenchHomeSlowestQueries";
|
||||
public static final String FAILED = "sqlWorkbenchHomeFailedQueries";
|
||||
public static final String MOST_USED = "sqlWorkbenchHomeMostUsedNotebooks";
|
||||
public static final String SHARED = "sqlWorkbenchHomeSharedNotebooks";
|
||||
public static final String WRITES = "sqlWorkbenchHomeWrites";
|
||||
public static final String BY_USER = "sqlWorkbenchHomeActivityByUser";
|
||||
public static final String NOTEBOOK_RUNNER = "sqlWorkbenchNotebookOpenInRunner";
|
||||
|
||||
private static final String HTML = WidgetType.HTML.getType();
|
||||
private static final String TABLE = WidgetType.TABLE.getType();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home widget names, in page order (unprefixed).
|
||||
*******************************************************************************/
|
||||
public static List<String> homeWidgetNames()
|
||||
{
|
||||
return (List.of(SUMMARY, RUNS_PER_DAY, RECENT_NOTEBOOKS, RECENT_RUNS, SLOWEST, FAILED, MOST_USED, SHARED, WRITES, BY_USER));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
QPermissionRules access = config.getAccessPermissionRules();
|
||||
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
widgets.add(widget(config, SUMMARY, "SQL Workbench", HTML, SqlWorkbenchHome.KIND_SUMMARY, access));
|
||||
widgets.add(widget(config, RUNS_PER_DAY, "Runs per Day", WidgetType.LINE_CHART.getType(), SqlWorkbenchHome.KIND_RUNS_PER_DAY, access));
|
||||
widgets.add(widget(config, RECENT_NOTEBOOKS, "Recent Notebooks", TABLE, SqlWorkbenchHome.KIND_RECENT_NOTEBOOKS, access));
|
||||
widgets.add(widget(config, RECENT_RUNS, "Recent Runs", TABLE, SqlWorkbenchHome.KIND_RECENT_RUNS, access));
|
||||
widgets.add(widget(config, SLOWEST, "Slowest Queries, 30 Days", TABLE, SqlWorkbenchHome.KIND_SLOWEST, access));
|
||||
widgets.add(widget(config, FAILED, "Failed Queries, 30 Days", TABLE, SqlWorkbenchHome.KIND_FAILED, access));
|
||||
widgets.add(widget(config, MOST_USED, "Most-Used Notebooks, 30 Days", TABLE, SqlWorkbenchHome.KIND_MOST_USED, access));
|
||||
widgets.add(widget(config, SHARED, "Shared Notebooks", TABLE, SqlWorkbenchHome.KIND_SHARED, access));
|
||||
widgets.add(widget(config, WRITES, "Writes", TABLE, SqlWorkbenchHome.KIND_WRITES, access));
|
||||
widgets.add(widget(config, BY_USER, "Activity by User, 30 Days", TABLE, SqlWorkbenchHome.KIND_BY_USER, config.getAdminPermissionRules()));
|
||||
widgets.add(widget(config, NOTEBOOK_RUNNER, "Open in Runner", HTML, SqlWorkbenchHome.KIND_NOTEBOOK_RUNNER, access).withIsCard(false).withShowReloadButton(false));
|
||||
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
widgets.forEach(output::add);
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One full-width widget rendered by SqlWorkbenchHomeWidgetRenderer.
|
||||
*******************************************************************************/
|
||||
private static QWidgetMetaData widget(SqlWorkbenchQBitConfig config, String name, String label, String type, String kind, QPermissionRules permissionRules)
|
||||
{
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(name))
|
||||
.withLabel(label)
|
||||
.withType(type)
|
||||
.withGridColumns(12)
|
||||
.withIsCard(true)
|
||||
.withShowReloadButton(true)
|
||||
.withCodeReference(new QCodeReference(SqlWorkbenchHomeWidgetRenderer.class))
|
||||
.withDefaultValue(SqlWorkbenchHomeWidgetRenderer.KIND, kind)
|
||||
.withPermissionRules(permissionRules));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** with the workbench widget, before the apps
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (310);
|
||||
}
|
||||
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runner: a child app of the sqlWorkbench app whose page is the
|
||||
** SQL_WORKBENCH widget (the interactive notebook editor the Next UI draws).
|
||||
** It is a custom screen, so it has a nav entry; processes never do. Open a
|
||||
** notebook with /app/sqlWorkbenchRunner?notebook=<id>. Same permission as
|
||||
** the app.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRunnerAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String APP_NAME = "sqlWorkbenchRunner";
|
||||
public static final String LABEL = "Runner";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAppMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
return (new QAppMetaData()
|
||||
.withName(config.applyPrefix(APP_NAME))
|
||||
.withLabel(LABEL)
|
||||
.withIcon(new QIcon("play_circle"))
|
||||
.withPermissionRules(config.getAccessPermissionRules())
|
||||
.withWidgets(List.of(config.applyPrefix(SqlWorkbenchWidgetMetaDataProducer.NAME))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the widget, before the sqlWorkbench app (its parent)
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (850);
|
||||
}
|
||||
|
||||
}
|
||||
+30
-16
@@ -37,6 +37,7 @@ import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
@@ -86,11 +87,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchTableMetaDataHelper
|
||||
{
|
||||
public static final String JOIN_NOTEBOOK_CELL = "sql_workbench_notebook_join_sql_workbench_cell";
|
||||
public static final String JOIN_NOTEBOOK_VERSION = "sql_workbench_notebook_join_sql_workbench_notebook_version";
|
||||
public static final String JOIN_NOTEBOOK_SNAPSHOT = "sql_workbench_notebook_join_sql_workbench_result_snapshot";
|
||||
public static final String JOIN_NOTEBOOK_SHARE = "sql_workbench_notebook_join_sql_workbench_notebook_share";
|
||||
public static final String JOIN_SHARE_NOTEBOOK = "sql_workbench_notebook_share_join_sql_workbench_notebook";
|
||||
public static final String JOIN_NOTEBOOK_CELL = "sql_workbench_notebook_join_sql_workbench_cell";
|
||||
public static final String JOIN_NOTEBOOK_VERSION = "sql_workbench_notebook_join_sql_workbench_notebook_version";
|
||||
public static final String JOIN_NOTEBOOK_SNAPSHOT = "sql_workbench_notebook_join_sql_workbench_result_snapshot";
|
||||
public static final String JOIN_NOTEBOOK_SHARE = "sql_workbench_notebook_join_sql_workbench_notebook_share";
|
||||
public static final String JOIN_SHARE_NOTEBOOK = "sql_workbench_notebook_share_join_sql_workbench_notebook";
|
||||
public static final String JOIN_NOTEBOOK_EXECUTION = "sql_workbench_notebook_join_sql_workbench_execution";
|
||||
|
||||
|
||||
|
||||
@@ -107,12 +109,14 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
.withLock(ownerLock(config, "ownerUserId"))
|
||||
.withLock(shareLock(config, config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME) + ".audienceKey", config.applyPrefix(JOIN_SHARE_NOTEBOOK))))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "title", "visibility", "ownerName")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "defaultBackendName", "parametersJson")))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_CELL, "notes"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SHARE, "share"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SNAPSHOT, "push_pin"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_VERSION, "history"))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "title", "description", "ownerName", "visibility")))
|
||||
.withSection(new QFieldSection("runner", "Runner", new QIcon("play_circle"), Tier.T2, null).withWidgetName(config.applyPrefix(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER)))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("defaultBackendName", "parametersJson")))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_CELL, "Cells", "notes"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SHARE, "Shared With", "share"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SNAPSHOT, "Pinned Results", "push_pin"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_VERSION, "Versions", "history"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_EXECUTION, "Runs", "policy"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("ownerUserId", "currentVersion", "createDate", "modifyDate")));
|
||||
table.getField("visibility").setIsEditable(false);
|
||||
table.getField("visibility").setIsRequired(false);
|
||||
@@ -269,8 +273,10 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The joins: notebook to each child (one-to-many), and share to notebook
|
||||
** (many-to-one, the direction the share locks walk).
|
||||
** The joins: notebook to each child (one-to-many), notebook to its runs in
|
||||
** the execution audit (one-to-many; the audit is not foreign-keyed, so it
|
||||
** outlives the notebook), and share to notebook (many-to-one, the
|
||||
** direction the share locks walk).
|
||||
*******************************************************************************/
|
||||
public static List<QJoinMetaData> buildJoins(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
@@ -280,6 +286,7 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
children.put(JOIN_NOTEBOOK_VERSION, SqlWorkbenchNotebookVersion.TABLE_NAME);
|
||||
children.put(JOIN_NOTEBOOK_SNAPSHOT, SqlWorkbenchResultSnapshot.TABLE_NAME);
|
||||
children.put(JOIN_NOTEBOOK_SHARE, SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
children.put(JOIN_NOTEBOOK_EXECUTION, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
List<QJoinMetaData> joins = new ArrayList<>();
|
||||
for(Map.Entry<String, String> entry : children.entrySet())
|
||||
@@ -305,7 +312,8 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
/*******************************************************************************
|
||||
** Child-record list widgets on the notebook's record view: cells (add and
|
||||
** edit in place), shares (add, for users who may share), pinned results,
|
||||
** versions.
|
||||
** versions, and runs (the notebook's executions the viewer may see: their
|
||||
** own, or everyone's for workbench admins).
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaDataInterface> buildChildListWidgets(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
@@ -330,8 +338,14 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_VERSION)))
|
||||
.withLabel("Versions").withMaxRows(25)
|
||||
.withOrderBys(List.of(new QFilterOrderBy("versionNumber", false)))
|
||||
.withOnlyIncludeFieldNames(List.of("id", "versionNumber", "comment", "savedByName", "createDate"))
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_EXECUTION)))
|
||||
.withLabel("Runs").withMaxRows(25)
|
||||
.withOrderBys(List.of(new QFilterOrderBy("startTime", false), new QFilterOrderBy("id", false)))
|
||||
.withOnlyIncludeFieldNames(List.of("id", "startTime", "userName", "mode", "status", "durationMs", "rowsReturned", "rowsAffected", "cellKey"))
|
||||
.getWidgetMetaData());
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
@@ -410,9 +424,9 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
/*******************************************************************************
|
||||
** A record-view section that shows one of the child-list widgets.
|
||||
*******************************************************************************/
|
||||
private static QFieldSection widgetSection(SqlWorkbenchQBitConfig config, String joinName, String icon)
|
||||
private static QFieldSection widgetSection(SqlWorkbenchQBitConfig config, String joinName, String label, String icon)
|
||||
{
|
||||
return (new QFieldSection(config.applyPrefix(joinName), new QIcon(icon), Tier.T2).withWidgetName(config.applyPrefix(joinName)));
|
||||
return (new QFieldSection(config.applyPrefix(joinName), label, new QIcon(icon), Tier.T2, null).withWidgetName(config.applyPrefix(joinName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -56,6 +57,14 @@ import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBacken
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.C3P0PooledConnectionProvider;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.ConnectionPoolSettings;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -434,6 +443,45 @@ class PostgresIntegrationTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home dashboard over real engine runs on Postgres (aggregates, IN
|
||||
** filters and timestamps through the RDBMS module): a reader sees their
|
||||
** own runs, an admin everyone's, including the committed write.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHomeDashboardOnPostgres() throws Exception
|
||||
{
|
||||
NotebookService notebooks = new NotebookService(SqlWorkbenchRuntime.get(qInstance));
|
||||
login("writer-1");
|
||||
Integer id = (Integer) notebooks.create(Map.of("title", "Dashboard source", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 1"))), writer()).get("id");
|
||||
engine.execute(new ExecutionRequest(BACKEND, "SELECT count(*) FROM person", Map.of(), false, id, null, null, null, false), writer());
|
||||
String sql = "UPDATE person SET salary = salary + 0 WHERE salary IS NOT NULL";
|
||||
Map<String, Object> preview = engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, null, false), writer());
|
||||
engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, (String) preview.get("confirmToken"), false), writer());
|
||||
login("reader-1");
|
||||
engine.execute(new ExecutionRequest(BACKEND, "SELECT broken FROM nowhere", Map.of(), false, null, null, null, null, false), reader());
|
||||
|
||||
login("admin-1");
|
||||
Map<String, String> html = new HashMap<>();
|
||||
for(String name : SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames())
|
||||
{
|
||||
QWidgetData data = new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData((QWidgetMetaData) qInstance.getWidget(name))).getWidgetData();
|
||||
html.put(name, data instanceof TableData table ? table.getRows().toString() : data instanceof RawHTML raw ? raw.getHtml() : String.valueOf(((ChartData) data).getSublabel()));
|
||||
}
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES)).contains("UPDATE person", "<strong>2</strong>");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED)).contains("SELECT broken FROM nowhere");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED)).contains("Dashboard source");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER)).contains("writer-1", "reader-1");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).contains("Writes, 30 Days", "2 rows affected");
|
||||
|
||||
login("reader-1");
|
||||
TableData recent = (TableData) new RenderWidgetAction().execute(new RenderWidgetInput()
|
||||
.withWidgetMetaData((QWidgetMetaData) qInstance.getWidget(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS))).getWidgetData();
|
||||
assertThat(recent.getRows().toString()).contains("SELECT broken").doesNotContain("UPDATE person");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log in with the workbench security keys.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -27,9 +27,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetRenderer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
@@ -112,15 +115,22 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
assertThat(widget.getType()).isEqualTo("SQL_WORKBENCH");
|
||||
|
||||
QAppMetaData app = qInstance.getApp(SqlWorkbenchAppMetaDataProducer.APP_NAME);
|
||||
assertThat(app.getWidgets()).containsExactly(SqlWorkbenchWidgetMetaDataProducer.NAME);
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("sql_workbench_notebook", "sql_workbench_cell", "sql_workbench_notebook_share",
|
||||
"sql_workbench_result_snapshot", "sql_workbench_notebook_version", "sql_workbench_execution");
|
||||
assertThat(app.getWidgets()).containsExactlyElementsOf(SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames());
|
||||
assertThat(app.getWidgets()).allSatisfy(name -> assertThat(qInstance.getWidget(name)).isNotNull());
|
||||
assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("sql_workbench");
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// the qbit's own processes are record actions on its tables and //
|
||||
// never app children (the nav only lists tables and the widget) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
assertThat(app.getChildren()).allSatisfy(child -> assertThat(child).isInstanceOf(QTableMetaData.class));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// nav: the Runner (a child app showing the notebook widget), then the //
|
||||
// main tables only; the qbit's processes are record actions, never //
|
||||
// nav children //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("sqlWorkbenchRunner", "sql_workbench_notebook", "sql_workbench_execution");
|
||||
QAppMetaData runner = qInstance.getApp(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME);
|
||||
assertThat(runner.getLabel()).isEqualTo("Runner");
|
||||
assertThat(runner.getWidgets()).containsExactly(SqlWorkbenchWidgetMetaDataProducer.NAME);
|
||||
assertThat(runner.getChildren()).isNullOrEmpty();
|
||||
assertThat(runner.getPermissionRules().getPermissionBaseName()).isEqualTo("sql_workbench");
|
||||
assertThat(app.getChildren()).noneMatch(child -> child instanceof QProcessMetaData);
|
||||
|
||||
assertThat(QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences()).isNotEmpty();
|
||||
assertThat(SqlWorkbenchRuntime.get(qInstance)).isNotNull();
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.developer;
|
||||
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.developer.DeveloperPanelsAction;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlockType;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelLink;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit test for {@link SqlWorkbenchDeveloperPanelProvider} and
|
||||
** {@link RecordSql}.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchDeveloperPanelProviderTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private static final String TABLE_NAME = "orderLine";
|
||||
private static final String SKU = "AB'C-1";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table whose backend names differ from its QQQ names, with a string
|
||||
** primary key, plus a table on a non-RDBMS backend.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
qInstance.addTable(new QTableMetaData()
|
||||
.withName(TABLE_NAME)
|
||||
.withBackendName(BACKEND_NAME)
|
||||
.withPrimaryKeyField("sku")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("order_line"))
|
||||
.withField(new QFieldMetaData("sku", QFieldType.STRING).withBackendName("line_sku"))
|
||||
.withField(new QFieldMetaData("quantity", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("unitPrice", QFieldType.DECIMAL).withBackendName("unit_price")));
|
||||
|
||||
qInstance.addBackend(new QBackendMetaData().withName("memory"));
|
||||
qInstance.addTable(new QTableMetaData()
|
||||
.withName("memoryThing")
|
||||
.withBackendName("memory")
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
|
||||
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP TABLE IF EXISTS order_line");
|
||||
statement.execute("CREATE TABLE order_line (line_sku VARCHAR(40) PRIMARY KEY, quantity INT, unit_price DECIMAL(10,2))");
|
||||
statement.execute("INSERT INTO order_line VALUES ('AB''C-1', 3, 9.50), ('other', 1, 1.00)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private DeveloperPanelContext context(String tableName, Object primaryKey)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
return (new DeveloperPanelContext().withTable(table).withRecord(new QRecord().withValue(table.getPrimaryKeyField(), (java.io.Serializable) primaryKey)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The producer registers the provider; the panel shows through the
|
||||
** framework action with the record and table queries, backend details,
|
||||
** links and the note.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPanel() throws Exception
|
||||
{
|
||||
assertThat(DeveloperPanelsMetaData.of(qInstance).getProviders())
|
||||
.anyMatch(reference -> SqlWorkbenchDeveloperPanelProvider.class.getName().equals(reference.getName()));
|
||||
|
||||
DeveloperPanel panel = new DeveloperPanelsAction().execute(context(TABLE_NAME, SKU)).stream()
|
||||
.filter(p -> SqlWorkbenchDeveloperPanelProvider.ID.equals(p.getId())).findFirst().orElseThrow();
|
||||
assertNull(panel.getError());
|
||||
assertEquals("SQL Workbench", panel.getTitle());
|
||||
assertEquals(450, panel.getOrder());
|
||||
assertEquals("terminal", panel.getIcon());
|
||||
|
||||
List<DeveloperPanelBlock> code = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.CODE).toList();
|
||||
assertEquals(2, code.size());
|
||||
assertEquals("sql", code.get(0).getLanguage());
|
||||
assertEquals("""
|
||||
SELECT
|
||||
"line_sku" AS "sku",
|
||||
"quantity",
|
||||
"unit_price" AS "unitPrice"
|
||||
FROM "order_line"
|
||||
WHERE "line_sku" = 'AB''C-1'""", code.get(0).getCode());
|
||||
assertEquals("""
|
||||
SELECT
|
||||
"line_sku" AS "sku",
|
||||
"quantity",
|
||||
"unit_price" AS "unitPrice"
|
||||
FROM "order_line"
|
||||
LIMIT 100""", code.get(1).getCode());
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// the generated SQL runs on the backend and finds the row //
|
||||
////////////////////////////////////////////////////////////
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
|
||||
{
|
||||
try(ResultSet resultSet = statement.executeQuery(code.get(0).getCode()))
|
||||
{
|
||||
assertTrue(resultSet.next());
|
||||
assertEquals(SKU, resultSet.getString("sku"));
|
||||
assertEquals(3, resultSet.getInt("quantity"));
|
||||
assertFalse(resultSet.next());
|
||||
}
|
||||
try(ResultSet resultSet = statement.executeQuery(code.get(1).getCode()))
|
||||
{
|
||||
int rows = 0;
|
||||
while(resultSet.next())
|
||||
{
|
||||
rows++;
|
||||
}
|
||||
assertEquals(2, rows);
|
||||
}
|
||||
}
|
||||
|
||||
DeveloperPanelBlock keyValues = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.KEY_VALUES).findFirst().orElseThrow();
|
||||
assertEquals(BACKEND_NAME, keyValues.getItems().get(0).getValue());
|
||||
assertEquals("h2", keyValues.getItems().get(1).getValue());
|
||||
assertEquals("order_line", keyValues.getItems().get(2).getValue());
|
||||
assertEquals("No", keyValues.getItems().get(3).getValue());
|
||||
|
||||
DeveloperPanelBlock links = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.LINKS).findFirst().orElseThrow();
|
||||
assertEquals(2, links.getLinks().size());
|
||||
DeveloperPanelLink recordLink = links.getLinks().get(0);
|
||||
assertThat(recordLink.getHref()).startsWith("/app/sqlWorkbenchRunner?sql=").endsWith("&backend=h2").doesNotContain(" ", "+", "'", "\n");
|
||||
String sqlParam = recordLink.getHref().substring("/app/sqlWorkbenchRunner?sql=".length(), recordLink.getHref().indexOf("&backend="));
|
||||
assertEquals(code.get(0).getCode(), URLDecoder.decode(sqlParam, StandardCharsets.UTF_8));
|
||||
assertThat(links.getLinks().get(1).getHref()).contains("LIMIT%20100");
|
||||
|
||||
assertTrue(panel.getBlocks().stream().anyMatch(b -> b.getType() == DeveloperPanelBlockType.TEXT && b.getText().contains("runs only what you execute")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A prefixed workbench links to its prefixed Runner; the replica is reported.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPrefixAndReplica() throws Exception
|
||||
{
|
||||
config.setTableNamePrefix("ops");
|
||||
qInstance.getApp("sqlWorkbench").setName("ops_sqlWorkbench");
|
||||
qInstance.getApps().put("ops_sqlWorkbench", qInstance.getApps().remove("sqlWorkbench"));
|
||||
config.setUseBackendReadOnlyReplica(true);
|
||||
((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)).setReadOnlyBackendMetaData(defineH2Backend("h2Replica"));
|
||||
|
||||
DeveloperPanel panel = new SqlWorkbenchDeveloperPanelProvider().build(context("person", 7));
|
||||
String href = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.LINKS).findFirst().orElseThrow().getLinks().get(0).getHref();
|
||||
assertThat(href).startsWith("/app/ops_sqlWorkbenchRunner?sql=");
|
||||
assertThat(panel.getBlocks().get(0).getCode()).endsWith("WHERE \"id\" = 7");
|
||||
DeveloperPanelBlock keyValues = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.KEY_VALUES).findFirst().orElseThrow();
|
||||
assertEquals("Yes (h2Replica)", keyValues.getItems().get(3).getValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writers get the same (SELECT-only) panel.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWriterSameQueries() throws Exception
|
||||
{
|
||||
String readerSql = new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode();
|
||||
loginAs(WRITER);
|
||||
assertEquals(readerSql, new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not available without the access permission, for a non-RDBMS backend,
|
||||
** for an excluded or not-allowed backend, or without the app.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotAvailable()
|
||||
{
|
||||
SqlWorkbenchDeveloperPanelProvider provider = new SqlWorkbenchDeveloperPanelProvider();
|
||||
assertTrue(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
|
||||
loginAs("nobody-1");
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
assertTrue(new DeveloperPanelsAction().execute(context(TABLE_NAME, SKU)).stream().noneMatch(p -> SqlWorkbenchDeveloperPanelProvider.ID.equals(p.getId())));
|
||||
|
||||
loginAs(READER);
|
||||
assertFalse(provider.isAvailable(context("memoryThing", 1)));
|
||||
|
||||
config.setExcludedBackendNames(List.of(BACKEND_NAME));
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
|
||||
config.setExcludedBackendNames(null);
|
||||
config.setAllowedBackendNames(List.of("someOtherBackend"));
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
|
||||
config.setAllowedBackendNames(null);
|
||||
qInstance.getApps().remove("sqlWorkbench");
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier quoting and literals per dialect.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testQuotingAndLiterals()
|
||||
{
|
||||
assertEquals("`my``col`", RecordSql.quoteIdentifier(SqlDialect.MYSQL, "my`col"));
|
||||
assertEquals("\"my\"\"col\"", RecordSql.quoteIdentifier(SqlDialect.POSTGRES, "my\"col"));
|
||||
assertEquals("'a''b\\c'", RecordSql.stringLiteral(SqlDialect.POSTGRES, "a'b\\c"));
|
||||
assertEquals("'a''b\\\\c'", RecordSql.stringLiteral(SqlDialect.MYSQL, "a'b\\c"));
|
||||
assertEquals("42", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, 42));
|
||||
assertEquals("12.50", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.DECIMAL, new java.math.BigDecimal("12.50")));
|
||||
assertEquals("'1; DROP TABLE x'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, "1; DROP TABLE x"));
|
||||
assertEquals("'42'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, "42"));
|
||||
assertEquals("NULL", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, null));
|
||||
assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.GENERIC)).endsWith("FETCH FIRST 100 ROWS ONLY");
|
||||
assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.MYSQL)).contains("`first_name` AS `firstName`").endsWith("LIMIT 100");
|
||||
assertEquals("%27a%20b%27%26c%3D1", SqlWorkbenchDeveloperPanelProvider.encode("'a b'&c=1"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.home;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home dashboard widgets, rendered through RenderWidgetAction in each
|
||||
** viewer's session: real data, record locks (own runs; admins see all),
|
||||
** permission gates, and honest empty states.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchHomeTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private Integer orders;
|
||||
private Integer scratch;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** writer-1 owns "Orders report" (shared with reader-1) and "Private
|
||||
** scratch"; runs by writer-1 (read, write, failure) and reader-1 (one in
|
||||
** the window, one 40 days ago).
|
||||
*******************************************************************************/
|
||||
private void seed() throws Exception
|
||||
{
|
||||
loginAs(WRITER);
|
||||
orders = insert(SqlWorkbenchNotebook.TABLE_NAME, Map.of("title", "Orders report", "description", "Daily orders\nsecond line")).getValueInteger("id");
|
||||
scratch = insert(SqlWorkbenchNotebook.TABLE_NAME, Map.of("title", "Private scratch")).getValueInteger("id");
|
||||
insert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", orders, "audienceType", "USER", "audienceId", READER));
|
||||
|
||||
Instant now = Instant.now();
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
run(WRITER, "READ_ONLY", "SUCCEEDED", "SELECT * FROM orders WHERE total > 100", orders, 1500, now.minus(Duration.ofMinutes(5)), null, null);
|
||||
run(WRITER, "WRITE_COMMIT", "SUCCEEDED", "UPDATE person SET first_name = 'x'", null, 40, now.minus(Duration.ofMinutes(10)), 3, null);
|
||||
run(WRITER, "READ_ONLY", "FAILED", "SELEC broken", scratch, 5, now.minus(Duration.ofDays(3)), null, "syntax error at <x>");
|
||||
run(READER, "READ_ONLY", "SUCCEEDED", "SELECT count(*) FROM orders", orders, 50, now.minus(Duration.ofDays(1)), null, null);
|
||||
run(READER, "READ_ONLY", "SUCCEEDED", "SELECT 'old'", orders, 50, now.minus(Duration.ofDays(40)), null, null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert as the session.
|
||||
*******************************************************************************/
|
||||
private static QRecord insert(String table, Map<String, Serializable> values) throws QException
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
values.forEach(record::setValue);
|
||||
record = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords().get(0);
|
||||
assertThat(record.getErrors()).isNullOrEmpty();
|
||||
return (record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One audit row (as the system user: the audit is append-only).
|
||||
*******************************************************************************/
|
||||
private static void run(String user, String mode, String status, String sql, Integer notebookId, int durationMs, Instant start, Integer affected, String error) throws QException
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put("executionUuid", UUID.randomUUID().toString());
|
||||
values.put("userId", user);
|
||||
values.put("userName", "Name of " + user);
|
||||
values.put("backendName", BACKEND_NAME);
|
||||
values.put("mode", mode);
|
||||
values.put("status", status);
|
||||
values.put("sqlText", sql);
|
||||
values.put("notebookId", notebookId);
|
||||
values.put("durationMs", durationMs);
|
||||
values.put("startTime", start);
|
||||
values.put("endTime", start.plusMillis(durationMs));
|
||||
values.put("rowsReturned", affected == null ? 10 : null);
|
||||
values.put("rowsAffected", affected);
|
||||
values.put("errorMessage", error);
|
||||
values.put("createDate", start);
|
||||
insert(SqlWorkbenchExecution.TABLE_NAME, values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render a widget through the core action (widget permission checked).
|
||||
*******************************************************************************/
|
||||
private QWidgetData render(String widgetName, Map<String, String> params) throws QException
|
||||
{
|
||||
QWidgetMetaData widget = (QWidgetMetaData) qInstance.getWidget(widgetName);
|
||||
return (new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(widget).withUrlParams(new HashMap<>(params))).getWidgetData());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private QWidgetData render(String widgetName) throws QException
|
||||
{
|
||||
return (render(widgetName, Map.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All the HTML of a table widget's cells (and its footer).
|
||||
*******************************************************************************/
|
||||
private static String cells(QWidgetData data)
|
||||
{
|
||||
StringBuilder html = new StringBuilder();
|
||||
for(Map<String, Object> row : ((TableData) data).getRows())
|
||||
{
|
||||
row.values().forEach(v -> html.append(v).append('|'));
|
||||
html.append('\n');
|
||||
}
|
||||
return (html + String.valueOf(data.getFooterHTML()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSummaryForOwnerAndAdmin() throws Exception
|
||||
{
|
||||
seed();
|
||||
|
||||
loginAs(WRITER);
|
||||
String html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("Notebooks", ">2<", "2 yours, 0 shared with you", "Runs, 7 Days", "1 failed",
|
||||
"Writes, 30 Days", "3 rows affected", "/app/sqlWorkbenchRunner", "Open the Runner", "your own runs");
|
||||
|
||||
loginAs(READER);
|
||||
html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("0 yours, 1 shared with you", "0 failed", "0 rows affected");
|
||||
|
||||
loginAs(ADMIN);
|
||||
html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("0 yours, 2 by other users", "workbench admin", "3 rows affected");
|
||||
|
||||
config.setWriteModeEnabled(false);
|
||||
html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("write mode is off");
|
||||
assertThat(cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES))).contains("Write mode is off");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunsAreLockedToTheirUserExceptForAdmins() throws Exception
|
||||
{
|
||||
seed();
|
||||
|
||||
loginAs(WRITER);
|
||||
String runs = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS));
|
||||
assertThat(runs).contains("UPDATE person", "SELEC broken", "Orders report", "Private scratch", "you", "3 rows affected", "10 rows")
|
||||
.doesNotContain("count(*)");
|
||||
|
||||
loginAs(READER);
|
||||
runs = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS));
|
||||
assertThat(runs).contains("SELECT count(*) FROM orders", "SELECT 'old'", "Orders report").doesNotContain("UPDATE person");
|
||||
|
||||
loginAs(ADMIN);
|
||||
runs = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS));
|
||||
assertThat(runs).contains("UPDATE person", "count(*)", "Name of writer-1", "Name of reader-1");
|
||||
|
||||
TableData byUser = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER);
|
||||
assertThat(byUser.getRows()).hasSize(2);
|
||||
assertThat(cells(byUser)).contains("Name of writer-1", "writer-1", "Name of reader-1", "last run").doesNotContain("Only your own");
|
||||
assertThat(byUser.getColumns()).hasSize(4);
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// activity by user is admin-only (the widget's permission) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER)).isTrue();
|
||||
loginAs(WRITER);
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER)).isFalse();
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).isTrue();
|
||||
SqlWorkbenchHome.Viewer viewer = SqlWorkbenchHome.viewer(config, Instant.now());
|
||||
String own = cells(SqlWorkbenchHome.load(SqlWorkbenchHome.KIND_BY_USER, viewer, new RenderWidgetInput()));
|
||||
assertThat(own).contains("Only your own runs").doesNotContain("reader-1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSlowestFailedWritesAndChart() throws Exception
|
||||
{
|
||||
seed();
|
||||
loginAs(WRITER);
|
||||
|
||||
TableData slowest = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SLOWEST);
|
||||
assertThat(slowest.getRows()).hasSize(3);
|
||||
assertThat(String.valueOf(slowest.getRows().get(0).get("duration"))).contains("1.5 s");
|
||||
assertThat(slowest.getLinkURL()).isEqualTo("/app/sql_workbench_execution");
|
||||
|
||||
String failed = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED));
|
||||
assertThat(failed).contains("syntax error at <x>", "failed", "/app/sql_workbench_execution/").doesNotContain("UPDATE");
|
||||
|
||||
String writes = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES));
|
||||
assertThat(writes).contains("UPDATE person", "<strong>3</strong>", "Write mode is on").doesNotContain("SELECT");
|
||||
|
||||
ChartData chart = (ChartData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RUNS_PER_DAY);
|
||||
assertThat(chart.getChartData().getLabels()).hasSize(30);
|
||||
assertThat(chart.getChartData().getDatasets()).extracting(ChartData.Data.Dataset::getLabel).containsExactly("Runs", "Failed", "Writes");
|
||||
assertThat(chart.getChartData().getDatasets().get(0).getData().stream().mapToLong(Number::longValue).sum()).isEqualTo(3);
|
||||
assertThat(chart.getChartData().getDatasets().get(1).getData().stream().mapToLong(Number::longValue).sum()).isEqualTo(1);
|
||||
assertThat(chart.getChartData().getDatasets().get(2).getData().stream().mapToLong(Number::longValue).sum()).isEqualTo(1);
|
||||
assertThat(chart.getSublabel()).isEqualTo("3 runs in the last 30 days, by day");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookWidgets() throws Exception
|
||||
{
|
||||
seed();
|
||||
|
||||
loginAs(READER);
|
||||
TableData recent = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_NOTEBOOKS);
|
||||
assertThat(recent.getRows()).hasSize(1);
|
||||
assertThat(cells(recent)).contains("Orders report", "Daily orders", "Name of writer-1", "shared", "/app/sqlWorkbenchRunner?notebook=" + orders)
|
||||
.doesNotContain("second line", "Private scratch");
|
||||
|
||||
String shared = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.SHARED));
|
||||
assertThat(shared).contains("Orders report", "reader-1", "Name of writer-1");
|
||||
|
||||
loginAs(ADMIN);
|
||||
TableData mostUsed = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED);
|
||||
assertThat(mostUsed.getRows()).hasSize(2);
|
||||
assertThat(String.valueOf(mostUsed.getRows().get(0).get("notebook"))).contains("Orders report");
|
||||
assertThat(mostUsed.getRows().get(0).get("runs")).isEqualTo("2");
|
||||
assertThat(mostUsed.getRows().get(0).get("users")).isEqualTo("2");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// reader-1's own runs on a notebook they can't read: labeled //
|
||||
// honestly, not hidden //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
SystemUser.run(() -> run(READER, "READ_ONLY", "SUCCEEDED", "SELECT 1", scratch, 5, Instant.now(), null, null));
|
||||
loginAs(READER);
|
||||
assertThat(cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED))).contains("Notebook #" + scratch + " (deleted, or not visible to you)");
|
||||
|
||||
QWidgetData runner = render(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER, Map.of("id", String.valueOf(orders)));
|
||||
assertThat(((RawHTML) runner).getHtml()).contains("/app/sqlWorkbenchRunner?notebook=" + orders, "Open in Runner");
|
||||
assertThat(((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER)).getHtml()).contains("Save the notebook");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEmptyStatesAndPermissions() throws Exception
|
||||
{
|
||||
loginAs(READER);
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_NOTEBOOKS)).getNoRowsFoundHTML()).contains("No notebooks yet");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS)).getNoRowsFoundHTML()).contains("No runs yet");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES)).getNoRowsFoundHTML()).contains("No writes have been committed");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SHARED)).getNoRowsFoundHTML()).contains("No shared notebooks");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED)).getNoRowsFoundHTML()).contains("No notebook runs");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED)).getNoRowsFoundHTML()).contains("No failed runs");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SLOWEST)).getNoRowsFoundHTML()).contains("No completed runs");
|
||||
assertThat(((ChartData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RUNS_PER_DAY)).getSublabel()).isEqualTo("No runs in the last 30 days");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// no workbench access: every widget is denied (the middleware checks //
|
||||
// widget permissions); rendered anyway, the locks show nothing //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
loginAs("nobody-1");
|
||||
for(String name : SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames())
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
|
||||
}
|
||||
assertThat(((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml()).contains("can't read");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// workbench access but no table read permissions: says so, no zeros //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference("limited-1"));
|
||||
session.setPermissions(new HashSet<>(List.of(config.getAccessPermissionName())));
|
||||
QContext.init(qInstance, session);
|
||||
assertThat(((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml()).contains("can't read");
|
||||
List<String> denied = new ArrayList<>();
|
||||
for(String name : List.of(SqlWorkbenchHomeWidgetsMetaDataProducer.RUNS_PER_DAY, SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_NOTEBOOKS,
|
||||
SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS, SqlWorkbenchHomeWidgetsMetaDataProducer.SLOWEST, SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED,
|
||||
SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED, SqlWorkbenchHomeWidgetsMetaDataProducer.SHARED, SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES))
|
||||
{
|
||||
denied.add(((RawHTML) render(name)).getHtml());
|
||||
}
|
||||
assertThat(denied).allSatisfy(html -> assertThat(html).contains("You don't have permission to read"));
|
||||
|
||||
SqlWorkbenchHome.Viewer viewer = new SqlWorkbenchHome.Viewer(config, Instant.now(), ZoneId.of("UTC"), "x", false);
|
||||
assertThat(((RawHTML) SqlWorkbenchHome.load("nope", viewer, new RenderWidgetInput())).getHtml()).contains("Unknown");
|
||||
assertThat(((RawHTML) SqlWorkbenchHome.load(null, viewer, new RenderWidgetInput())).getHtml()).contains("Unknown");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testLabelsAndHtml()
|
||||
{
|
||||
assertThat(SqlWorkbenchHome.modeLabel("WRITE_PREVIEW")).isEqualTo("write preview");
|
||||
assertThat(SqlWorkbenchHome.modeLabel("EXPLAIN")).isEqualTo("explain");
|
||||
assertThat(SqlWorkbenchHome.modeLabel("EXPLAIN_ANALYZE")).isEqualTo("explain analyze");
|
||||
assertThat(SqlWorkbenchHome.modeLabel("SOMETHING_ELSE")).isEqualTo("something else");
|
||||
assertThat(SqlWorkbenchHome.modeLabel(null)).isEmpty();
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord())).isEmpty();
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord().withValue("status", "RUNNING"))).contains("running", HomeHtml.INFO_COLOR);
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord().withValue("status", "CANCELLED"))).contains(HomeHtml.OFF_COLOR);
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord().withValue("status", "TIMED_OUT"))).contains("timed out", HomeHtml.BAD_COLOR);
|
||||
assertThat(SqlWorkbenchHome.firstLine(null, 5)).isNull();
|
||||
assertThat(SqlWorkbenchHome.firstLine("abcdefgh", 5)).isEqualTo("abcd…");
|
||||
|
||||
assertThat(HomeHtml.escape(null)).isEmpty();
|
||||
assertThat(HomeHtml.escape("<a href='x'>&\"")).isEqualTo("<a href='x'>&"");
|
||||
assertThat(HomeHtml.code("SELECT *\n FROM t", 100)).contains(">SELECT * FROM t<");
|
||||
assertThat(HomeHtml.code(null, 10)).contains("><");
|
||||
assertThat(HomeHtml.code("x".repeat(3000), 10)).contains("xxxxxxxxx…");
|
||||
assertThat(HomeHtml.duration(null)).isEmpty();
|
||||
assertThat(HomeHtml.duration(850)).isEqualTo("850 ms");
|
||||
assertThat(HomeHtml.duration(4200)).isEqualTo("4.2 s");
|
||||
assertThat(HomeHtml.duration(185_000)).isEqualTo("3m 05s");
|
||||
assertThat(HomeHtml.plural(1, "run")).isEqualTo("1 run");
|
||||
assertThat(HomeHtml.card("a", null, " ")).doesNotContain("·");
|
||||
|
||||
ZoneId utc = ZoneId.of("UTC");
|
||||
Instant now = Instant.parse("2026-10-12T15:00:00Z");
|
||||
assertThat(HomeHtml.when(null, now, utc)).isEmpty();
|
||||
assertThat(HomeHtml.when(Instant.parse("2026-10-12T09:05:00Z"), now, utc)).isEqualTo("9:05 AM");
|
||||
assertThat(HomeHtml.when(Instant.parse("2026-03-01T09:05:00Z"), now, utc)).isEqualTo("Mar 1, 9:05 AM");
|
||||
assertThat(HomeHtml.when(Instant.parse("2025-03-01T09:05:00Z"), now, utc)).isEqualTo("Mar 1, 2025, 9:05 AM");
|
||||
assertThat(HomeHtml.tiles(List.of(new HomeHtml.Tile("A", "1", null, null)))).contains(">1<").doesNotContain("href");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchIntegrations;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The app's navigation (Runner plus the main tables only), the sub-tables as
|
||||
** child lists on a notebook's record view (still standard tables), and the
|
||||
** ESB / Quick Search hooks.
|
||||
*******************************************************************************/
|
||||
class AppNavigationTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private static final List<String> SUB_TABLES = List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNavListsTheRunnerAndMainTablesOnly()
|
||||
{
|
||||
QAppMetaData app = qInstance.getApp(SqlWorkbenchAppMetaDataProducer.APP_NAME);
|
||||
assertThat(app.getChildren()).extracting(QAppChildMetaData::getName)
|
||||
.containsExactly(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME, SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// no app anywhere lists a sub-table, yet each is a standard table //
|
||||
// with a record label (reachable by URL, the table API and search) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
for(QAppMetaData any : qInstance.getApps().values())
|
||||
{
|
||||
assertThat(any.getChildren() == null ? List.<QAppChildMetaData>of() : any.getChildren()).extracting(QAppChildMetaData::getName)
|
||||
.as(any.getName()).doesNotContainAnyElementsOf(SUB_TABLES);
|
||||
}
|
||||
for(String subTable : SUB_TABLES)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(subTable);
|
||||
assertThat(table).as(subTable).isNotNull();
|
||||
assertThat(table.getRecordLabelFields()).as(subTable).isNotEmpty();
|
||||
assertThat(table.getField("notebookId").getPossibleValueSourceName()).isEqualTo(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookRecordViewShowsTheSubTablesAndRuns()
|
||||
{
|
||||
QTableMetaData notebook = qInstance.getTable(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
List<String> widgetSections = notebook.getSections().stream().map(QFieldSection::getWidgetName).filter(Objects::nonNull).toList();
|
||||
assertThat(widgetSections).containsExactly(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER,
|
||||
SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_CELL, SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_SHARE, SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_SNAPSHOT,
|
||||
SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_VERSION, SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION);
|
||||
|
||||
for(String joinName : widgetSections.subList(1, widgetSections.size()))
|
||||
{
|
||||
QWidgetMetaDataInterface widget = qInstance.getWidget(joinName);
|
||||
assertThat(widget.getType()).as(joinName).isEqualTo(WidgetType.CHILD_RECORD_LIST.getType());
|
||||
QJoinMetaData join = qInstance.getJoin(joinName);
|
||||
assertThat(join.getLeftTable()).isEqualTo(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
assertThat(join.getType()).isEqualTo(JoinType.ONE_TO_MANY);
|
||||
}
|
||||
assertThat(qInstance.getJoin(SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION).getRightTable()).isEqualTo(SqlWorkbenchExecution.TABLE_NAME);
|
||||
assertThat(qInstance.getWidget(SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION).getLabel()).isEqualTo("Runs");
|
||||
assertThat(notebook.getSections().stream().filter(s -> s.getWidgetName() != null).map(QFieldSection::getLabel).toList())
|
||||
.containsExactly("Runner", "Cells", "Shared With", "Pinned Results", "Versions", "Runs");
|
||||
assertThat(notebook.getSections().get(0).getFieldNames()).containsExactly("id", "title", "description", "ownerName", "visibility");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runs child list goes through the execution audit's lock: the
|
||||
** viewer's own runs of the notebook, every user's for an admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunsChildListIsLocked() throws Exception
|
||||
{
|
||||
loginAs(WRITER);
|
||||
QRecord notebook = new InsertAction().execute(new InsertInput(SqlWorkbenchNotebook.TABLE_NAME).withRecord(new QRecord().withValue("title", "Shared one"))).getRecords().get(0);
|
||||
Integer id = notebook.getValueInteger("id");
|
||||
new InsertAction().execute(new InsertInput(SqlWorkbenchNotebookShare.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("notebookId", id).withValue("audienceType", "USER").withValue("audienceId", READER)));
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
audit(WRITER, id);
|
||||
audit(READER, id);
|
||||
audit(READER, id);
|
||||
});
|
||||
|
||||
assertThat(runs(id)).hasSize(1);
|
||||
loginAs(READER);
|
||||
assertThat(runs(id)).hasSize(2).allSatisfy(r -> assertThat(r.getValueString("userId")).isEqualTo(READER));
|
||||
loginAs(ADMIN);
|
||||
assertThat(runs(id)).hasSize(3);
|
||||
assertThat(runs(id).get(0).getValueInteger("id")).isGreaterThan(runs(id).get(2).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testIntegrationHooks()
|
||||
{
|
||||
assertThat(SqlWorkbenchIntegrations.mainTableNames(config)).containsExactly(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
assertThat(SqlWorkbenchIntegrations.esbTableNames(config)).isEqualTo(SqlWorkbenchIntegrations.mainTableNames(config));
|
||||
|
||||
List<SqlWorkbenchIntegrations.SearchableTable> searchable = SqlWorkbenchIntegrations.quickSearchTables(config);
|
||||
assertThat(searchable).extracting(SqlWorkbenchIntegrations.SearchableTable::tableName).containsExactlyElementsOf(SqlWorkbenchIntegrations.mainTableNames(config));
|
||||
for(SqlWorkbenchIntegrations.SearchableTable table : searchable)
|
||||
{
|
||||
QTableMetaData tableMetaData = qInstance.getTable(table.tableName());
|
||||
assertThat(table.fieldWeights().keySet()).allSatisfy(field -> assertThat(tableMetaData.getFields()).containsKey(field));
|
||||
assertThat(tableMetaData.getFields()).containsKey(table.basepullTimestamp());
|
||||
assertThat(table.recordLabelFields()).allSatisfy(field -> assertThat(tableMetaData.getFields()).containsKey(field));
|
||||
}
|
||||
assertThat(searchable.get(0).fieldWeights()).containsEntry("title", 10).containsKey("description").containsKey("ownerName");
|
||||
assertThat(searchable.get(1).fieldWeights()).containsKey("sqlText").containsKey("userName").doesNotContainKey("parametersJson");
|
||||
|
||||
///////////////////////////////////////
|
||||
// the hooks follow the table prefix //
|
||||
///////////////////////////////////////
|
||||
SqlWorkbenchQBitConfig prefixed = new SqlWorkbenchQBitConfig().withTableNamePrefix("myNs");
|
||||
assertThat(SqlWorkbenchIntegrations.esbTableNames(prefixed)).containsExactly("my_ns_sql_workbench_notebook", "my_ns_sql_workbench_execution");
|
||||
assertThat(SqlWorkbenchIntegrations.quickSearchTables(prefixed).get(1).tableName()).isEqualTo("my_ns_sql_workbench_execution");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notebook's Runs child list as the session sees it.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> runs(Integer notebookId) throws QException
|
||||
{
|
||||
QWidgetMetaDataInterface widget = qInstance.getWidget(SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION);
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("id", String.valueOf(notebookId));
|
||||
ChildRecordListData data = (ChildRecordListData) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(widget).withUrlParams(params)).getWidgetData();
|
||||
return (data.getQueryOutput().getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One audit row.
|
||||
*******************************************************************************/
|
||||
private static void audit(String user, Integer notebookId) throws QException
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put("executionUuid", UUID.randomUUID().toString());
|
||||
values.put("userId", user);
|
||||
values.put("mode", "READ_ONLY");
|
||||
values.put("status", "SUCCEEDED");
|
||||
values.put("sqlText", "SELECT 1");
|
||||
values.put("notebookId", notebookId);
|
||||
values.put("startTime", Instant.now());
|
||||
QRecord record = new QRecord();
|
||||
values.forEach(record::setValue);
|
||||
new InsertAction().execute(new InsertInput(SqlWorkbenchExecution.TABLE_NAME).withRecord(record));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user