├── .dir-locals.el ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── cypress ├── cypress.config.js ├── cypress.sh ├── cypress │ ├── e2e │ │ ├── doc.cy.js │ │ ├── query.cy.js │ │ └── search.cy.js │ └── support │ │ ├── commands.js │ │ └── e2e.js ├── package-lock.json └── package.json ├── demo ├── demo-doc.gif └── demo-query.gif ├── deps.edn ├── dev-src ├── sales.clj ├── testdata │ └── people.edn └── user.clj ├── package.json ├── resources ├── public │ └── xtdb-inspector.css └── xtdb-inspector.css ├── src └── xtdb_inspector │ ├── core.clj │ ├── id.clj │ ├── metrics.clj │ ├── page.clj │ ├── page │ ├── attr.clj │ ├── dashboard.clj │ ├── doc.clj │ ├── query.clj │ └── tx.clj │ ├── ui.clj │ ├── ui │ ├── chart.clj │ ├── edn.clj │ ├── table.clj │ └── tabs.clj │ └── util.clj └── tailwind.config.js /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil 2 | (cider-clojure-cli-global-options . "-A:dev"))) 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: DeLaGuardo/setup-clojure@9.5 9 | with: 10 | cli: 1.11.1.1165 11 | - run: ./cypress/cypress.sh 12 | shell: bash 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cpcache/ 2 | /.nrepl-port 3 | /node_modules/ 4 | /resources/public/styles.css 5 | /package-lock.json 6 | /cypress/node_modules/ 7 | /.clj-kondo/ 8 | /.lsp/ 9 | /cypress/cypress/screenshots/ 10 | /cypress/cypress/videos/ 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tatu Tarvainen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xtdb-inspector 2 | 3 | ![test workflow](https://github.com/tatut/xtdb-inspector/actions/workflows/test.yml/badge.svg) 4 | 5 | Web UI for inspecting XTDB database 6 | 7 | Demo: 8 | 9 | Browsing documents and showing history: 10 | 11 | ![Demo screen recording of document browsing](demo/demo-doc.gif) 12 | 13 | Query viewer and saved queries: 14 | 15 | ![Demo screen recording of queries](demo/demo-query.gif) 16 | 17 | ## Running 18 | 19 | To simply try it out, start repl with `:dev` alias: 20 | 21 | - eval `(start)` in your REPL 22 | - eval `(some-docs)` in your REPL to generate test docs 23 | - open [http://localhost:3000/doc/%3Ahello](http://localhost:3000/doc/%3Ahello) in browser 24 | 25 | Other way is to embed in an existing web application that 26 | uses XTDB. The `xtdb-inspector.core/inspector-handler` returns 27 | a ring handler. See monitoring below for config that needs to 28 | be added to the XTDB node. 29 | 30 | ## Monitoring 31 | 32 | To enable the UI access to the XTDB monitoring, configure the 33 | monitoring reporter in the XTDB node config: 34 | 35 | ``` 36 | :xtdb-inspector.metrics/reporter {} 37 | ``` 38 | 39 | ## Custom URI prefix 40 | 41 | Sometime (e.g., when embedding `xtdb-inspector` in another web application) there is a need for having all of its URLs prefixed. Simply set the environment variable `XTDB_INSPECTOR_URI_PREFIX`: 42 | 43 | - run `export XTDB_INSPECTOR_URI_PREFIX=/_inspector` in your shell 44 | - eval `(start)` in your REPL 45 | - eval `(some-docs)` in your REPL 46 | - open [http://localhost:3000/_inspector/doc/%3Ahello](http://localhost:3000/_inspector/doc/%3Ahello) in a browser. 47 | 48 | 49 | ## Changes 50 | 51 | ### 2023-01-25 52 | - Support for `:in` arguments in queries 53 | 54 | ### 2022-11-23 55 | - Format metrics numbers with NumberFormat 56 | 57 | ### 2022-10-25 58 | - Support custom URI prefix 59 | 60 | ### 2022-10-19 61 | - Fix nested pull problem in query page 62 | 63 | ### 2022-10-18 64 | - Support exporting query to EDN file 65 | 66 | ### 2022-10-17 67 | - Add `:allow-editing?` configuration option (default: `true`) for disabling doc edit functionality 68 | - Support `:wrap-page-fn` that wraps any page rendering with a custom fn 69 | 70 | ### 2022-08-10 71 | - Show latest transaction first by default 72 | 73 | ### 2022-07 74 | - Dashboard page 75 | - Graphs for attribute values 76 | 77 | ### 2022-06-29 78 | - Pretty print EDN values (better coloring and layout) 79 | 80 | ### 2022-06-27 81 | - Add new page for transaction log 82 | - Document view can expand linked docs inline 83 | 84 | ### 2022-06-21 85 | - FIXED Attribute name in URL is encoded, so attributes with URL characters (like ?) work 86 | - Add convenience button to document page to copy the doc id to clipboard 87 | 88 | ### 2022-06-17 89 | - Tables now have filtering and ordering 90 | - Attribute values page limits to 100 items (with button to fetch more) 91 | - Remember last run query in editor page 92 | 93 | ### 2022-04-13 94 | - Add `java.time.Instant` display and editing 95 | - Allow adding new attributes and creating documents 96 | 97 | ### 2022-03-26 98 | - Better display end edit non-EDN values (`java.time` types) 99 | 100 | ### 2021-10-28 101 | - Add top bar navigation with search. Can query lucene index to find docs. 102 | - Directly navigate to saved query by name (`/query/`) to load it 103 | - New attribute page that shows attributes and their values 104 | - Document page can now do simple edits to the values 105 | -------------------------------------------------------------------------------- /cypress/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /cypress/cypress.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # this should be run in the parent directory 4 | cd "$(dirname "$0")" 5 | cd .. 6 | 7 | clojure -A:dev -m user & 8 | 9 | MAX_WAIT=90 10 | until $(curl --output /dev/null --silent --fail http://localhost:3000/doc); do 11 | echo 'Waiting for XTDB inspector to be up' 12 | sleep 1 13 | let "MAX_WAIT = MAX_WAIT - 1" 14 | if [ $MAX_WAIT -eq 0 ]; then 15 | echo "XTDB inspector didn't start in 30s, exiting" 16 | exit 1 17 | fi 18 | done 19 | 20 | cd cypress 21 | npm ci 22 | npx cypress run 23 | -------------------------------------------------------------------------------- /cypress/cypress/e2e/doc.cy.js: -------------------------------------------------------------------------------- 1 | describe('doc page',()=>{ 2 | it('asks for a doc', ()=>{ 3 | cy.visit('http://localhost:3000/doc'); 4 | cy.get('input#doc').type(':hello'); 5 | cy.get('button').contains('Go').click(); 6 | cy.location().should((loc)=>{ 7 | expect(loc.pathname).to.eq('/doc/%3Ahello'); 8 | }); 9 | }); 10 | 11 | it('doc shows attributes',()=>{ 12 | cy.visit('http://localhost:3000/doc/%3Ahello'); 13 | cy.get('td').contains(':greeting'); 14 | cy.get('td').contains('"hello XTDB inspector world!"'); 15 | }); 16 | 17 | it('is possible to add attribute',()=>{ 18 | cy.visit('http://localhost:3000/doc/%3Ahello'); 19 | cy.get('input[placeholder="New attr kw"]').type(':test-attr'); 20 | cy.get('select').select('EDN'); 21 | cy.get('input[placeholder="EDN"]').type('"hello cypress"').blur(); 22 | cy.wait(500); 23 | cy.get('td').contains(':test-attr'); 24 | cy.get('td').contains('"hello cypress"'); 25 | }); 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /cypress/cypress/e2e/query.cy.js: -------------------------------------------------------------------------------- 1 | describe('Query page', () => { 2 | it('simple query and results', () => { 3 | cy.visit('http://localhost:3000/query'); 4 | cy.window().then(win => win.editor.setValue('')); 5 | cy.get('.CodeMirror textarea').type(` 6 | {:find [e thing] 7 | :where [[e :things thing]]}`, {force: true}); 8 | cy.get('button').contains('Run query').click(); 9 | cy.get('td a').contains(':hello'); 10 | cy.get('td a').contains('"thing1"'); 11 | cy.get('td a').contains('"thing3"'); 12 | }); 13 | 14 | it('can save a query', () => { 15 | cy.visit('http://localhost:3000/query'); 16 | cy.window().then(win => win.editor.setValue('')); 17 | let q = `{:find [e thing] :where [[e :things thing]]}`; 18 | cy.get('.CodeMirror textarea').type(q, {force: true, parseSpecialCharSequences:false}); 19 | cy.get('#save-query-as').type('test-query'); 20 | cy.get('button').contains('Save').click(); 21 | cy.window().then(win => win.editor.setValue('')); 22 | cy.wait(500); 23 | cy.get('select').select('test-query'); 24 | cy.get('.CodeMirror textarea').should('have.value', q); 25 | }); 26 | 27 | it('has bar chart', () => { 28 | cy.visit('http://localhost:3000/query'); 29 | cy.get('select').select('job-title counts'); 30 | cy.location('search').should('match', /\?query=job-title%20counts/); 31 | cy.get('button').contains('Run query').click(); 32 | cy.wait(500); 33 | cy.get('a.tab').contains('Bar chart').click(); 34 | cy.wait(500); 35 | cy.get('svg text').contains('18 GIS Technical Architect'); 36 | }); 37 | 38 | }) 39 | -------------------------------------------------------------------------------- /cypress/cypress/e2e/search.cy.js: -------------------------------------------------------------------------------- 1 | describe('Lucene search',()=>{ 2 | it('queries after typing',()=>{ 3 | cy.visit('http://localhost:3000/doc'); 4 | cy.get('input[name=search]').type('dev*'); 5 | cy.get('table.lucene-results').find('tr').should('have.length',10); 6 | 7 | // clear the input and results will be gone 8 | cy.get('input[name=search]').clear(); 9 | cy.get('table.lucene-results').should('not.exist'); 10 | }); 11 | 12 | it('links to document',()=>{ 13 | cy.visit('http://localhost:3000/doc'); 14 | cy.get('input[name=search]').type('nathanial'); 15 | cy.wait(500); 16 | cy.get('table.lucene-results').find('tr').contains('{:person-id 1}'); 17 | cy.get('table.lucene-results tr a').click(); 18 | cy.location().should((loc)=>{ 19 | expect(loc.pathname).to.eq('/doc/_%7B%3Aperson-id%201%7D'); 20 | }); 21 | }) 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /cypress/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | import 'cypress-wait-until'; 27 | -------------------------------------------------------------------------------- /cypress/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /cypress/package.json: -------------------------------------------------------------------------------- 1 | {"name":"xtdb-inspector-cypress-tests","version":"0-development","devDependencies":{"cypress":"^10.2.0","cypress-wait-until":"^1.7.2"}} -------------------------------------------------------------------------------- /demo/demo-doc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatut/xtdb-inspector/b04e59edc7ce7ba11557be5d6cebd25ebe404efa/demo/demo-doc.gif -------------------------------------------------------------------------------- /demo/demo-query.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatut/xtdb-inspector/b04e59edc7ce7ba11557be5d6cebd25ebe404efa/demo/demo-query.gif -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {tatut/ripley {:git/url "https://github.com/tatut/ripley.git" 3 | :sha "e33818ae6f93b2000d6b31929325070176048f65"} 4 | tatut/ripley-alpine {:git/url "https://github.com/tatut/ripley-alpine.git" 5 | :sha "57d3f2cb99cd05c6579ba1d975bd0af858a64fdb"} 6 | compojure/compojure {:mvn/version "1.6.1"} 7 | http-kit/http-kit {:mvn/version "2.5.3"} 8 | com.xtdb/xtdb-core {:mvn/version "1.21.0"} 9 | com.xtdb/xtdb-metrics {:mvn/version "1.21.0"} 10 | com.xtdb/xtdb-lucene {:mvn/version "1.21.0"}} 11 | :aliases {:dev {:extra-paths ["dev-src"] 12 | :extra-deps {com.xtdb/xtdb-http-server {:mvn/version "1.21.0"} 13 | dk.ative/docjure {:mvn/version "1.17.0"}}}}} 14 | -------------------------------------------------------------------------------- /dev-src/sales.clj: -------------------------------------------------------------------------------- 1 | (ns sales 2 | "Load fictional US sales data database. 3 | This is for testing the dashboards. 4 | 5 | Download the file: https://data.world/dataman-udit/us-regional-sales-data" 6 | (:require [dk.ative.docjure.spreadsheet :as sheet] 7 | [xtdb.api :as xt])) 8 | 9 | (defn- ->id [key] 10 | (fn [id] {key (if (number? id) 11 | (long id) 12 | id)})) 13 | 14 | (def import-config 15 | {"Sales Orders Sheet" 16 | {:columns 17 | {:A :xt/id 18 | :B :order/channel 19 | :C :order/warehouse 20 | :D :order/procure-date 21 | :E :order/order-date 22 | :F :order/ship-date 23 | :G :order/delivery-date 24 | :H :order/currency 25 | :I :order/sales-team 26 | :J :order/customer 27 | :K :order/store 28 | :L :order/product 29 | :M :order/quantity 30 | :N :order/discount 31 | :O :order/unit-price 32 | :P :order/unit-cost} 33 | :process 34 | {:xt/id (->id :order) 35 | :order/product (->id :product) 36 | :order/sales-team (->id :sales-team) 37 | :order/customer (->id :customer) 38 | :order/store (->id :store)}} 39 | 40 | "Customers Sheet" 41 | {:columns 42 | {:A :xt/id 43 | :B :customer/name} 44 | :process 45 | {:xt/id (->id :customer)}} 46 | 47 | "Store Locations Sheet" 48 | {:columns 49 | {:A :xt/id 50 | :B :store/city 51 | :C :store/county 52 | :E :store/state 53 | :G :store/lat 54 | :H :store/lng} 55 | :process 56 | {:xt/id (->id :store)}} 57 | 58 | "Products Sheet" 59 | {:columns 60 | {:A :xt/id 61 | :B :product/name} 62 | :process {:xt/id (->id :product)}} 63 | 64 | "Sales Team Sheet" 65 | {:columns 66 | {:A :xt/id 67 | :B :sales-team/name} 68 | :process 69 | {:xt/id (->id :sales-team)}}}) 70 | 71 | (defn load-sales-data [xtdb-node file] 72 | (let [wb (sheet/load-workbook-from-file file)] 73 | (doseq [s (sheet/sheet-seq wb) 74 | :let [{:keys [columns process]} (import-config (sheet/sheet-name s))] 75 | :when columns] 76 | (let [data (mapv (fn [row] 77 | (reduce-kv (fn [row field process] 78 | (update row field process)) 79 | row process)) 80 | (drop 1 (sheet/select-columns columns s)))] 81 | (xt/submit-tx xtdb-node 82 | (for [d data] 83 | [::xt/put d])))))) 84 | 85 | (defn add-dashboard [xtdb-node] 86 | (xt/submit-tx 87 | xtdb-node 88 | [[::xt/put {:xt/id "sales-dashboard" 89 | :xtdb-inspector.dashboard/name "sales" 90 | :xtdb-inspector.dashboard/description "Sales dashboard" 91 | :xtdb-inspector.dashboard/config 92 | {:update-duration :manual 93 | :widgets 94 | [{:id :top-products 95 | :label "Top sellers" 96 | :col-span 2 97 | :type :pie 98 | :query '{:find [pn (sum (* qnt up))] 99 | :where [[o :order/product p] 100 | [p :product/name pn] 101 | [o :order/quantity qnt] 102 | [o :order/unit-price up]] 103 | :group-by [pn]}} 104 | {:id :top-states 105 | :type :pie 106 | :col-span 2 107 | :label "Top states" 108 | :query '{:find [state (sum (* quantity price))] 109 | :where [[o :order/quantity quantity] 110 | [o :order/unit-price price] 111 | [o :order/store store] 112 | [store :store/state state]]}} 113 | {:id :quarterly-sales 114 | :label "Quarterly sales" 115 | :type :bars 116 | :col-span 2 117 | :query '{:find [quarter (sum (* quantity price))] 118 | :where [[o :order/order-date date] 119 | [o :order/quantity quantity] 120 | [o :order/unit-price price] 121 | [(sales/quarter date) quarter]] 122 | :group-by [quarter] 123 | :order-by [[quarter :desc]]}} 124 | {:id :quarterly-sales-by-state 125 | :label "Quarterly sales by state" 126 | :type :query 127 | :col-span 2 128 | :query '{:find [state quarter (sum (* quantity price))] 129 | :where [[o :order/order-date date] 130 | [o :order/store store] 131 | [store :store/state state] 132 | [(sales/quarter date) quarter] 133 | [o :order/quantity quantity] 134 | [o :order/unit-price price]] 135 | :group-by [state quarter] 136 | :order-by [[quarter :desc] [(sum (* quantity price)) :desc]]}}]}}]])) 137 | 138 | (defn quarter [date] 139 | (let [y (+ 1900 (.getYear date)) 140 | m (inc (.getMonth date))] 141 | (format "%d Q%d" 142 | y 143 | (case m 144 | (1 2 3) 1 145 | (4 5 6) 2 146 | (7 8 9) 3 147 | (10 11 12) 4)))) 148 | 149 | (comment 150 | (load-sales-data @user/xtdb (str (System/getenv "HOME") 151 | "/Downloads/US_Regional_Sales_data.xlsx")) 152 | (add-dashboard @user/xtdb) 153 | 154 | 155 | :ok) 156 | -------------------------------------------------------------------------------- /dev-src/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require [xtdb.api :as xt] 3 | [xtdb-inspector.core :as inspector] 4 | [clojure.java.io :as io] 5 | [clojure.string :as str])) 6 | 7 | (defonce xtdb (atom nil)) 8 | (defonce server (atom nil)) 9 | 10 | (defn start 11 | ([] (start true)) 12 | ([allow-editing?] 13 | (println "Starting dev instance with in-memory XTDB in port 3000") 14 | (swap! xtdb #(or % (xt/start-node {:xtdb.lucene/lucene-store {} 15 | :xtdb.metrics/metrics {} 16 | :xtdb-inspector.metrics/reporter {}}))) 17 | (swap! server 18 | (fn [old-server] 19 | (when old-server 20 | (old-server)) 21 | (inspector/start {:xtdb-node @xtdb 22 | :port 3000 23 | :allow-editing? allow-editing?}))))) 24 | 25 | (defn some-docs [] 26 | ;; insert some docs for testing 27 | (xt/submit-tx 28 | @xtdb 29 | [[::xt/put {:xt/id :hello :greeting "hello xtdb inspector world!" 30 | :number 420.69M :link "over-there"}] 31 | [::xt/put {:xt/id "over-there" 32 | :message "you are now over here!" 33 | :number 666M 34 | :go-deeper {:foo :bar}}] 35 | [::xt/put {:xt/id {:foo :bar} 36 | :message "even deeper now" 37 | :number 3.1415M 38 | :back-to-start :hello}] 39 | [::xt/put {:xt/id "thing1" 40 | :name "The first thing"}] 41 | [::xt/put {:xt/id "thing2" 42 | :name "Another thing"}] 43 | [::xt/put {:xt/id "thing3" 44 | :name "Yet another thing here"}]]) 45 | 46 | ;; insert some changes for testing history 47 | (xt/submit-tx @xtdb [[::xt/put {:xt/id :hello 48 | :greeting "hello XTDB inspector world!" 49 | :number 420.666M 50 | :link "over-there" 51 | :new-key "this one is" 52 | :things ["thing2"]}]]) 53 | 54 | (xt/submit-tx @xtdb [[::xt/put {:xt/id :hello 55 | :greeting "hello XTDB inspector world!" 56 | :number 42M 57 | :link "over-there" 58 | :new-key "this one is" 59 | :things ["thing1" "thing3"]}]]) 60 | 61 | ;; Insert some test people data 62 | (xt/submit-tx 63 | @xtdb 64 | (for [{:keys [id first_name last_name email gender job_title address date_of_birth]} 65 | (read-string (slurp (io/resource "testdata/people.edn"))) 66 | :let [[year month day] (map #(Long/parseLong %) 67 | (str/split date_of_birth #"-"))]] 68 | [::xt/put 69 | {:xt/id {:person-id id} 70 | :first-name first_name 71 | :last-name last_name 72 | :email email 73 | :gender (-> gender str/lower-case keyword) 74 | :job-title job_title 75 | :address address 76 | :birthday (format "%02d-%02d" month day) ; to quickly fetch "today's birthdays" 77 | :date-of-birth (java.time.LocalDate/of year month day)}])) 78 | 79 | ;; Insert a saved query 80 | (xt/submit-tx 81 | @xtdb 82 | [[::xt/put {:xt/id (java.util.UUID/randomUUID) 83 | :xtdb-inspector.saved-query/name "job-title counts" 84 | :xtdb-inspector.saved-query/query 85 | ;; Stored as text so they can have formatting and 86 | ;; comments 87 | (str "{:find [?jt (count ?p)] ; title and its count \n" 88 | " :where [[?p :job-title ?jt]]\n" 89 | " ;; order most popular titles first\n" 90 | " :order-by [[(count ?p) :desc]]}")}] 91 | [::xt/put {:xt/id (java.util.UUID/randomUUID) 92 | :xtdb-inspector.saved-query/name "users with name" 93 | :xtdb-inspector.saved-query/query 94 | (str "{:find [?u (pull ?u [:first-name :last-name])]\n" 95 | " :where [[?u :first-name]]}")}] 96 | 97 | [::xt/put {:xt/id (java.util.UUID/randomUUID) 98 | :xtdb-inspector.saved-query/name "born in 2000s" 99 | :xtdb-inspector.saved-query/query 100 | (str "{:find [?u (pull ?u [:first-name :last-name :date-of-birth])]\n" 101 | " :where [[?u :first-name]\n" 102 | " [?u :date-of-birth dob]\n" 103 | " [(>= dob date)]]\n" 104 | " :in [date]}") 105 | :xtdb-inspector.saved-query/in-args {'date (java.time.LocalDate/of 2000 1 1)}}]]) 106 | 107 | ;; Insert a dashboard 108 | (xt/submit-tx 109 | @xtdb 110 | [[::xt/put {:xt/id {:dashboard "demo"} 111 | :xtdb-inspector.dashboard/name "demo" 112 | :xtdb-inspector.dashboard/description "Demonstrates dashboard widgets" 113 | :xtdb-inspector.dashboard/config 114 | {:update-duration (java.time.Duration/ofSeconds 60) 115 | :widgets 116 | [{:id :dev-count 117 | :type :stat 118 | :label "Developers" 119 | :description "# people any developer job title" 120 | :query '{:find [(count d)] 121 | :where [[(text-search :job-title "developer") [[d]]]]}} 122 | 123 | {:id :male-percentage 124 | :type :radial-progress 125 | :label "Male %" 126 | :query '{:find [(* 100.0 (/ (count e) total))] 127 | :where [[e :gender :male] 128 | [(q [:find (count g) :where [g :gender]]) 129 | [[total]]]]}} 130 | {:id :genders 131 | :type :pie 132 | :col-span 2 133 | :max-items 4 134 | :label "Gender distribution" 135 | :query '{:find [g (count g)] 136 | :where [[_ :gender g]] 137 | :group-by [g]}} 138 | 139 | {:id :todays-birthdays 140 | :type :query 141 | :label "Upcoming birthdays" 142 | :col-span 4 143 | :query '{:find [u 144 | (if (= 0 days-to-birthday) 145 | (str fn " " ln " turns " age " today. HAPPY BIRTHDAY!") 146 | (str fn " " ln " turns " age " in " days-to-birthday " days")) 147 | days-to-birthday] 148 | :keys [user celebrate in-days] 149 | :where [[u :birthday bd] 150 | [u :first-name fn] 151 | [u :last-name ln] 152 | [(>= bd today)] 153 | [(<= bd week-from-now)] 154 | [u :date-of-birth dob] 155 | [(user/days-until-birthday dob) days-to-birthday] 156 | [(user/birthday-age dob) age]] 157 | :order-by [[days-to-birthday :asc]] 158 | :in [today week-from-now]} 159 | :query-params '(letfn [(fmt [d] 160 | (.format (java.text.SimpleDateFormat. "MM-dd") d))] 161 | [(fmt (java.util.Date.)) 162 | (fmt (java.util.Date. 163 | (+ (System/currentTimeMillis) 164 | (* 1000 60 60 24 7))))])}]}}]])) 165 | 166 | (defn days-until-birthday [dob] 167 | (let [now (java.time.LocalDate/now) 168 | this-year-birthday (.withYear dob (.getYear now))] 169 | (.getDays (.until now this-year-birthday)))) 170 | 171 | (defn birthday-age [dob] 172 | (inc (.getYears (.until dob (java.time.LocalDate/now))))) 173 | 174 | (defn db [] (xt/db @xtdb)) 175 | 176 | (defn shutdown [] 177 | (when-let [server @server] 178 | (server)) 179 | (when-let [node @xtdb] 180 | (.close node)) 181 | (shutdown-agents)) 182 | 183 | (defn -main [& _args] 184 | (println "Starting main for testing") 185 | (start) 186 | (some-docs) 187 | ;; We are in the background, so sleep until tests are done 188 | (Thread/sleep (* 1000 60 15)) 189 | (shutdown)) 190 | 191 | 192 | ;; generate some test data for barcharts 193 | (comment 194 | (def devs (map first (xt/q (db) '[:find e :where [(text-search :job-title "developer") [[e]]]]))) 195 | (def langs [:clojure :haskell :rust :typescript :clojure :clojure :smalltalk]) ; stack the odds :D 196 | (xt/submit-tx 197 | @xtdb 198 | (for [d devs 199 | :let [l (rand-nth langs)]] 200 | [::xt/put (assoc (xt/entity (db) d) :favorite-language l)]))) 201 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "daisyui": "^2.17.0", 4 | "postcss-cli": "^8.3.1", 5 | "tailwindcss": "^3.1.4" 6 | }, 7 | "scripts": { 8 | "tailwind": "tailwindcss -i resources/xtdb-inspector.css -o resources/public/xtdb-inspector.css --watch", 9 | "tailwindprod": "tailwindcss -i resources/xtdb-inspector.css -o resources/public/xtdb-inspector.css -m" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /resources/public/xtdb-inspector.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v3.1.4 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}:root,[data-theme]{background-color:hsla(var(--b1)/var(--tw-bg-opacity,1));color:hsla(var(--bc)/var(--tw-text-opacity,1))}html{-webkit-tap-highlight-color:transparent}:root{--p:259 94% 51%;--pf:259 94% 41%;--sf:314 100% 38%;--af:174 60% 41%;--nf:219 14% 22%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:0 0% 100%;--s:314 100% 47%;--sc:0 0% 100%;--a:174 60% 51%;--ac:175 44% 15%;--n:219 14% 28%;--nc:0 0% 100%;--b1:0 0% 100%;--b2:0 0% 95%;--b3:180 2% 90%;--bc:215 28% 17%}@media (prefers-color-scheme:dark){:root{--p:262 80% 50%;--pf:262 80% 40%;--sf:316 70% 40%;--af:175 70% 33%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:0 0% 100%;--s:316 70% 50%;--sc:0 0% 100%;--a:175 70% 41%;--ac:0 0% 100%;--n:218 18% 12%;--nf:223 17% 8%;--nc:220 13% 69%;--b1:220 18% 20%;--b2:220 17% 17%;--b3:219 18% 15%;--bc:220 13% 69%}}[data-theme=light]{--p:259 94% 51%;--pf:259 94% 41%;--sf:314 100% 38%;--af:174 60% 41%;--nf:219 14% 22%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:0 0% 100%;--s:314 100% 47%;--sc:0 0% 100%;--a:174 60% 51%;--ac:175 44% 15%;--n:219 14% 28%;--nc:0 0% 100%;--b1:0 0% 100%;--b2:0 0% 95%;--b3:180 2% 90%;--bc:215 28% 17%}[data-theme=dark]{--p:262 80% 50%;--pf:262 80% 40%;--sf:316 70% 40%;--af:175 70% 33%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:0 0% 100%;--s:316 70% 50%;--sc:0 0% 100%;--a:175 70% 41%;--ac:0 0% 100%;--n:218 18% 12%;--nf:223 17% 8%;--nc:220 13% 69%;--b1:220 18% 20%;--b2:220 17% 17%;--b3:219 18% 15%;--bc:220 13% 69%}[data-theme=cupcake]{--p:183 47% 59%;--pf:183 47% 47%;--sf:338 71% 62%;--af:39 84% 46%;--nf:280 46% 11%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--pc:183 100% 12%;--sc:338 100% 16%;--ac:39 100% 12%;--nc:280 83% 83%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--s:338 71% 78%;--a:39 84% 58%;--n:280 46% 14%;--b1:24 33% 97%;--b2:27 22% 92%;--b3:22 14% 89%;--bc:280 46% 14%;--rounded-btn:1.9rem;--tab-border:2px;--tab-radius:.5rem}[data-theme=bumblebee]{--p:41 74% 53%;--pf:41 74% 42%;--sf:50 94% 46%;--af:240 33% 11%;--nf:240 33% 11%;--b2:0 0% 90%;--b3:0 0% 81%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--bc:0 0% 20%;--ac:240 60% 83%;--nc:240 60% 83%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:240 33% 14%;--s:50 94% 58%;--sc:240 33% 14%;--a:240 33% 14%;--n:240 33% 14%;--b1:0 0% 100%}[data-theme=emerald]{--p:141 50% 60%;--pf:141 50% 48%;--sf:219 96% 48%;--af:10 81% 45%;--nf:219 20% 20%;--b2:0 0% 90%;--b3:0 0% 81%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--btn-text-case:uppercase;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:151 28% 19%;--s:219 96% 60%;--sc:210 20% 98%;--a:10 81% 56%;--ac:210 20% 98%;--n:219 20% 25%;--nc:210 20% 98%;--b1:0 0% 100%;--bc:219 20% 25%;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=corporate]{--p:229 96% 64%;--pf:229 96% 51%;--sf:215 26% 47%;--af:154 49% 48%;--nf:233 27% 10%;--b2:0 0% 90%;--b3:0 0% 81%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--pc:229 100% 93%;--sc:215 100% 12%;--ac:154 100% 12%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--btn-text-case:uppercase;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:215 26% 59%;--a:154 49% 60%;--n:233 27% 13%;--nc:210 38% 95%;--b1:0 0% 100%;--bc:233 27% 13%;--rounded-box:0.25rem;--rounded-btn:.125rem;--rounded-badge:.125rem;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=synthwave]{--p:321 70% 69%;--pf:321 70% 55%;--sf:197 87% 52%;--af:48 89% 46%;--nf:253 61% 15%;--b2:254 59% 23%;--b3:254 59% 21%;--pc:321 100% 14%;--sc:197 100% 13%;--ac:48 100% 11%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:197 87% 65%;--a:48 89% 57%;--n:253 61% 19%;--nc:260 60% 98%;--b1:254 59% 26%;--bc:260 60% 98%;--in:199 87% 64%;--inc:257 63% 17%;--su:168 74% 68%;--suc:257 63% 17%;--wa:48 89% 57%;--wac:257 63% 17%;--er:352 74% 57%;--erc:260 60% 98%}[data-theme=retro]{--p:3 74% 76%;--pf:3 74% 61%;--sf:145 27% 58%;--af:49 67% 61%;--nf:42 17% 34%;--inc:221 100% 91%;--suc:142 100% 87%;--wac:32 100% 9%;--erc:0 100% 90%;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:345 5% 15%;--s:145 27% 72%;--sc:345 5% 15%;--a:49 67% 76%;--ac:345 5% 15%;--n:42 17% 42%;--nc:45 47% 80%;--b1:45 47% 80%;--b2:45 37% 72%;--b3:42 36% 65%;--bc:345 5% 15%;--in:221 83% 53%;--su:142 76% 36%;--wa:32 95% 44%;--er:0 72% 51%;--rounded-box:0.4rem;--rounded-btn:0.4rem;--rounded-badge:0.4rem}[data-theme=cyberpunk]{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;--pf:345 100% 58%;--sf:195 80% 56%;--af:276 74% 57%;--nf:57 100% 10%;--b2:56 100% 45%;--b3:56 100% 41%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--bc:56 100% 10%;--pc:345 100% 15%;--sc:195 100% 14%;--ac:276 100% 14%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:345 100% 73%;--s:195 80% 70%;--a:276 74% 71%;--n:57 100% 13%;--nc:56 100% 50%;--b1:56 100% 50%;--rounded-box:0;--rounded-btn:0;--rounded-badge:0;--tab-radius:0}[data-theme=valentine]{--p:353 74% 67%;--pf:353 74% 54%;--sf:254 86% 61%;--af:181 56% 56%;--nf:336 43% 38%;--b2:318 46% 80%;--b3:318 46% 72%;--pc:353 100% 13%;--sc:254 100% 15%;--ac:181 100% 14%;--inc:221 100% 91%;--suc:142 100% 87%;--wac:32 100% 9%;--erc:0 100% 90%;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:254 86% 77%;--a:181 56% 70%;--n:336 43% 48%;--nc:318 46% 89%;--b1:318 46% 89%;--bc:344 38% 28%;--in:221 83% 53%;--su:142 76% 36%;--wa:32 95% 44%;--er:0 72% 51%;--rounded-btn:1.9rem}[data-theme=halloween]{--p:32 89% 52%;--pf:32 89% 42%;--sf:271 46% 34%;--af:91 100% 26%;--nf:180 4% 9%;--b2:0 0% 12%;--b3:0 0% 10%;--bc:0 0% 83%;--sc:271 100% 88%;--ac:91 100% 7%;--nc:180 5% 82%;--inc:221 100% 91%;--suc:142 100% 87%;--wac:32 100% 9%;--erc:0 100% 90%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:180 7% 8%;--s:271 46% 42%;--a:91 100% 33%;--n:180 4% 11%;--b1:0 0% 13%;--in:221 83% 53%;--su:142 76% 36%;--wa:32 95% 44%;--er:0 72% 51%}[data-theme=garden]{--p:139 16% 43%;--pf:139 16% 34%;--sf:97 37% 75%;--af:0 68% 75%;--nf:0 4% 28%;--b2:0 4% 82%;--b3:0 4% 74%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--pc:139 100% 89%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:97 37% 93%;--sc:96 32% 15%;--a:0 68% 94%;--ac:0 22% 16%;--n:0 4% 35%;--nc:0 4% 91%;--b1:0 4% 91%;--bc:0 3% 6%}[data-theme=forest]{--p:141 72% 42%;--pf:141 72% 34%;--sf:141 75% 38%;--af:35 69% 42%;--nf:0 10% 5%;--b2:0 12% 7%;--b3:0 12% 7%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--bc:0 12% 82%;--pc:141 100% 8%;--sc:141 100% 10%;--ac:35 100% 10%;--nc:0 7% 81%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:141 75% 48%;--a:35 69% 52%;--n:0 10% 6%;--b1:0 12% 8%;--rounded-btn:1.9rem}[data-theme=aqua]{--p:182 93% 49%;--pf:182 93% 40%;--sf:274 31% 45%;--af:47 100% 64%;--nf:205 54% 40%;--b2:219 53% 39%;--b3:219 53% 35%;--bc:219 100% 89%;--sc:274 100% 91%;--ac:47 100% 16%;--nc:205 100% 90%;--inc:221 100% 91%;--suc:142 100% 87%;--wac:32 100% 9%;--erc:0 100% 90%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--pc:181 100% 17%;--s:274 31% 57%;--a:47 100% 80%;--n:205 54% 50%;--b1:219 53% 43%;--in:221 83% 53%;--su:142 76% 36%;--wa:32 95% 44%;--er:0 72% 51%}[data-theme=lofi]{--p:0 0% 5%;--pf:0 0% 4%;--sf:0 2% 8%;--af:0 0% 12%;--nf:0 0% 0%;--btn-text-case:uppercase;--border-btn:1px;--tab-border:1px;--pc:0 0% 100%;--s:0 2% 10%;--sc:0 0% 100%;--a:0 0% 15%;--ac:0 0% 100%;--n:0 0% 0%;--nc:0 0% 100%;--b1:0 0% 100%;--b2:0 0% 95%;--b3:0 2% 90%;--bc:0 0% 0%;--in:212 100% 48%;--inc:0 0% 100%;--su:137 72% 46%;--suc:0 0% 100%;--wa:5 100% 66%;--wac:0 0% 100%;--er:325 78% 49%;--erc:0 0% 100%;--rounded-box:0.25rem;--rounded-btn:0.125rem;--rounded-badge:0.125rem;--animation-btn:0;--animation-input:0;--btn-focus-scale:1;--tab-radius:0}[data-theme=pastel]{--p:284 22% 80%;--pf:284 22% 64%;--sf:352 70% 70%;--af:158 55% 65%;--nf:199 44% 49%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--bc:0 0% 20%;--pc:284 59% 16%;--sc:352 100% 18%;--ac:158 100% 16%;--nc:199 100% 12%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:352 70% 88%;--a:158 55% 81%;--n:199 44% 61%;--b1:0 0% 100%;--b2:210 20% 98%;--b3:216 12% 84%;--rounded-btn:1.9rem}[data-theme=fantasy]{--p:296 83% 25%;--pf:296 83% 20%;--sf:200 100% 30%;--af:31 94% 41%;--nf:215 28% 13%;--b2:0 0% 90%;--b3:0 0% 81%;--in:198 93% 60%;--su:158 64% 52%;--wa:43 96% 56%;--er:0 91% 71%;--pc:296 100% 85%;--sc:200 100% 87%;--ac:31 100% 10%;--nc:215 62% 83%;--inc:198 100% 12%;--suc:158 100% 10%;--wac:43 100% 11%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:200 100% 37%;--a:31 94% 51%;--n:215 28% 17%;--b1:0 0% 100%;--bc:215 28% 17%}[data-theme=wireframe]{font-family:Chalkboard,comic sans ms,sanssecondaryerif;--pf:0 0% 58%;--sf:0 0% 58%;--af:0 0% 58%;--nf:0 0% 74%;--bc:0 0% 20%;--pc:0 0% 14%;--sc:0 0% 14%;--ac:0 0% 14%;--nc:0 0% 18%;--inc:240 100% 90%;--suc:120 100% 85%;--wac:60 100% 10%;--erc:0 100% 90%;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:0 0% 72%;--s:0 0% 72%;--a:0 0% 72%;--n:0 0% 92%;--b1:0 0% 100%;--b2:0 0% 93%;--b3:0 0% 87%;--in:240 100% 50%;--su:120 100% 25%;--wa:60 30% 50%;--er:0 100% 50%;--rounded-box:0.2rem;--rounded-btn:0.2rem;--rounded-badge:0.2rem;--tab-radius:0.2rem}[data-theme=black]{--p:0 2% 20%;--pf:0 2% 16%;--sf:0 2% 16%;--af:0 2% 16%;--bc:0 0% 80%;--pc:0 5% 84%;--sc:0 5% 84%;--ac:0 5% 84%;--nc:0 3% 83%;--inc:240 100% 90%;--suc:120 100% 85%;--wac:60 100% 10%;--erc:0 100% 90%;--border-btn:1px;--tab-border:1px;--s:0 2% 20%;--a:0 2% 20%;--b1:0 0% 0%;--b2:0 0% 5%;--b3:0 2% 10%;--n:0 1% 15%;--nf:0 2% 20%;--in:240 100% 50%;--su:120 100% 25%;--wa:60 100% 50%;--er:0 100% 50%;--rounded-box:0;--rounded-btn:0;--rounded-badge:0;--animation-btn:0;--animation-input:0;--btn-text-case:lowercase;--btn-focus-scale:1;--tab-radius:0}[data-theme=luxury]{--p:0 0% 100%;--pf:0 0% 80%;--sf:218 54% 14%;--af:319 22% 21%;--nf:270 4% 7%;--pc:0 0% 20%;--sc:218 100% 84%;--ac:319 85% 85%;--inc:202 100% 14%;--suc:89 100% 10%;--wac:54 100% 13%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:218 54% 18%;--a:319 22% 26%;--n:270 4% 9%;--nc:37 67% 58%;--b1:240 10% 4%;--b2:270 4% 9%;--b3:270 2% 18%;--bc:37 67% 58%;--in:202 100% 70%;--su:89 62% 52%;--wa:54 69% 64%;--er:0 100% 72%}[data-theme=dracula]{--p:326 100% 74%;--pf:326 100% 59%;--sf:265 89% 62%;--af:31 100% 57%;--nf:230 15% 24%;--b2:231 15% 17%;--b3:231 15% 15%;--pc:326 100% 15%;--sc:265 100% 16%;--ac:31 100% 14%;--nc:230 71% 86%;--inc:191 100% 15%;--suc:135 100% 13%;--wac:65 100% 15%;--erc:0 100% 93%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:265 89% 78%;--a:31 100% 71%;--n:230 15% 30%;--b1:231 15% 18%;--bc:60 30% 96%;--in:191 97% 77%;--su:135 94% 65%;--wa:65 92% 76%;--er:0 100% 67%}[data-theme=cmyk]{--p:203 83% 60%;--pf:203 83% 48%;--sf:335 78% 48%;--af:56 100% 48%;--nf:0 0% 8%;--b2:0 0% 90%;--b3:0 0% 81%;--bc:0 0% 20%;--pc:203 100% 12%;--sc:335 100% 92%;--ac:56 100% 12%;--nc:0 0% 82%;--inc:192 100% 10%;--suc:291 100% 88%;--wac:25 100% 11%;--erc:4 100% 91%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:335 78% 60%;--a:56 100% 60%;--n:0 0% 10%;--b1:0 0% 100%;--in:192 48% 52%;--su:291 48% 38%;--wa:25 85% 57%;--er:4 81% 56%}[data-theme=autumn]{--p:344 96% 28%;--pf:344 96% 22%;--sf:0 63% 47%;--af:27 56% 50%;--nf:22 17% 35%;--b2:0 0% 85%;--b3:0 0% 77%;--bc:0 0% 19%;--pc:344 100% 86%;--sc:0 100% 92%;--ac:27 100% 13%;--nc:22 100% 89%;--inc:187 100% 10%;--suc:165 100% 9%;--wac:30 100% 10%;--erc:354 100% 90%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:0 63% 58%;--a:27 56% 63%;--n:22 17% 44%;--b1:0 0% 95%;--in:187 48% 50%;--su:165 34% 43%;--wa:30 84% 50%;--er:354 79% 49%}[data-theme=business]{--p:210 64% 31%;--pf:210 64% 24%;--sf:200 13% 44%;--af:13 80% 48%;--nf:213 14% 13%;--b2:0 0% 11%;--b3:0 0% 10%;--bc:0 0% 83%;--pc:210 100% 86%;--sc:200 100% 11%;--ac:13 100% 12%;--nc:213 28% 83%;--inc:199 100% 88%;--suc:144 100% 11%;--wac:39 100% 12%;--erc:6 100% 89%;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:200 13% 55%;--a:13 80% 60%;--n:213 14% 16%;--b1:0 0% 13%;--in:199 100% 42%;--su:144 31% 56%;--wa:39 64% 60%;--er:6 56% 43%;--rounded-box:0.25rem;--rounded-btn:.125rem;--rounded-badge:.125rem}[data-theme=acid]{--p:303 100% 50%;--pf:303 100% 40%;--sf:27 100% 40%;--af:72 98% 40%;--nf:238 43% 14%;--b2:0 0% 88%;--b3:0 0% 79%;--bc:0 0% 20%;--pc:303 100% 90%;--sc:27 100% 10%;--ac:72 100% 10%;--nc:238 99% 83%;--inc:210 100% 12%;--suc:149 100% 12%;--wac:53 100% 11%;--erc:1 100% 89%;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:27 100% 50%;--a:72 98% 50%;--n:238 43% 17%;--b1:0 0% 98%;--in:210 92% 58%;--su:149 50% 58%;--wa:53 93% 57%;--er:1 100% 45%;--rounded-box:1.25rem;--rounded-btn:1rem;--rounded-badge:1rem}[data-theme=lemonade]{--p:89 96% 31%;--pf:89 96% 24%;--sf:60 81% 44%;--af:63 80% 71%;--nf:238 43% 14%;--b2:0 0% 90%;--b3:0 0% 81%;--bc:0 0% 20%;--pc:89 100% 86%;--sc:60 100% 11%;--ac:63 100% 18%;--nc:238 99% 83%;--inc:192 79% 17%;--suc:74 100% 16%;--wac:50 100% 15%;--erc:1 100% 17%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:60 81% 55%;--a:63 80% 88%;--n:238 43% 17%;--b1:0 0% 100%;--in:192 39% 85%;--su:74 76% 79%;--wa:50 87% 75%;--er:1 70% 83%}[data-theme=night]{--p:198 93% 60%;--pf:198 93% 48%;--sf:234 89% 59%;--af:329 86% 56%;--b2:222 47% 10%;--b3:222 47% 9%;--bc:222 66% 82%;--pc:198 100% 12%;--sc:234 100% 15%;--ac:329 100% 14%;--nc:217 76% 83%;--inc:198 100% 10%;--suc:172 100% 10%;--wac:41 100% 13%;--erc:351 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:234 89% 74%;--a:329 86% 70%;--n:217 33% 17%;--nf:217 30% 22%;--b1:222 47% 11%;--in:198 90% 48%;--su:172 66% 50%;--wa:41 88% 64%;--er:351 95% 71%}[data-theme=coffee]{--p:30 67% 58%;--pf:30 67% 46%;--sf:182 25% 16%;--af:194 74% 20%;--nf:300 20% 5%;--b2:306 19% 10%;--b3:306 19% 9%;--pc:30 100% 12%;--sc:182 67% 84%;--ac:194 100% 85%;--nc:300 14% 81%;--inc:171 100% 13%;--suc:93 100% 12%;--wac:43 100% 14%;--erc:10 100% 15%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:182 25% 20%;--a:194 74% 25%;--n:300 20% 6%;--b1:306 19% 11%;--bc:37 8% 42%;--in:171 37% 67%;--su:93 25% 62%;--wa:43 100% 69%;--er:10 95% 75%}[data-theme=winter]{--p:212 100% 51%;--pf:212 100% 41%;--sf:247 47% 35%;--af:310 49% 42%;--nf:217 92% 8%;--pc:212 100% 90%;--sc:247 100% 89%;--ac:310 100% 90%;--nc:217 100% 82%;--inc:192 100% 16%;--suc:182 100% 13%;--wac:32 100% 17%;--erc:0 100% 14%;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-text-case:uppercase;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--s:247 47% 43%;--a:310 49% 52%;--n:217 92% 10%;--b1:0 0% 100%;--b2:217 100% 97%;--b3:219 44% 92%;--bc:214 30% 32%;--in:192 93% 78%;--su:182 47% 66%;--wa:32 62% 84%;--er:0 63% 72%}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::-webkit-backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.alert{display:flex;width:100%;flex-direction:column;align-items:center;justify-content:space-between;gap:1rem;--tw-bg-opacity:1;background-color:hsl(var(--b2,var(--b1))/var(--tw-bg-opacity));padding:1rem;border-radius:var(--rounded-box,1rem)}.alert>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}@media (min-width:768px){.alert{flex-direction:row}.alert>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}}.alert>:where(*){display:flex;align-items:center;gap:.5rem}.avatar.placeholder>div{display:flex;align-items:center;justify-content:center}.badge{height:1.25rem;width:-webkit-fit-content;width:-moz-fit-content;width:fit-content;padding-left:.563rem;padding-right:.563rem;border-width:1px;border-color:hsl(var(--n)/var(--tw-border-opacity));border-radius:var(--rounded-badge,1.9rem)}.badge,.btn{display:inline-flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);font-size:.875rem;line-height:1.25rem;--tw-border-opacity:1;--tw-bg-opacity:1;background-color:hsl(var(--n)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--nc)/var(--tw-text-opacity))}.btn{flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;border-color:#0000;border-color:hsl(var(--n)/var(--tw-border-opacity));text-align:center;border-radius:var(--rounded-btn,.5rem);height:3rem;padding-left:1rem;padding-right:1rem;line-height:1em;min-height:3rem;font-weight:600;text-transform:uppercase;text-transform:var(--btn-text-case,uppercase);-webkit-text-decoration-line:none;text-decoration-line:none;border-width:var(--border-btn,1px);-webkit-animation:button-pop var(--animation-btn,.25s) ease-out;animation:button-pop var(--animation-btn,.25s) ease-out}.btn-disabled,.btn[disabled]{pointer-events:none}.btn-square{height:3rem;width:3rem;padding:0}.btn.loading,.btn.loading:hover{pointer-events:none}.btn.loading:before{margin-right:.5rem;height:1rem;width:1rem;border-radius:9999px;border-width:2px;-webkit-animation:spin 2s linear infinite;animation:spin 2s linear infinite;content:"";border-top-color:#0000;border-left-color:#0000;border-bottom-color:initial;border-right-color:initial}@media (prefers-reduced-motion:reduce){.btn.loading:before{-webkit-animation:spin 10s linear infinite;animation:spin 10s linear infinite}}@-webkit-keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.btn-group>input[type=radio].btn{-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn-group>input[type=radio].btn:before{content:attr(data-title)}.card{position:relative;display:flex;flex-direction:column;overflow:hidden;border-radius:var(--rounded-box,1rem)}.card:focus{outline:2px solid #0000;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;padding:var(--padding-card,2rem);gap:.5rem}.card-body :where(p){flex-grow:1}.card figure{display:flex;align-items:center;justify-content:center}.card.image-full{display:grid}.card.image-full:before{position:relative;content:"";z-index:10;--tw-bg-opacity:1;background-color:hsl(var(--n)/var(--tw-bg-opacity));opacity:.75;border-radius:var(--rounded-box,1rem)}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:hsl(var(--nc)/var(--tw-text-opacity))}.collapse{position:relative;display:grid;overflow:hidden}.collapse-content,.collapse-title,.collapse>input[type=checkbox]{grid-column-start:1;grid-row-start:1}.collapse>input[type=checkbox]{-webkit-appearance:none;-moz-appearance:none;appearance:none;opacity:0}.collapse-content{grid-row-start:2;overflow:hidden;max-height:0;padding-left:1rem;padding-right:1rem;cursor:unset;transition:padding .2s ease-in-out,background-color .2s ease-in-out}.collapse-open .collapse-content,.collapse:focus:not(.collapse-close) .collapse-content,.collapse:not(.collapse-close) input[type=checkbox]:checked~.collapse-content{max-height:9000px}.divider{display:flex;flex-direction:row;align-items:center;align-self:stretch;margin-top:1rem;margin-bottom:1rem;height:1rem;white-space:nowrap}.divider:after,.divider:before{content:"";flex-grow:1;height:.125rem;width:100%}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;justify-content:space-between;padding:.5rem .25rem}.input{flex-shrink:1;transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);height:3rem;padding-left:1rem;padding-right:1rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-width:1px;border-color:hsl(var(--bc)/var(--tw-border-opacity));--tw-border-opacity:0;--tw-bg-opacity:1;background-color:hsl(var(--b1)/var(--tw-bg-opacity));border-radius:var(--rounded-btn,.5rem)}.input-group{display:flex;width:100%;align-items:stretch}.input-group>*,.input-group>.input{border-radius:0}.input-group-sm{font-size:.875rem;line-height:2rem}.input-group :where(span){display:flex;align-items:center;--tw-bg-opacity:1;background-color:hsl(var(--b3,var(--b2))/var(--tw-bg-opacity));padding-left:1rem;padding-right:1rem}.input-group :first-child{border-top-left-radius:var(--rounded-btn,.5rem);border-top-right-radius:0;border-bottom-left-radius:var(--rounded-btn,.5rem);border-bottom-right-radius:0}.input-group :last-child{border-top-left-radius:0;border-top-right-radius:var(--rounded-btn,.5rem);border-bottom-left-radius:0;border-bottom-right-radius:var(--rounded-btn,.5rem)}.link{cursor:pointer;-webkit-text-decoration-line:underline;text-decoration-line:underline}.menu{display:flex;flex-direction:column}.menu.horizontal{display:inline-flex;flex-direction:row}.menu.horizontal :where(li){flex-direction:row}.menu :where(li){position:relative;display:flex;flex-direction:column;flex-wrap:wrap;align-items:stretch}.menu :where(li:not(.menu-title))>:where(:not(ul)){display:flex}.menu :where(li:not(.disabled):not(.menu-title))>:where(:not(ul)){cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;outline:2px solid #0000;outline-offset:2px}.menu>:where(li>:not(ul):focus){outline:2px solid #0000;outline-offset:2px}.menu>:where(li.disabled>:not(ul):focus){cursor:auto}.menu>:where(li) :where(ul){display:flex;flex-direction:column;align-items:stretch}.menu>:where(li)>:where(ul){position:absolute;display:none;top:auto;left:100%;border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.menu>:where(li:hover)>:where(ul){display:flex}.menu>:where(li:focus)>:where(ul){display:flex}.navbar{display:flex;align-items:center;padding:var(--navbar-padding,.5rem);min-height:4rem;width:100%}:where(.navbar>*){display:inline-flex;align-items:center}.navbar-start{width:50%;justify-content:flex-start}.radial-progress{position:relative;display:inline-grid;height:var(--size);width:var(--size);place-content:center;border-radius:9999px;background-color:initial;vertical-align:middle;box-sizing:initial;--value:0;--size:5rem;--thickness:calc(var(--size)/10)}.radial-progress::-moz-progress-bar{-moz-appearance:none;appearance:none;background-color:initial}.radial-progress::-webkit-progress-bar,.radial-progress::-webkit-progress-value{-webkit-appearance:none;appearance:none;background-color:initial}.radial-progress:after,.radial-progress:before{position:absolute;border-radius:9999px;content:""}.radial-progress:before{top:0;right:0;bottom:0;left:0;background:radial-gradient(farthest-side,currentColor 98%,#0000) top/var(--thickness) var(--thickness) no-repeat,conic-gradient(currentColor calc(var(--value)*1%),#0000 0);-webkit-mask:radial-gradient(farthest-side,#0000 calc(99% - var(--thickness)),#000 calc(100% - var(--thickness)));mask:radial-gradient(farthest-side,#0000 calc(99% - var(--thickness)),#000 calc(100% - var(--thickness)))}.radial-progress:after{inset:calc(50% - var(--thickness)/2);transform:rotate(calc(var(--value)*3.6deg - 90deg)) translate(calc(var(--size)/2 - 50%));background-color:currentColor}.range{height:1.5rem;width:100%;cursor:pointer;-webkit-appearance:none;--range-shdw:var(--bc);overflow:hidden;background-color:initial;border-radius:var(--rounded-box,1rem)}.range:focus{outline:none}.select{display:inline-flex;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;padding-left:1rem;padding-right:2.5rem;font-size:.875rem;line-height:1.25rem;line-height:2;min-height:3rem;border-width:1px;border-color:hsl(var(--bc)/var(--tw-border-opacity));--tw-border-opacity:0;--tw-bg-opacity:1;background-color:hsl(var(--b1)/var(--tw-bg-opacity));font-weight:600;transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);border-radius:var(--rounded-btn,.5rem);background-image:linear-gradient(45deg,#0000 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,#0000 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16px) calc(1px + 50%);background-size:4px 4px,4px 4px;background-repeat:no-repeat}.select-disabled,.select[disabled]{pointer-events:none;cursor:not-allowed;--tw-border-opacity:1;border-color:hsl(var(--b2,var(--b1))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--b2,var(--b1))/var(--tw-bg-opacity));--tw-text-opacity:0.2}.select[multiple]{height:auto}.stats{display:inline-grid;--tw-bg-opacity:1;background-color:hsl(var(--b1)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--bc)/var(--tw-text-opacity));border-radius:var(--rounded-box,1rem)}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{display:inline-grid;width:100%;grid-template-columns:repeat(1,1fr);-moz-column-gap:1rem;column-gap:1rem;border-color:hsl(var(--bc)/var(--tw-border-opacity));--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-value{font-size:2.25rem;line-height:2.5rem;font-weight:800}.stat-desc,.stat-value{grid-column-start:1;white-space:nowrap}.stat-desc{font-size:.75rem;line-height:1rem;opacity:.6}.swap{position:relative;display:inline-grid;-webkit-user-select:none;-moz-user-select:none;user-select:none;place-content:center;cursor:pointer}.swap>*{grid-column-start:1;grid-row-start:1;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:transform,opacity}.swap input{-webkit-appearance:none;-moz-appearance:none;appearance:none}.swap .swap-indeterminate,.swap .swap-on,.swap input:checked~.swap-off,.swap input:indeterminate~.swap-off,.swap input:indeterminate~.swap-on,.swap.swap-active .swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate,.swap-active .swap-on{opacity:1}.tabs{display:flex;align-items:flex-end}.tab,.tabs{flex-wrap:wrap}.tab{display:inline-flex;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;justify-content:center;text-align:center;height:2rem;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:hsla(var(--bc)/var(--tw-text-opacity,1));--tab-bg:hsla(var(--b1)/var(--tw-bg-opacity,1));--tab-border-color:hsla(var(--b3)/var(--tw-bg-opacity,1));color:var(--tab-color);padding-left:var(--tab-padding,1rem);padding-right:var(--tab-padding,1rem)}.tab,.table{position:relative}.table{text-align:left}.table th:first-child{position:sticky;position:-webkit-sticky;left:0;z-index:11}.textarea{flex-shrink:1;transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;line-height:2;min-height:3rem;border-width:1px;border-color:hsl(var(--bc)/var(--tw-border-opacity));--tw-border-opacity:0;--tw-bg-opacity:1;background-color:hsl(var(--b1)/var(--tw-bg-opacity));border-radius:var(--rounded-btn,.5rem)}.btn-outline .badge{--tw-border-opacity:1;border-color:hsl(var(--nf,var(--n))/var(--tw-border-opacity));--tw-text-opacity:1;color:hsl(var(--nc)/var(--tw-text-opacity))}.btn-outline.btn-primary .badge{--tw-border-opacity:1;border-color:hsl(var(--p)/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--p)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--pc)/var(--tw-text-opacity))}.btn-outline.btn-secondary .badge{--tw-border-opacity:1;border-color:hsl(var(--s)/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--s)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--sc)/var(--tw-text-opacity))}.btn-outline.btn-accent .badge{--tw-border-opacity:1;border-color:hsl(var(--a)/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--a)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--ac)/var(--tw-text-opacity))}.btn-outline .badge.outline{--tw-border-opacity:1;border-color:hsl(var(--nf,var(--n))/var(--tw-border-opacity));background-color:initial}.btn-outline.btn-primary .badge-outline{--tw-border-opacity:1;border-color:hsl(var(--p)/var(--tw-border-opacity));background-color:initial;--tw-text-opacity:1;color:hsl(var(--p)/var(--tw-text-opacity))}.btn-outline.btn-secondary .badge-outline{--tw-border-opacity:1;border-color:hsl(var(--s)/var(--tw-border-opacity));background-color:initial;--tw-text-opacity:1;color:hsl(var(--s)/var(--tw-text-opacity))}.btn-outline.btn-info .badge-outline{--tw-border-opacity:1;border-color:hsl(var(--in)/var(--tw-border-opacity));background-color:initial;--tw-text-opacity:1;color:hsl(var(--in)/var(--tw-text-opacity))}.btn-outline:hover .badge{--tw-border-opacity:1;border-color:hsl(var(--b2,var(--b1))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--b2,var(--b1))/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--bc)/var(--tw-text-opacity))}.btn-outline:hover .badge.outline{--tw-border-opacity:1;border-color:hsl(var(--b2,var(--b1))/var(--tw-border-opacity));--tw-text-opacity:1;color:hsl(var(--nc)/var(--tw-text-opacity))}.btn-outline.btn-primary:hover .badge{background-color:hsl(var(--pc)/var(--tw-bg-opacity));color:hsl(var(--p)/var(--tw-text-opacity))}.btn-outline.btn-primary:hover .badge,.btn-outline.btn-primary:hover .badge.outline{--tw-border-opacity:1;border-color:hsl(var(--pc)/var(--tw-border-opacity));--tw-bg-opacity:1;--tw-text-opacity:1}.btn-outline.btn-primary:hover .badge.outline{background-color:hsl(var(--pf,var(--p))/var(--tw-bg-opacity));color:hsl(var(--pc)/var(--tw-text-opacity))}.btn-outline.btn-secondary:hover .badge{background-color:hsl(var(--sc)/var(--tw-bg-opacity));color:hsl(var(--s)/var(--tw-text-opacity))}.btn-outline.btn-secondary:hover .badge,.btn-outline.btn-secondary:hover .badge.outline{--tw-border-opacity:1;border-color:hsl(var(--sc)/var(--tw-border-opacity));--tw-bg-opacity:1;--tw-text-opacity:1}.btn-outline.btn-secondary:hover .badge.outline{background-color:hsl(var(--sf,var(--s))/var(--tw-bg-opacity));color:hsl(var(--sc)/var(--tw-text-opacity))}.btn-outline.btn-accent:hover .badge{background-color:hsl(var(--ac)/var(--tw-bg-opacity));color:hsl(var(--a)/var(--tw-text-opacity))}.btn-outline.btn-accent:hover .badge,.btn-outline.btn-accent:hover .badge.outline{--tw-border-opacity:1;border-color:hsl(var(--ac)/var(--tw-border-opacity));--tw-bg-opacity:1;--tw-text-opacity:1}.btn-outline.btn-accent:hover .badge.outline{background-color:hsl(var(--af,var(--a))/var(--tw-bg-opacity));color:hsl(var(--ac)/var(--tw-text-opacity))}.btn:active:focus,.btn:active:hover{-webkit-animation:none;animation:none;transform:scale(var(--btn-focus-scale,.95))}.btn-active,.btn:hover{--tw-border-opacity:1;border-color:hsl(var(--nf,var(--n))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--nf,var(--n))/var(--tw-bg-opacity))}.btn:focus-visible{outline:2px solid hsl(var(--nf));outline-offset:2px}.btn-primary{--tw-border-opacity:1;border-color:hsl(var(--p)/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--p)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--pc)/var(--tw-text-opacity))}.btn-primary.btn-active,.btn-primary:hover{--tw-border-opacity:1;border-color:hsl(var(--pf,var(--p))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--pf,var(--p))/var(--tw-bg-opacity))}.btn-primary:focus-visible{outline:2px solid hsl(var(--p))}.btn-secondary{--tw-border-opacity:1;border-color:hsl(var(--s)/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--s)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--sc)/var(--tw-text-opacity))}.btn-secondary.btn-active,.btn-secondary:hover{--tw-border-opacity:1;border-color:hsl(var(--sf,var(--s))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--sf,var(--s))/var(--tw-bg-opacity))}.btn-secondary:focus-visible{outline:2px solid hsl(var(--s))}.btn-info{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1;color:hsl(var(--inc,var(--nc))/var(--tw-text-opacity))}.btn-info,.btn-info.btn-active,.btn-info:hover{border-color:hsl(var(--in)/var(--tw-border-opacity));background-color:hsl(var(--in)/var(--tw-bg-opacity))}.btn-info.btn-active,.btn-info:hover{--tw-border-opacity:1;--tw-bg-opacity:1}.btn-info:focus-visible{outline:2px solid hsl(var(--in))}.btn.glass.btn-active,.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn.glass:focus-visible{outline:2px 0 0 2px solid currentColor}.btn-outline.btn-primary{--tw-text-opacity:1;color:hsl(var(--p)/var(--tw-text-opacity))}.btn-outline.btn-primary:hover{--tw-border-opacity:1;border-color:hsl(var(--pf,var(--p))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--pf,var(--p))/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--pc)/var(--tw-text-opacity))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:hsl(var(--s)/var(--tw-text-opacity))}.btn-outline.btn-secondary:hover{--tw-border-opacity:1;border-color:hsl(var(--sf,var(--s))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--sf,var(--s))/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--sc)/var(--tw-text-opacity))}.btn-outline.btn-info{--tw-text-opacity:1;color:hsl(var(--in)/var(--tw-text-opacity))}.btn-outline.btn-info:hover{--tw-border-opacity:1;border-color:hsl(var(--in)/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--in)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--inc,var(--nc))/var(--tw-text-opacity))}.btn-disabled,.btn-disabled:hover,.btn[disabled],.btn[disabled]:hover{--tw-border-opacity:0;background-color:hsl(var(--n)/var(--tw-bg-opacity));--tw-bg-opacity:0.2;color:hsl(var(--bc)/var(--tw-text-opacity));--tw-text-opacity:0.2}.btn.loading.btn-circle:before,.btn.loading.btn-square:before{margin-right:0}.btn.loading.btn-lg:before,.btn.loading.btn-xl:before{height:1.25rem;width:1.25rem}.btn.loading.btn-sm:before,.btn.loading.btn-xs:before{height:.75rem;width:.75rem}.btn-group>.btn-active,.btn-group>input[type=radio]:checked.btn{--tw-border-opacity:1;border-color:hsl(var(--p)/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--p)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--pc)/var(--tw-text-opacity))}.btn-group>.btn-active:focus-visible,.btn-group>input[type=radio]:checked.btn:focus-visible{outline:2px solid hsl(var(--p))}.btn-group:not(.btn-group-vertical)>.btn:not(:first-of-type){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.btn-group:not(.btn-group-vertical)>.btn:not(:last-of-type){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group-vertical>.btn:not(:first-of-type){margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.btn-group-vertical>.btn:not(:last-of-type){border-bottom-right-radius:0;border-bottom-left-radius:0}@-webkit-keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.95))}40%{transform:scale(1.02)}to{transform:scale(1)}}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.95))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:hsl(var(--b2,var(--b1))/var(--tw-border-opacity))}.card.compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-title{display:flex;align-items:center;gap:.5rem;font-size:1.25rem;line-height:1.75rem;font-weight:600}@-webkit-keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.collapse:focus-visible{outline:2px solid hsl(var(--nf));outline-offset:2px}.collapse-arrow .collapse-title:after{transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);top:1.4rem;content:"";transform-origin:75% 75%;transform:rotate(45deg);box-shadow:2px 2px}.collapse-arrow .collapse-title:after,.collapse-plus .collapse-title:after{position:absolute;display:block;height:.5rem;width:.5rem;transition-property:all;right:1.4rem;pointer-events:none}.collapse-plus .collapse-title:after{transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);top:.9rem;content:"+"}.collapse:not(.collapse-open):not(.collapse-close) .collapse-title,.collapse:not(.collapse-open):not(.collapse-close) input[type=checkbox]{cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close) .collapse-title{cursor:unset}.collapse-title,.collapse>input[type=checkbox]{width:100%;padding:1rem 3rem 1rem 1rem;min-height:3.75rem;transition:background-color .2s ease-in-out}.collapse-open :where(.collapse-content),.collapse:focus:not(.collapse-close) :where(.collapse-content),.collapse:not(.collapse-close) :where(input[type=checkbox]:checked~.collapse-content){padding-bottom:1rem;transition:padding .2s ease-in-out,background-color .2s ease-in-out}.collapse-arrow:focus:not(.collapse-close) .collapse-title:after,.collapse-arrow:not(.collapse-close) input[type=checkbox]:checked~.collapse-title:after,.collapse-open.collapse-arrow .collapse-title:after{transform:rotate(225deg)}.collapse-open.collapse-plus .collapse-title:after,.collapse-plus:focus:not(.collapse-close) .collapse-title:after,.collapse-plus:not(.collapse-close) input[type=checkbox]:checked~.collapse-title:after{content:"−"}.divider:after,.divider:before{background-color:hsl(var(--bc)/var(--tw-bg-opacity));--tw-bg-opacity:0.1}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content .drawer-button.btn-primary{outline:2px solid hsl(var(--p))}.drawer-toggle:focus-visible~.drawer-content .drawer-button.btn-secondary{outline:2px solid hsl(var(--s))}.drawer-toggle:focus-visible~.drawer-content .drawer-button.btn-info{outline:2px solid hsl(var(--in))}.label a:hover{--tw-text-opacity:1;color:hsl(var(--bc)/var(--tw-text-opacity))}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{--tw-border-opacity:0.2}.input:focus{outline:2px solid hsla(var(--bc)/.2);outline-offset:2px}.input-disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:hsl(var(--b2,var(--b1))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--b2,var(--b1))/var(--tw-bg-opacity));--tw-text-opacity:0.2}.input-disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:hsl(var(--bc)/var(--tw-placeholder-opacity));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input[disabled]::placeholder{color:hsl(var(--bc)/var(--tw-placeholder-opacity));--tw-placeholder-opacity:0.2}.link:focus{outline:2px solid #0000;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.menu.horizontal li.bordered>a,.menu.horizontal li.bordered>button,.menu.horizontal li.bordered>span{border-left-width:0;border-bottom-width:4px;--tw-border-opacity:1;border-color:hsl(var(--p)/var(--tw-border-opacity))}.menu[class*=" p-"] li>*,.menu[class^=p-] li>*{border-radius:var(--rounded-btn,.5rem)}.menu :where(li.bordered>*){border-left-width:4px;--tw-border-opacity:1;border-color:hsl(var(--p)/var(--tw-border-opacity))}.menu :where(li)>:where(:not(ul)){gap:.75rem;padding:.75rem 1rem;color:currentColor}.menu :where(li:not(.menu-title):not(:empty))>:where(:not(ul):focus),.menu :where(li:not(.menu-title):not(:empty))>:where(:not(ul):hover){background-color:hsl(var(--bc)/var(--tw-bg-opacity));--tw-bg-opacity:0.1}.menu :where(li:not(.menu-title):not(:empty))>:where(:not(ul).active),.menu :where(li:not(.menu-title):not(:empty))>:where(:not(ul):active){--tw-bg-opacity:1;background-color:hsl(var(--p)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--pc)/var(--tw-text-opacity))}.menu :where(li:empty){margin:.5rem 1rem;height:1px;background-color:hsl(var(--bc)/var(--tw-bg-opacity));--tw-bg-opacity:0.1}.menu li.disabled>*{-webkit-user-select:none;-moz-user-select:none;user-select:none;color:hsl(var(--bc)/var(--tw-text-opacity));--tw-text-opacity:0.2}.menu li.disabled>:hover{background-color:initial}.menu li.hover-bordered a{border-left-width:4px;border-color:#0000}.menu li.hover-bordered a:hover{--tw-border-opacity:1;border-color:hsl(var(--p)/var(--tw-border-opacity))}.menu.compact li>a,.menu.compact li>span{padding-top:.5rem;padding-bottom:.5rem;font-size:.875rem;line-height:1.25rem}.menu .menu-title>*{padding-top:.25rem;padding-bottom:.25rem;font-size:.75rem;line-height:1rem;font-weight:700;color:hsl(var(--bc)/var(--tw-text-opacity));--tw-text-opacity:0.4}.menu :where(li:not(.disabled))>:where(:not(ul)){outline:2px solid #0000;outline-offset:2px;transition-property:color,background-color,border-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-text-decoration-color,-webkit-backdrop-filter;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.menu>:where(li:first-child){border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:unset;border-bottom-left-radius:unset}.menu>:where(li:first-child)>:where(:not(ul)){border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:unset;border-bottom-left-radius:unset}.menu>:where(li:last-child){border-top-left-radius:unset;border-top-right-radius:unset;border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.menu>:where(li:last-child)>:where(:not(ul)){border-top-left-radius:unset;border-top-right-radius:unset;border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.menu>:where(li)>:where(ul) :where(li){width:100%;white-space:nowrap}.menu>:where(li)>:where(ul) :where(li) :where(ul){padding-left:1rem}.menu>:where(li)>:where(ul) :where(li)>:where(:not(ul)){width:100%;white-space:nowrap}.menu>:where(li)>:where(ul)>:where(li:first-child){border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:unset;border-bottom-left-radius:unset}.menu>:where(li)>:where(ul)>:where(li:first-child)>:where(:not(ul)){border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:unset;border-bottom-left-radius:unset}.menu>:where(li)>:where(ul)>:where(li:last-child){border-top-left-radius:unset;border-top-right-radius:unset;border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.menu>:where(li)>:where(ul)>:where(li:last-child)>:where(:not(ul)){border-top-left-radius:unset;border-top-right-radius:unset;border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.mockup-phone .display{overflow:hidden;border-radius:40px;margin-top:-25px}@-webkit-keyframes progress-loading{50%{left:107%}}@keyframes progress-loading{50%{left:107%}}@-webkit-keyframes radiomark{0%{box-shadow:0 0 0 12px hsl(var(--b1)) inset,0 0 0 12px hsl(var(--b1)) inset}50%{box-shadow:0 0 0 3px hsl(var(--b1)) inset,0 0 0 3px hsl(var(--b1)) inset}to{box-shadow:0 0 0 4px hsl(var(--b1)) inset,0 0 0 4px hsl(var(--b1)) inset}}@keyframes radiomark{0%{box-shadow:0 0 0 12px hsl(var(--b1)) inset,0 0 0 12px hsl(var(--b1)) inset}50%{box-shadow:0 0 0 3px hsl(var(--b1)) inset,0 0 0 3px hsl(var(--b1)) inset}to{box-shadow:0 0 0 4px hsl(var(--b1)) inset,0 0 0 4px hsl(var(--b1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px hsl(var(--b1)) inset,0 0 0 2rem hsl(var(--range-shdw)) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px hsl(var(--b1)) inset,0 0 0 2rem hsl(var(--range-shdw)) inset}.range::-webkit-slider-runnable-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:hsla(var(--bc)/.1)}.range::-moz-range-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:hsla(var(--bc)/.1)}.range::-webkit-slider-thumb{background-color:hsl(var(--b1));position:relative;height:1.5rem;width:1.5rem;border-style:none;border-radius:var(--rounded-box,1rem);-webkit-appearance:none;top:50%;color:hsl(var(--range-shdw));transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px hsl(var(--range-shdw)) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{background-color:hsl(var(--b1));position:relative;height:1.5rem;width:1.5rem;border-style:none;border-radius:var(--rounded-box,1rem);top:50%;color:hsl(var(--range-shdw));--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px hsl(var(--range-shdw)) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@-webkit-keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered{--tw-border-opacity:0.2}.select:focus{outline:2px solid hsla(var(--bc)/.2);outline-offset:2px}.select-disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:hsl(var(--bc)/var(--tw-placeholder-opacity));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select[disabled]::placeholder{color:hsl(var(--bc)/var(--tw-placeholder-opacity));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.tab:hover{--tw-text-opacity:1}.tab.tab-active{border-color:hsl(var(--bc)/var(--tw-border-opacity));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid #0000;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-3px}.tab:focus-visible.tab-lifted{border-bottom-right-radius:var(--tab-radius,.5rem);border-bottom-left-radius:var(--tab-radius,.5rem)}.tab-bordered{border-color:hsl(var(--bc)/var(--tw-border-opacity));--tw-border-opacity:0.2;border-style:solid;border-bottom-width:calc(var(--tab-border, 1px) + 1px)}.tab-lifted.tab-active{background-color:var(--tab-bg);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);border-left-color:var(--tab-border-color);border-right-color:var(--tab-border-color);border-top-color:var(--tab-border-color);padding-left:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-right:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-top:0}.tab-lifted.tab-active:after,.tab-lifted.tab-active:before{z-index:1;content:"";display:block;position:absolute;width:var(--tab-radius,.5rem);height:var(--tab-radius,.5rem);bottom:0;--tab-grad:calc(68% - var(--tab-border, 1px));--tab-corner-bg:radial-gradient(circle at var(--circle-pos),#0000 var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.3px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.3px))}.tab-lifted.tab-active:before{left:calc(var(--tab-radius, .5rem)*-1);--circle-pos:top left;background-image:var(--tab-corner-bg)}[dir=rtl] .tab-lifted.tab-active:before{--circle-pos:top right}.tab-lifted.tab-active:after{right:calc(var(--tab-radius, .5rem)*-1);--circle-pos:top right;background-image:var(--tab-corner-bg)}[dir=rtl] .tab-lifted.tab-active:after{--circle-pos:top left}.tab-lifted.tab-active+.tab-lifted.tab-active:before,.tab-lifted.tab-active:first-child:before,.tab-lifted.tab-active:last-child:after{background:none}.tabs-boxed .tab-active{--tw-bg-opacity:1;background-color:hsl(var(--p)/var(--tw-bg-opacity));border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active,.tabs-boxed .tab-active:hover{--tw-text-opacity:1;color:hsl(var(--pc)/var(--tw-text-opacity))}.table :where(th,td){white-space:nowrap;padding:1rem;vertical-align:middle}.table tr.active td,.table tr.active th,.table tr.active:nth-child(2n) td,.table tr.active:nth-child(2n) th,.table tr.hover:hover td,.table tr.hover:hover th,.table tr.hover:nth-child(2n):hover td,.table tr.hover:nth-child(2n):hover th{--tw-bg-opacity:1;background-color:hsl(var(--b3,var(--b2))/var(--tw-bg-opacity))}.table:where(:not(.table-zebra)) :where(thead,tbody,tfoot) :where(tr:not(:last-child) :where(th,td)){border-bottom-width:1px;--tw-border-opacity:1;border-color:hsl(var(--b2,var(--b1))/var(--tw-border-opacity))}.table :where(thead,tfoot) :where(th,td){--tw-bg-opacity:1;background-color:hsl(var(--b2,var(--b1))/var(--tw-bg-opacity));font-size:.75rem;line-height:1rem;font-weight:700;text-transform:uppercase}.table :where(:first-child) :where(:first-child) :where(th,td):first-child{border-top-left-radius:.5rem}.table :where(:first-child) :where(:first-child) :where(th,td):last-child{border-top-right-radius:.5rem}.table :where(:last-child) :where(:last-child) :where(th,td):first-child{border-bottom-left-radius:.5rem}.table :where(:last-child) :where(:last-child) :where(th,td):last-child{border-bottom-right-radius:.5rem}.table :where(tbody th,tbody td){--tw-bg-opacity:1;background-color:hsl(var(--b1)/var(--tw-bg-opacity))}.table-zebra tbody tr:nth-child(2n) td,.table-zebra tbody tr:nth-child(2n) th{--tw-bg-opacity:1;background-color:hsl(var(--b2,var(--b1))/var(--tw-bg-opacity))}.textarea:focus{outline:2px solid hsla(var(--bc)/.2);outline-offset:2px}.textarea-disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:hsl(var(--b2,var(--b1))/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:hsl(var(--b2,var(--b1))/var(--tw-bg-opacity));--tw-text-opacity:0.2}.textarea-disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:hsl(var(--bc)/var(--tw-placeholder-opacity));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea[disabled]::placeholder{color:hsl(var(--bc)/var(--tw-placeholder-opacity));--tw-placeholder-opacity:0.2}.btn-xs{height:1.5rem;padding-left:.5rem;padding-right:.5rem;min-height:1.5rem;font-size:.75rem}.btn-sm{height:2rem;padding-left:.75rem;padding-right:.75rem;min-height:2rem;font-size:.875rem}.btn-square:where(.btn-xs){height:1.5rem;width:1.5rem;padding:0}.btn-square:where(.btn-sm){height:2rem;width:2rem;padding:0}.btn-square:where(.btn-md){height:3rem;width:3rem;padding:0}.btn-square:where(.btn-lg){height:4rem;width:4rem;padding:0}.btn-circle:where(.btn-xs){height:1.5rem;width:1.5rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-sm){height:2rem;width:2rem;border-radius:9999px;padding:0}.divider-horizontal{flex-direction:column}.divider-horizontal:after,.divider-horizontal:before{height:100%;width:.125rem}.divider-vertical{flex-direction:row}.divider-vertical:after,.divider-vertical:before{height:.125rem;width:100%}.input-sm{padding-right:.75rem}.input-sm,.select-sm{height:2rem;padding-left:.75rem;font-size:.875rem;line-height:2rem}.select-sm{padding-right:2rem;min-height:2rem}.alert-warning{--tw-bg-opacity:1;background-color:hsl(var(--wa)/var(--tw-bg-opacity));--tw-text-opacity:1;color:hsl(var(--wac,var(--nc))/var(--tw-text-opacity))}.card-compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{padding:var(--padding-card,2rem);font-size:1rem;line-height:1.5rem}.card-normal .card-title{margin-bottom:.75rem}.divider-horizontal{margin:0 1rem;height:auto;width:1rem}.divider-vertical{margin:1rem 0;height:1rem;width:auto}.menu-compact :where(li>*){padding-top:.5rem;padding-bottom:.5rem;font-size:.875rem;line-height:1.25rem}.table-compact :where(th,td){padding:.5rem;font-size:.875rem;line-height:1.25rem}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-y-0{top:0;bottom:0}.left-0{left:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.mt-4{margin-top:1rem}.mb-3{margin-bottom:.75rem}.mr-1{margin-right:.25rem}.-ml-1{margin-left:-.25rem}.mr-3{margin-right:.75rem}.ml-1{margin-left:.25rem}.mb-2{margin-bottom:.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-5{height:1.25rem}.h-4{height:1rem}.h-full{height:100%}.w-full{width:100%}.w-9\/12{width:75%}.w-5{width:1.25rem}.w-1\/3{width:33.333333%}.w-1\/2{width:50%}.w-2\/6{width:33.333333%}.w-4\/6{width:66.666667%}.w-4{width:1rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-grow{flex-grow:1}.border-collapse{border-collapse:collapse}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{-webkit-animation:spin 1s linear infinite;animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.gap-2{gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded-md{border-radius:.375rem}.rounded-lg{border-radius:.5rem}.rounded-full{border-radius:9999px}.rounded{border-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.border-indigo-100{--tw-border-opacity:1;border-color:rgb(224 231 255/var(--tw-border-opacity))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-red-800{--tw-bg-opacity:1;background-color:rgb(153 27 27/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-blue-200{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity))}.bg-base-100{--tw-bg-opacity:1;background-color:hsl(var(--b1)/var(--tw-bg-opacity))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-red-300{--tw-bg-opacity:1;background-color:rgb(252 165 165/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.fill-current{fill:currentColor}.object-right-top{-o-object-position:right top;object-position:right top}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-0{padding:0}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.px-1{padding-left:.25rem;padding-right:.25rem}.py-0{padding-top:0;padding-bottom:0}.pt-4{padding-top:1rem}.pl-0{padding-left:0}.pr-2{padding-right:.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pr-6{padding-right:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-sm{font-size:.875rem;line-height:1.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-semibold{font-weight:600}.font-medium{font-weight:500}.font-bold{font-weight:700}.font-light{font-weight:300}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-primary{--tw-text-opacity:1;color:rgb(20 106 142/var(--tw-text-opacity))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-info{--tw-text-opacity:1;color:hsl(var(--in)/var(--tw-text-opacity))}.text-lime-500{--tw-text-opacity:1;color:rgb(132 204 22/var(--tw-text-opacity))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.underline{-webkit-text-decoration-line:underline;text-decoration-line:underline}.line-through{-webkit-text-decoration-line:line-through;text-decoration-line:line-through}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-xl{--tw-shadow:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid #0000;outline-offset:2px}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,fill,stroke,-webkit-text-decoration-color;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,-webkit-text-decoration-color;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.hover\:bg-indigo-400:hover{--tw-bg-opacity:1;background-color:rgb(129 140 248/var(--tw-bg-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.focus\:bg-white:focus{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.focus\:text-gray-900:focus{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.focus\:text-gray-700:focus{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.focus\:placeholder-gray-600:focus::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity))}.focus\:placeholder-gray-600:focus::placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-indigo-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-gray-400\/80:disabled{background-color:#9ca3afcc}.disabled\:shadow-none:disabled{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.sm\:mt-0{margin-top:0}.sm\:flex-row{flex-direction:row}.sm\:rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}}@media (min-width:768px){.md\:menu-horizontal{display:inline-flex;flex-direction:row}.md\:menu-horizontal :where(li){flex-direction:row}.md\:menu-horizontal>:where(li)>:where(ul){top:100%;left:auto}.md\:menu-horizontal :where(li.bordered>*){border-left-width:0;border-bottom-width:4px}.md\:menu-horizontal>:where(li:first-child){border-top-left-radius:inherit;border-top-right-radius:unset;border-bottom-right-radius:unset;border-bottom-left-radius:inherit}.md\:menu-horizontal>:where(li:first-child)>:where(:not(ul)){border-top-left-radius:inherit;border-top-right-radius:unset;border-bottom-right-radius:unset;border-bottom-left-radius:inherit}.md\:menu-horizontal>:where(li:last-child){border-top-left-radius:unset;border-top-right-radius:inherit;border-bottom-right-radius:inherit;border-bottom-left-radius:unset}.md\:menu-horizontal>:where(li:last-child)>:where(:not(ul)){border-top-left-radius:unset;border-top-right-radius:inherit;border-bottom-right-radius:inherit;border-bottom-left-radius:unset}}@media (min-width:1024px){.lg\:menu-horizontal{display:inline-flex;flex-direction:row}.lg\:menu-horizontal :where(li){flex-direction:row}.lg\:menu-horizontal>:where(li)>:where(ul){top:100%;left:auto}.lg\:menu-horizontal :where(li.bordered>*){border-left-width:0;border-bottom-width:4px}.lg\:menu-horizontal>:where(li:first-child){border-top-left-radius:inherit;border-top-right-radius:unset;border-bottom-right-radius:unset;border-bottom-left-radius:inherit}.lg\:menu-horizontal>:where(li:first-child)>:where(:not(ul)){border-top-left-radius:inherit;border-top-right-radius:unset;border-bottom-right-radius:unset;border-bottom-left-radius:inherit}.lg\:menu-horizontal>:where(li:last-child){border-top-left-radius:unset;border-top-right-radius:inherit;border-bottom-right-radius:inherit;border-bottom-left-radius:unset}.lg\:menu-horizontal>:where(li:last-child)>:where(:not(ul)){border-top-left-radius:unset;border-top-right-radius:inherit;border-bottom-right-radius:inherit;border-bottom-left-radius:unset}} -------------------------------------------------------------------------------- /resources/xtdb-inspector.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/xtdb_inspector/core.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.core 2 | "Core XTDB inspector ns, start new inspector server or 3 | get routes to integrate into an existing ring app." 4 | (:require [org.httpkit.server :as http-kit] 5 | [compojure.core :refer [routes GET POST]] 6 | [compojure.route :as route] 7 | [xtdb-inspector.util :refer [->route]] 8 | [xtdb-inspector.page :as page] 9 | [xtdb-inspector.page.doc :as page.doc] 10 | [xtdb-inspector.page.query :as page.query] 11 | [xtdb-inspector.page.attr :as page.attr] 12 | [xtdb-inspector.page.tx :as page.tx] 13 | [xtdb-inspector.page.dashboard :as page.dashboard] 14 | [ripley.live.context :as context] 15 | [ring.middleware.params :as ring-params])) 16 | 17 | (defn- page [{wrap :wrap-page-fn :as ctx} req page-fn] 18 | (let [handler 19 | (fn [req] 20 | (let [ctx (assoc ctx :request req)] 21 | (page/page-response 22 | ctx 23 | #(page-fn ctx))))] 24 | (if wrap 25 | (wrap req handler) 26 | (handler req)))) 27 | 28 | (defn inspector-handler 29 | ([xtdb-node] (inspector-handler xtdb-node {})) 30 | ([xtdb-node {:keys [allow-editing? wrap-page-fn] 31 | :or {allow-editing? true}}] 32 | (let [ctx {:xtdb-node xtdb-node 33 | :allow-editing? allow-editing? 34 | :wrap-page-fn wrap-page-fn}] 35 | (ring-params/wrap-params 36 | (routes 37 | (context/connection-handler "/__ripley-live" :ping-interval 45) 38 | (GET (->route "/doc") req 39 | (page ctx req #'page.doc/render-form)) 40 | (GET (->route "/doc/:doc-id") req 41 | (page ctx req #'page.doc/render)) 42 | (GET (->route "/query") req 43 | (page ctx req #'page.query/render)) 44 | (POST (->route "/query/export") req 45 | (page.query/export-query ctx req)) 46 | (GET (->route "/query/:query") req 47 | (page ctx req #'page.query/render)) 48 | (GET (->route "/attr") req 49 | (page ctx req #'page.attr/render)) 50 | (GET (->route "/attr/:keyword") req 51 | (page ctx req #'page.attr/render)) 52 | (GET (->route "/attr/:namespace/:keyword") req 53 | (page ctx req #'page.attr/render)) 54 | (GET (->route "/tx") req 55 | (page ctx req #'page.tx/render)) 56 | (GET (->route "/dashboard") req 57 | (page ctx req #'page.dashboard/render-listing)) 58 | (GET (->route "/dashboard/:dashboard") req 59 | (page ctx req #'page.dashboard/render)) 60 | (route/resources "/")))))) 61 | 62 | (defn start [{:keys [port xtdb-node allow-editing?] 63 | :or {port 3000 64 | allow-editing? true}}] 65 | {:pre [(some? xtdb-node)]} 66 | (http-kit/run-server 67 | (inspector-handler xtdb-node {:allow-editing? allow-editing?}) 68 | {:port port})) 69 | -------------------------------------------------------------------------------- /src/xtdb_inspector/id.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.id 2 | "Handle document ids" 3 | (:require [xtdb.api :as xt] 4 | [xtdb-inspector.util :refer [enc]] 5 | [clojure.string :as str])) 6 | 7 | 8 | (defn id-type? 9 | "Check if x can be an id." 10 | [x] 11 | (or 12 | (keyword? x) 13 | (string? x) 14 | (integer? x) 15 | (map? x) 16 | (uuid? x) 17 | (instance? java.net.URL x) 18 | (instance? java.net.URI x))) 19 | 20 | (defn valid-id? 21 | "Check if given id is a valid id that actually 22 | has information in the database." 23 | [db id] 24 | (and (id-type? id) 25 | (some? (ffirst (xt/q db '[:find e :where [e :xt/id] :in e] 26 | id))))) 27 | 28 | 29 | (defn read-doc-id 30 | "Read doc id from URL parameter. 31 | doc-id must already be URL decoded." 32 | [doc-id] 33 | (cond 34 | ;; Keyword id 35 | (str/starts-with? doc-id ":") 36 | (keyword (subs doc-id 1)) 37 | 38 | ;; 39 | (Character/isDigit (.charAt doc-id 0)) 40 | (Long/parseLong doc-id) 41 | 42 | ;; Any edn (prefixed with _) 43 | (str/starts-with? doc-id "_") 44 | (binding [*read-eval* false] 45 | (read-string (subs doc-id 1))) 46 | 47 | ;; Otherwise it's a string 48 | :else doc-id)) 49 | 50 | (defn doc-id-param 51 | "Output doc-id as URL parameter" 52 | [id] 53 | (enc 54 | (cond 55 | (keyword? id) 56 | (pr-str id) 57 | 58 | (or 59 | (integer? id) 60 | (and (string? id) 61 | (not (str/starts-with? id "_")) 62 | (not (Character/isDigit (.charAt id 0))))) 63 | (str id) 64 | 65 | :else 66 | (str "_" (pr-str id))))) 67 | -------------------------------------------------------------------------------- /src/xtdb_inspector/metrics.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.metrics 2 | "Metrics reporting UI" 3 | (:require [xtdb.metrics :as metrics] 4 | [xtdb.system :as sys] 5 | [ripley.html :as h] 6 | [ripley.live.source :as source]) 7 | (:import (com.codahale.metrics MetricRegistry Gauge Meter) 8 | (java.util.concurrent Executors ScheduledExecutorService TimeUnit 9 | Future))) 10 | 11 | (set! *warn-on-reflection* true) 12 | 13 | (def metrics (atom {})) 14 | 15 | (defn- report [^MetricRegistry registry] 16 | {:gauges 17 | (into {} 18 | (let [g (.getGauges registry)] 19 | (for [k (keys g) 20 | :let [v (.getValue ^Gauge (get g k))]] 21 | [k v]))) 22 | :meters 23 | (into {} 24 | (let [ms (.getMeters registry)] 25 | (for [k (keys ms) 26 | :let [^Meter m (get ms k)]] 27 | [k {:min1 (.getOneMinuteRate m) 28 | :min5 (.getFiveMinuteRate m) 29 | :min15 (.getFifteenMinuteRate m) 30 | :mean (.getMeanRate m) 31 | :count (.getCount m)}])))}) 32 | 33 | (defn ->reporter {::sys/deps {:registry ::metrics/registry 34 | :metrics ::metrics/metrics} 35 | :sys/args {}} 36 | [{reg :registry}] 37 | (let [^ScheduledExecutorService executor (Executors/newScheduledThreadPool 1) 38 | ^Future task 39 | (.scheduleAtFixedRate executor 40 | #(reset! metrics (report reg)) 41 | 1 1 TimeUnit/SECONDS)] 42 | (reify java.lang.AutoCloseable 43 | (close [_] 44 | (.cancel task true) 45 | (.shutdownNow executor) 46 | (reset! metrics {}))))) 47 | 48 | 49 | ;;;;;;;;;;; 50 | ;; UI for showing metrics, will listen to the atom 51 | ;; and update all metrics live to connected clients. 52 | ;; 53 | 54 | (def idx-meters-to-render 55 | [["bytes" "xtdb.index-store.indexed-bytes"] 56 | ["attr vals" "xtdb.index-store.indexed-avs"] 57 | ["docs" "xtdb.index-store.indexed-docs"]]) 58 | 59 | (defn render-meters [title meters-to-render metrics-source] 60 | (let [nf (java.text.NumberFormat/getNumberInstance)] 61 | (letfn [(fmt [n] 62 | (let [formatted (if n (.format nf n) "")] 63 | (h/html [:span formatted]))) 64 | (meter-source [meter-name time-frame] 65 | (source/computed 66 | #(get-in % [:meters meter-name time-frame]) 67 | metrics-source))] 68 | (h/html 69 | [:div.meters 70 | [:table.w-full.text-right.border.border-collapse.border-gray-500 71 | [:tr.bg-gray-300 72 | [:td.border.border-collapse.border-gray-500 title] 73 | [:td.border.border-collapse.border-gray-500 "1m"] 74 | [:td.border.border-collapse.border-gray-500 "5m"] 75 | [:td.border.border-collapse.border-gray-500 "15m"] 76 | [:td.border.border-collapse.border-gray-500 "cnt"]] 77 | [::h/for [[label meter-name] meters-to-render] 78 | [:tr 79 | [:td.border.border-collapse.border-gray-500 label] 80 | [:td.border.border-collapse.border-gray-500 81 | [::h/live (meter-source meter-name :min1) fmt]] 82 | [:td.border.border-collapse.border-gray-500 83 | [::h/live (meter-source meter-name :min5) fmt]] 84 | [:td.border.border-collapse.border-gray-500 85 | [::h/live (meter-source meter-name :min15) fmt]] 86 | [:td.border.border-collapse.border-gray-500.font-semibold 87 | [::h/live (meter-source meter-name :count) fmt]]]]]])))) 88 | 89 | 90 | (defn metrics-ui [ctx] 91 | (let [ms (source/source metrics)] 92 | (h/html 93 | [:div.metrics.flex-col.bg-gray-50.rounded-md.border-2.border-black.m-1.p-1.text-sm 94 | [:div 95 | (render-meters "Indexed" idx-meters-to-render ms) 96 | 97 | [:div {:class "px-4 py-2 mt-4 text-lg text-gray-900 bg-gray-200 rounded-lg sm:mt-0 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-300"} 98 | [::h/live (source/c= (get-in %ms [:gauges "xtdb.query.currently-running"])) 99 | #(h/html [:span 100 | {:class "badge mb-3 bg-red-800 rounded-full px-2 py-1 text-center object-right-top text-white text-sm mr-1"} %])] 101 | "running queries"]]]))) 102 | -------------------------------------------------------------------------------- /src/xtdb_inspector/page.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.page 2 | "Main page template that sets up styles and app bar" 3 | (:require [ripley.html :as h] 4 | [xtdb-inspector.metrics :as metrics] 5 | [xtdb-inspector.util :refer [root-path ->route]] 6 | [xtdb.api :as xt] 7 | [ripley.live.source :as source] 8 | [clojure.string :as str] 9 | [xtdb-inspector.id :as id] 10 | [ripley.alpine :refer [x-data] :as x])) 11 | 12 | (defn search [search! results] 13 | (x-data 14 | {:data {:term "" :search false} 15 | :source (source/computed 16 | (fn [{:keys [term results]}] 17 | {:term term 18 | :results (mapv 19 | (fn [[e v a]] 20 | ;; id, href, attr, value 21 | [(pr-str e) 22 | (str root-path "/doc/" (id/doc-id-param e)) 23 | (pr-str a) 24 | (pr-str v)]) results)}) 25 | results) 26 | :container-element :div.form-control} 27 | (fn [{:keys [term results]}] 28 | (h/html 29 | [:div.form-control 30 | [:div.input-group.input-group-sm 31 | [:input#lucene-search.input.input-bordered.input-sm 32 | {:placeholder "Search...", :name "search", :type "search" 33 | :x-model.debounce.500ms "term"}] 34 | [:button.btn.btn-square.btn-sm 35 | {:type "submit" 36 | :x-bind:disabled "search == true" 37 | :x-effect "term; $dispatch('click')" 38 | :x-on:click (str 39 | "if(term.length > 2) { " 40 | "search = true; " 41 | (x/callback search! "term") 42 | "} else { " results " = []; }")} 43 | [:svg 44 | {:width "20px" :height "20px" 45 | :fill "currentColor" 46 | :y "0px" 47 | :x "0px" 48 | :version "1.1", 49 | :viewbox "0 0 56.966 56.966",} 50 | [:path 51 | {:d "M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"}]]]] 52 | 53 | [:template {:x-if (str "search || " results ".length > 0") 54 | :x-effect (str term "; search = false")} 55 | [:table.lucene-results {:style "left:12.5%;top:50px;" :class "z-50 fixed w-9/12 bg-gray-100 p-2 border-2"} 56 | [:template {:x-if "search"} 57 | [:tr 58 | [:td {:colspan 3} 59 | [:div.flex.justify-center.m-4 60 | [:svg.animate-spin.-ml-1.mr-3.h-5.w-5.text-primary 61 | {:fill "none" :viewBox "0 0 24 24"} 62 | [:circle.opacity-25 {:cx 12 :cy 12 :r 10 :stroke "currentColor" :stroke-width 4}] 63 | [:path.opacity-75 {:fill "currentColor" 64 | :d "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]] 65 | "Searching..."]]]] 66 | 67 | [:template {:x-if (str term " == term")} 68 | [:template {:x-for (str "r in " results)} 69 | [:tr 70 | [:td [:a.underline.bg-blue-200.px-1 {:x-bind:href "r[1]" 71 | :x-html "r[0]"}]] 72 | [:td {:x-text "r[2]"}] 73 | [:td {:x-html "r[3]"}]]]]]]])))) 74 | 75 | (def links 76 | [[(->route "/query") "Query"] 77 | [(->route "/doc") "Documents"] 78 | [(->route "/attr") "Attributes"] 79 | [(->route "/tx") "Transactions"] 80 | [(->route "/dashboard") "Dashboards"]]) 81 | 82 | (defn app-bar [ctx search! results] 83 | (h/html 84 | [:nav.navbar.bg-base-100 85 | [:div.flex-1 86 | [:span.font-semibold "XTDB Inspector"]] 87 | 88 | [:div.navbar-start 89 | [:ul.menu.menu-compact.lg:menu-horizontal.md:menu-horizontal 90 | [::h/for [[href label] links] 91 | [:li 92 | [:a {:href href} label]]]]] 93 | 94 | [:div.navbar-end; flex-none.gap-2 95 | (search search! results)]])) 96 | 97 | 98 | (defn lucene-search! [xtdb-node set-results! text] 99 | (set-results! 100 | {:term text 101 | :results 102 | (if (str/blank? text) 103 | [] 104 | (xt/q (xt/db xtdb-node) 105 | '{:find [e v a s] 106 | :where [[(wildcard-text-search text) [[e v a s]]]] 107 | :limit 10 108 | :order-by [[s :desc]] 109 | :in [text]} text))})) 110 | 111 | (defn render-page [{:keys [xtdb-node] :as ctx} page-content-fn] 112 | (let [[results set-results!] (source/use-state []) 113 | [show-metrics? set-show-metrics!] (source/use-state false)] 114 | (h/out! "\n") 115 | (h/html 116 | [:html {:data-theme "light"} 117 | [:head 118 | [:meta {:charset "UTF-8"}] 119 | [:link {:rel "stylesheet" :href "/xtdb-inspector.css"}] 120 | [:style 121 | ".hover-trigger .hover-target { display: none; }" 122 | ".hover-trigger:hover .hover-target { display: block; }"] 123 | (ripley.alpine/alpine-js-script) 124 | (h/live-client-script "/__ripley-live")] 125 | [:body 126 | (app-bar ctx (partial lucene-search! xtdb-node set-results!) results) 127 | [:div.page 128 | (page-content-fn)] 129 | [::h/live show-metrics? 130 | (fn [show?] 131 | (h/html 132 | [:div.flex.flex-col.justify-end.items-end.fixed 133 | {:style "bottom: 0.2rem; right: 0.2rem;"} 134 | [::h/when show? 135 | (metrics/metrics-ui ctx)] 136 | [:button {:on-click #(set-show-metrics! (not show?))} 137 | [::h/if show? "- hide metrics" "+ show metrics"]]]))]]]))) 138 | 139 | (defn page-response [ctx page-content-fn] 140 | (h/render-response 141 | #(render-page ctx page-content-fn))) 142 | -------------------------------------------------------------------------------- /src/xtdb_inspector/page/attr.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.page.attr 2 | "Page to show attributes and browse their values." 3 | (:require [xtdb.api :as xt] 4 | [ripley.html :as h] 5 | [xtdb-inspector.ui :as ui] 6 | [xtdb-inspector.ui.table :as ui.table] 7 | [xtdb-inspector.util :refer [enc root-path]] 8 | [ripley.live.source :as source] 9 | [xtdb-inspector.id :as id] 10 | [xtdb-inspector.ui.tabs :as ui.tabs] 11 | [xtdb-inspector.ui.chart :as ui.chart] 12 | [ripley.integration.xtdb :as rx])) 13 | 14 | (defn- request-attr [request] 15 | (let [{kw' :keyword 16 | ns' :namespace} (:params request)] 17 | (when kw' 18 | (keyword ns' kw')))) 19 | 20 | (defn- render-attr-values-tab [xtdb-node attr] 21 | (let [[limit-source set-limit!] (source/use-state 100) 22 | values (source/computed 23 | (fn [limit] 24 | (with-open [db (xt/open-db xtdb-node)] 25 | (into [] 26 | (map (fn [[e v]] 27 | {:doc e 28 | :val {:id? (id/valid-id? db v) 29 | :v v}})) 30 | (xt/q db 31 | {:find '[e v] 32 | :where [['e attr 'v]] 33 | :limit (inc limit)})))) 34 | limit-source)] 35 | [:div 36 | (ui.table/table 37 | {:columns [{:label "Document" :accessor :doc 38 | :render (fn [doc] 39 | (ui/format-value (constantly true) doc))} 40 | {:label "Value" :accessor :val 41 | :render (fn [{:keys [id? v]}] 42 | (ui/format-value (constantly id?) v))}] 43 | :key first} 44 | values) 45 | (h/html 46 | [::h/live (source/computed 47 | (fn [values limit] 48 | {:count (count values) 49 | :limit limit}) values limit-source) 50 | (fn [{:keys [count limit]}] 51 | (h/html 52 | [:div 53 | [::h/when (> count limit) 54 | [:div 55 | [:div.text-xs "Query limited to " limit " items"] 56 | [:button {:class "px-4 py-2 bg-indigo-50 outline-none border border-indigo-100 rounded text-indigo-500 font-medium active:scale-95 hover:bg-indigo-400 hover:text-white focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2 disabled:bg-gray-400/80 disabled:shadow-none disabled:cursor-not-allowed transition-colors duration-200" 57 | :on-click #(set-limit! (* 2 limit))} "Fetch more results"]]]]))])])) 58 | 59 | (defn- render-attr-graphs-tab [xtdb-node attr] 60 | (let [attr-name (pr-str attr)] 61 | (h/html 62 | [:div.mx-2 63 | "Values for " 64 | [:span.font-mono attr-name] 65 | " by count" 66 | ;; PENDING: use viewBox to standardize the space in rendering 67 | ;; and use some proportional size here 68 | (ui.chart/bar-chart {} 69 | (rx/q {:node xtdb-node} 70 | {:find '[v (count v)] 71 | :keys '[label value] 72 | :where [['_ attr 'v]] 73 | :group-by ['v] 74 | :order-by '[[(count v) :desc]]}))]))) 75 | 76 | (defn- render-attr-values [{:keys [xtdb-node]} attr] 77 | (h/html 78 | (ui.tabs/tabs 79 | {:label "Values" 80 | :render #(render-attr-values-tab xtdb-node attr)} 81 | {:label "Graphs" 82 | :render #(render-attr-graphs-tab xtdb-node attr)}))) 83 | 84 | (defn- render-attr-listing [{:keys [xtdb-node]}] 85 | (ui.table/table 86 | {:columns [{:label "Attribute" :accessor first 87 | :render #(ui/link (str root-path "/attr/" (enc (subs (str %) 1))) 88 | (pr-str %))} 89 | {:label "Values count" :accessor second}] 90 | :key first 91 | :order [first :asc]} 92 | (future 93 | (xt/attribute-stats xtdb-node)))) 94 | 95 | (defn render [{:keys [xtdb-node request] :as ctx}] 96 | (if-let [attr (request-attr request)] 97 | (render-attr-values ctx attr) 98 | (render-attr-listing ctx))) 99 | -------------------------------------------------------------------------------- /src/xtdb_inspector/page/dashboard.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.page.dashboard 2 | "Dashboard page. 3 | Shows a dashboard that is configured in the database. 4 | 5 | A dashboard is a set of widgets that display query results. 6 | This can be things like charts, stats, gauges etc. 7 | 8 | Dashboard can update on a timed interval, or after each 9 | new transaction." 10 | (:require [ripley.html :as h] 11 | [xtdb.api :as xt] 12 | [xtdb-inspector.ui :as ui] 13 | [xtdb-inspector.util :refer [root-path]] 14 | xtdb.query 15 | [ripley.live.source :as source] 16 | [xtdb-inspector.ui.chart :as ui.chart] 17 | [xtdb-inspector.page.query :as page.query] 18 | [clojure.tools.logging :as log])) 19 | 20 | (defmulti render-widget 21 | "Render a widget's HTML. 22 | Receives the widget configuration map along with 23 | the current :query-results. 24 | 25 | The query results is a live source that updates based 26 | on the dashboard update interval. 27 | " 28 | :type) 29 | 30 | (defn- format-number 31 | "Format number with at most single digit accuracy in US locale. 32 | (CSS needs decimal separator to be \".\")" 33 | [num] 34 | (let [f (doto (java.text.NumberFormat/getInstance java.util.Locale/US) 35 | (.setMaximumFractionDigits 1))] 36 | (.format f num))) 37 | 38 | (defmethod render-widget :stat 39 | [{:keys [description query-results]}] 40 | (h/html 41 | [:div.stat 42 | [::h/live query-results 43 | #(let [v (some->> % ffirst format-number)] 44 | (h/html [:div.stat-value v]))] 45 | [::h/when description 46 | [:div.stat-desc description]]])) 47 | 48 | (defmethod render-widget :radial-progress 49 | [{:keys [query-results]}] 50 | (h/html 51 | ;;
70%
52 | [:span 53 | [::h/live query-results 54 | #(let [value (some->> % ffirst format-number) 55 | style (format "--value:%s;" value)] 56 | (h/html 57 | [:div.radial-progress {:style style} value "%"]))]])) 58 | 59 | (defmethod render-widget :pie 60 | [{:keys [query-results] :as w}] 61 | (ui.chart/pie 62 | (merge 63 | {:label-accessor first 64 | :value-accessor second 65 | :format-value format-number} 66 | (select-keys w [:width :height :value-accessor :label-accessor 67 | :max-items :other-label])) 68 | 69 | query-results)) 70 | 71 | (defmethod render-widget :bars 72 | [{:keys [query-results] :as w}] 73 | (ui.chart/bar-chart 74 | (merge 75 | {:label-accessor first 76 | :value-accessor second} 77 | (select-keys w [:width :bar-height 78 | :value-accessor :label-accessor])) 79 | query-results)) 80 | 81 | (defmethod render-widget :query 82 | [{:keys [db query-results query]}] 83 | (page.query/query-results-table 84 | db 85 | (page.query/unpack-find-defs query) 86 | query-results)) 87 | 88 | 89 | (defn- widget-container 90 | [{:keys [label col-span db] :as widget 91 | :or {col-span 1}} widget-source] 92 | (let [class (str "card bg-base-100 shadow-xl " 93 | (case col-span 94 | 1 "" 95 | 2 "col-span-2" 96 | 3 "col-span-3" 97 | 4 "col-span-4"))] 98 | (h/html 99 | [:div {:class class} 100 | [:div.card-body 101 | [:div.card-title label] 102 | (render-widget (assoc widget 103 | :db db 104 | :query-results 105 | (source/computed :query-results widget-source)))]]))) 106 | 107 | (defn- update-widgets [xtdb-node widgets state] 108 | (let [db (xt/db xtdb-node)] 109 | (doseq [{:keys [id query query-params]} widgets 110 | :let [params (when query-params 111 | (try 112 | (eval query-params) 113 | (catch Throwable t 114 | (log/warn t "Query parameter evaluation threw exception") 115 | nil)))]] 116 | (swap! state assoc-in [:widgets id :query-results] 117 | (apply xt/q db query params))))) 118 | 119 | (defn render [{:keys [xtdb-node request]}] 120 | (let [dashboard-name (get-in request [:route-params :dashboard]) 121 | dashboard (ffirst 122 | (xt/q (xt/db xtdb-node) 123 | '[:find (pull d [*]) 124 | :where [d :xtdb-inspector.dashboard/name name] 125 | :in name] 126 | dashboard-name)) 127 | state (atom {:widgets {}}) 128 | widget-source (fn [{id :id}] 129 | (source/computed 130 | #(get-in % [:widgets id]) 131 | state)) 132 | configured-widgets (:widgets (:xtdb-inspector.dashboard/config dashboard)) 133 | db (xt/db xtdb-node)] 134 | (future 135 | (update-widgets xtdb-node configured-widgets state)) 136 | (h/html 137 | [:div.grid.grid-cols-4.gap-2 138 | [::h/for [w configured-widgets] 139 | ;; PENDING: when source updates, widget should use later db as well 140 | (widget-container (assoc w :db db) (widget-source w))]]))) 141 | 142 | (defn render-listing [{:keys [xtdb-node]}] 143 | (let [dashboards (xt/q (xt/db xtdb-node) 144 | '{:find [(pull d [:xtdb-inspector.dashboard/name 145 | :xtdb-inspector.dashboard/description])] 146 | :where [[d :xtdb-inspector.dashboard/name]]})] 147 | (h/html 148 | [:div "Available dashboards:" 149 | [:ul 150 | [::h/for [[{:xtdb-inspector.dashboard/keys [name description]}] dashboards] 151 | [:li (ui/link (str root-path "/dashboard/" name) name) " " description]] 152 | [::h/when (empty? dashboards) 153 | [:div.alert.alert-warning 154 | "No dashboards defined."]]]]))) 155 | -------------------------------------------------------------------------------- /src/xtdb_inspector/page/doc.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.page.doc 2 | "Page that displays a single document." 3 | (:require [ripley.html :as h] 4 | [xtdb-inspector.id :as id] 5 | [xtdb-inspector.ui :as ui] 6 | [xtdb.api :as xt] 7 | [ripley.live.source :as source] 8 | [clojure.set :as set] 9 | [ripley.js :as js] 10 | [ripley.integration.xtdb :as rx] 11 | [ripley.live.protocols :as p] 12 | [clojure.java.io :as io] 13 | [xtdb-inspector.ui.table :as ui.table] 14 | [xtdb-inspector.ui.edn :as ui.edn]) 15 | (:import (java.time LocalDate LocalTime))) 16 | 17 | ;; PENDING: we could have a live collection 18 | ;; for the history and listen to changes as they happen. 19 | ;; 20 | ;; The whole page could be live and show the current doc 21 | ;; and the history. Any changes in db could be listened to 22 | ;; and automatically reflected here. 23 | 24 | (defn changes [history] 25 | (loop [old nil 26 | acc [] 27 | [h & history] history] 28 | (if-not h 29 | acc 30 | (let [new (dissoc (::xt/doc h) :xt/id) 31 | all-keys (set/union (set (keys old)) 32 | (set (keys new))) 33 | history-entry 34 | {:time (::xt/tx-time h) 35 | :changes (into {} 36 | (keep (fn [k] 37 | (let [old-value (get old k ::no-value) 38 | new-value (get new k ::no-value)] 39 | (when (not= old-value new-value) 40 | [k {::from old-value ::to new-value}])))) 41 | all-keys)}] 42 | (recur new 43 | (conj acc history-entry) 44 | history))))) 45 | 46 | 47 | (defn attr-val-row [attr val-fn] 48 | (h/html 49 | [:tr.hover:bg-gray-100 50 | [:td.px-2.py-2.font-semibold {:class "w-1/3"} attr] 51 | [:td.px-2.py-2 52 | (val-fn)]])) 53 | 54 | (defn entity-history [db id ] 55 | (let [history (xt/entity-history db id :asc {:with-docs? true}) 56 | changes (reverse (changes history)) 57 | initial (last changes)] 58 | (h/html 59 | [:div.entity-history.pt-4 60 | [:h3 "History"] 61 | [::h/for [{:keys [time changes] :as chg} changes 62 | :let [time (ui/format-inst time)]] 63 | [:div.history-entry 64 | [:hr] 65 | [:div.font-bold time 66 | [::h/when (= chg initial) 67 | " (initial document)"]] 68 | [:table.font-mono {:class "w-9/12"} 69 | [::h/for [[key val] (sort-by first changes) 70 | :let [key-name (pr-str key) 71 | old-val (when (not= ::no-value (::from val)) 72 | (pr-str (::from val))) 73 | new-val (when (not= ::no-value (::to val)) 74 | (::to val))]] 75 | (attr-val-row 76 | key-name 77 | (fn [] 78 | (h/html 79 | [:span 80 | [::h/when old-val 81 | [:div.line-through old-val]] 82 | [::h/when new-val 83 | [:div (ui/format-value (constantly false) new-val)]]])))]]]]]))) 84 | 85 | (defn links-to [xtdb id] 86 | (let [attrs 87 | ;; PENDING: we could also keep a set of all 88 | ;; attributes of id values we encounter when 89 | ;; displaying docs and persisting it. 90 | ;; But perhaps this is fast enough? 91 | (disj (into #{} 92 | (map key) 93 | (xt/attribute-stats xtdb)) 94 | :xt/id)] 95 | (with-open [db (xt/open-db xtdb)] 96 | (into [] 97 | (mapcat 98 | (fn [attr] 99 | (for [[from] 100 | (xt/q db {:find ['?e] 101 | :where [['?e attr 'id]] 102 | :in ['id]} id)] 103 | [attr from]))) 104 | attrs)))) 105 | 106 | (defn render-links-to [links] 107 | (h/html 108 | [:div 109 | [:table.font-mono {:class "w-9/12"} 110 | [:thead 111 | [:tr 112 | [:td "Attribute"] 113 | [:td "Document"]]] 114 | [:tbody 115 | [::h/for [[attr from] links 116 | :let [attr-name (pr-str attr)]] 117 | (attr-val-row attr-name 118 | #(ui/format-value (constantly true) from))]]]])) 119 | 120 | 121 | (defn- update-doc! [xtdb-node entity-id attribute value] 122 | ;; NOTE: this doesn't check if entity has changed 123 | (xt/submit-tx 124 | xtdb-node 125 | [[::xt/put 126 | (merge 127 | (xt/entity (xt/db xtdb-node) entity-id) 128 | {:xt/id entity-id ; if entity doesn't exist yet 129 | attribute value})]])) 130 | 131 | (defn- attr-row [key-fn content-fn] 132 | (h/html 133 | [:tr.hover:bg-gray-100 134 | [:td.px-2.py-2.font-semibold.align-top {:class "w-1/3"} 135 | (key-fn)] 136 | [:td.px-2.py-2.hover-trigger 137 | (content-fn)]])) 138 | 139 | (defn new-attr-row [xtdb-node id] 140 | (let [[rerender-source set-rerender!] (source/use-state 0) 141 | rerender! #(set-rerender! (inc (p/current-value rerender-source)))] 142 | (h/html 143 | [::h/live 144 | rerender-source 145 | (fn [_] 146 | (let [[attr-name set-attr-name!] (source/use-state "") 147 | on-change! #(do 148 | (rerender!) 149 | (update-doc! xtdb-node id 150 | (ui/parse-edn (p/current-value attr-name)) 151 | %))] 152 | (attr-row 153 | (fn [] 154 | ;; We don't want live component here, no need to rerender when it changes 155 | (ui/input "text" "" set-attr-name! 156 | :placeholder "New attr kw")) 157 | (fn [] 158 | (ui/input-any on-change! ::ui/empty)))))]))) 159 | 160 | (declare render-doc-data doc-source) 161 | 162 | (defn- inline-doc-view 163 | "Component to allow drilling down to nested documents inline without 164 | navigating to them." 165 | [{:keys [xtdb-node] :as ctx} _db id] 166 | (let [[show set-show!] (source/use-state false)] 167 | (h/html 168 | [:div {:class [::h/live (source/computed #(str "collapse collapse-" 169 | (if % "open" "close")) 170 | show)]} 171 | [:div ;; .collapse-title has way too much padding 172 | (ui/format-value (constantly true) id) 173 | 174 | [::h/live show 175 | #(h/html [:button.btn.btn-square.btn-xs.ml-1.btn-info 176 | {:on-click (partial set-show! (not %))} 177 | [::h/if % "-" "+"]])]] 178 | [:div.collapse-content 179 | [::h/when show 180 | (render-doc-data ctx id (doc-source xtdb-node id))]]]))) 181 | 182 | (defn- render-editable-value [{:keys [xtdb-node] :as ctx} db entity-id [k v]] 183 | (let [[edit? set-edit!] (source/use-state false)] 184 | (h/html 185 | [::h/live edit? 186 | (fn [edit?] 187 | (if-not edit? 188 | (let [id (when-not (vector? v) 189 | (id/valid-id? db v))] 190 | (h/html 191 | [:div.hover-trigger 192 | [:div.flex 193 | [::h/if id 194 | (inline-doc-view ctx db v) 195 | (ui/format-value (if (vector? v) 196 | (partial id/valid-id? db) 197 | (constantly id)) v)] 198 | [:div.flex-grow.flex.justify-end.items-start 199 | [:button.hover-target.fixed.bg-blue-500.rounded.px-1 200 | {:on-click #(set-edit! true)} 201 | "edit"]]]])) 202 | (ui/editor-widget-for (type v) v 203 | (partial update-doc! xtdb-node 204 | entity-id k))))]))) 205 | 206 | (defn- render-readonly-value [ctx db entity-id [k v]] 207 | (let [id (when-not (vector? v) 208 | (id/valid-id? db v))] 209 | (h/html 210 | [:div.hover-trigger 211 | [:div.flex 212 | [::h/if id 213 | (inline-doc-view ctx db v) 214 | (ui/format-value (if (vector? v) 215 | (partial id/valid-id? db) 216 | (constantly id)) v)]]]))) 217 | 218 | (defn render-doc-id-header [id] 219 | (let [id-str (pr-str id)] 220 | (h/html 221 | [:h3.bg-gray-300 "Document " 222 | [:span.font-mono id-str] 223 | [:input#doc-id {:style "display: none;" :disabled true :value id-str}] 224 | [:button.mx-2.px-2.rounded-full.bg-blue-200 225 | {:on-click "s=document.getElementById('doc-id');s.select();navigator.clipboard.writeText(s.value);"} 226 | "copy"]]))) 227 | 228 | (defn- render-doc-data [{:keys [xtdb-node allow-editing?] :as ctx} id entity-source] 229 | (ui.table/table 230 | {:key key 231 | :class "table table-compact table-zebra w-full" 232 | :columns [{:label "Attribute" :accessor key 233 | :render ui.edn/edn} 234 | {:label "Value" :accessor val 235 | :render-full (partial (if allow-editing? render-editable-value render-readonly-value) 236 | ctx (xt/db xtdb-node) id)}] 237 | :order [key :asc] 238 | :render-after (when allow-editing? 239 | #(new-attr-row xtdb-node id))} 240 | (source/computed 241 | (comp seq #(dissoc % :xt/id)) 242 | entity-source))) 243 | 244 | (defn doc-source [xtdb-node id] 245 | (source/computed 246 | ffirst 247 | (rx/q {:node xtdb-node} 248 | '{:find [(pull e [*])] 249 | :in [e]} id))) 250 | 251 | (defn render [{:keys [xtdb-node request] :as ctx}] 252 | (let [id (some-> request :params :doc-id id/read-doc-id) 253 | entity-source (doc-source xtdb-node id) 254 | [show-history-source set-show-history!] (source/use-state false)] 255 | (h/html 256 | [:div 257 | (render-doc-id-header id) 258 | (render-doc-data ctx id entity-source) 259 | 260 | [:h3.bg-gray-300 "Links from other documents"] 261 | [::h/live (future (links-to xtdb-node id)) 262 | render-links-to] 263 | 264 | [::h/live show-history-source 265 | (fn [show?] 266 | (h/html 267 | [:div 268 | [::h/if show? 269 | (entity-history (xt/db xtdb-node) id) 270 | [:button {:on-click #(set-show-history! true)} 271 | "Show history"]]]))]]))) 272 | 273 | (defn render-form [ctx] 274 | (let [doc (atom nil) 275 | set-doc! (fn [value] 276 | (binding [*read-eval* false] 277 | (reset! doc (read-string value))))] 278 | (h/html 279 | [:div.flex.flex-col.m-4 280 | [:div "Insert XTDB document id (" [:span.font-mono ":xt/id"] ") as EDN:"] 281 | [:div 282 | [:div.form-control 283 | [:div.input-group 284 | [:input#doc.input {:name "doc"}] 285 | [:button.btn.btn-primary 286 | {:on-click (js/js set-doc! (js/input-value "doc"))} 287 | "Go"]]]] 288 | [:span.font-light 289 | "Examples: 123 :hello \"some-doc\" "] 290 | (js/eval-js-from-source 291 | (source/c= 292 | (when %doc 293 | (str "window.location.pathname += \"/" 294 | (id/doc-id-param %doc) "\""))))])) ) 295 | -------------------------------------------------------------------------------- /src/xtdb_inspector/page/query.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.page.query 2 | "Edit and run a query" 3 | (:require [ripley.html :as h] 4 | [ripley.live.source :as source] 5 | [ripley.js :as js] 6 | [xtdb.api :as xt] 7 | [xtdb-inspector.ui :as ui] 8 | [ripley.integration.xtdb :as rx] 9 | [ripley.live.protocols :as p] 10 | xtdb.query 11 | [xtdb-inspector.id :as id] 12 | [clojure.string :as str] 13 | [xtdb-inspector.ui.tabs :as ui.tabs] 14 | [xtdb-inspector.ui.table :as ui.table] 15 | [xtdb-inspector.ui.chart :as ui.chart] 16 | [clojure.set :as set] 17 | [ring.util.io :as ring-io] 18 | [clojure.java.io :as io])) 19 | 20 | (def last-query (atom {:query-text "{:find []\n :where []\n :limit 100}" 21 | :in-args {}})) 22 | 23 | (def codemirror-js 24 | ["https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.1/codemirror.min.js" 25 | "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.1/mode/clojure/clojure.min.js"]) 26 | 27 | (def codemirror-css "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.1/codemirror.min.css") 28 | 29 | (defn safe-read [x] 30 | (try 31 | (binding [*read-eval* false] 32 | (let [item (read-string x)] 33 | (if-not (map? item) 34 | {:error (str "Expected map query definition. Got: " (type item))} 35 | {:q item}))) 36 | (catch Throwable t 37 | {:error (.getMessage t)}))) 38 | 39 | (defn- state-val [[state _]] 40 | (p/current-value state)) 41 | 42 | (defn- swap-state! [[_ set-state! :as state] update-fn & args] 43 | (set-state! (apply update-fn (state-val state) args))) 44 | 45 | (defn query-result-and-count-sources [db q args on-error!] 46 | (let [[results set-results!] (source/use-state []) 47 | result-count (atom 0)] 48 | (future 49 | (try 50 | (with-open [res (apply xt/open-q db q args)] 51 | (doseq [p (->> res iterator-seq (partition-all 100))] 52 | (swap! result-count + (count p)) 53 | (set-results! (into (p/current-value results) p)))) 54 | (catch Throwable t 55 | (on-error! (str "Error in query: " (.getMessage t)))))) 56 | [results result-count])) 57 | 58 | (defn- query! [xtdb-node state query-str] 59 | (let [merge-state! (partial swap-state! state merge) 60 | {:keys [q error]} (safe-read query-str) 61 | {:keys [in-args in-args-names]} (state-val state) 62 | args (when in-args 63 | (map in-args in-args-names))] 64 | (reset! last-query {:query-text query-str 65 | :in-args in-args}) 66 | (if error 67 | (merge-state! {:error? true 68 | :error-message error 69 | :running? false 70 | :results nil}) 71 | (do 72 | (merge-state! {:running? true 73 | :error? false 74 | :results nil}) 75 | (try 76 | (let [db (xt/db xtdb-node)] 77 | (merge-state! {:running? false 78 | :error? false 79 | :results (query-result-and-count-sources 80 | db q args 81 | #(merge-state! {:error? true 82 | :error-message % 83 | :running? false 84 | :results nil})) 85 | :query q 86 | :timing (System/nanoTime) 87 | :basis (xt/db-basis db)})) 88 | (catch Throwable e 89 | (merge-state! {:error? true 90 | :error-message (str "Error in query: " (.getMessage e)) 91 | :running? false 92 | :results nil}))))))) 93 | 94 | (defn- loading [label] 95 | (h/html 96 | [:div.flex 97 | [:svg.animate-spin.-ml-1.mr-3.h-5.w-5.text-black 98 | {:xmlns "http://www.w3.org/2000/svg" 99 | :fill "none" 100 | :viewBox "0 0 24 24"} 101 | [:circle.opacity-25 {:cx 12 :cy 12 :r 10 :stroke "currentColor" 102 | :stroke-width 4}] 103 | [:path.opacity-75 104 | {:fill "currentColor" 105 | :d "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]] 106 | label])) 107 | 108 | (defn unpack-find-defs 109 | "Unpack the :find definitions into header name and accessor patterns. 110 | This separates keywords specified in pull patterns to own columns 111 | in the result table. 112 | 113 | Also determines which vars are ids based on query." 114 | [query] 115 | (let [normalized (xtdb.query/normalize-query query) 116 | id? (set/union 117 | ;; All symbols in the E position in EAV where triples 118 | (into #{} 119 | (for [w (:where normalized) 120 | :when (and (vector? w) 121 | (symbol? (first w)))] 122 | (first w))) 123 | ;; All symbols in first position of pull finds 124 | (into #{} 125 | (for [f (:find normalized) 126 | :when (and (list? f) 127 | (= 'pull (first f)))] 128 | (second f)))) 129 | column-accessors 130 | (or (some->> query :keys (map keyword)) 131 | (range))] 132 | (mapcat 133 | (fn [header column-accessor] 134 | (let [column-name (when (not (number? column-accessor)) 135 | (name column-accessor))] 136 | (if (and (coll? header) 137 | (= 'pull (first header)) 138 | (every? #(not= '* %) (nth header 2))) 139 | ;; pull pattern that has no star, generate column for each 140 | (for [k (nth header 2) 141 | :let [k (if (map? k) 142 | ;; nested pull 143 | (first (keys k)) 144 | ;; attribute pull 145 | k)]] 146 | {:label (str (when column-name 147 | (str column-name ": ")) (name k)) 148 | :accessor #(get-in % [column-accessor k]) 149 | :id? (when (= :xt/id k) 150 | true)}) 151 | 152 | ;; any other result 153 | [{:label (or column-name (pr-str header)) 154 | :accessor #(get-in % [column-accessor]) 155 | :id? (when (id? header) true)}]))) 156 | (:find normalized) column-accessors))) 157 | 158 | (defn- bar-chartable? [find-defs] 159 | (and (= 2 (count find-defs)) 160 | (some #(str/starts-with? (:label %) "(count ") find-defs))) 161 | 162 | 163 | (defn- duration [ms] 164 | (cond 165 | (> ms 1000) 166 | (format "%.2fs" (/ ms 1000.0)) 167 | 168 | (> ms 100) 169 | (format "%.0fms" ms) 170 | 171 | :else 172 | (format "%.2fms" ms))) 173 | 174 | (defn query-results-table [db headers result-source] 175 | (ui.table/table 176 | {:key identity 177 | ;; Set render method that uses format value 178 | :columns (mapv (fn [{id? :id? :as hdr}] 179 | (let [is-id? (fn [v] 180 | (if (some? id?) 181 | id? 182 | (id/valid-id? db v)))] 183 | (assoc hdr 184 | :render (partial ui/format-value is-id?)))) 185 | headers)} 186 | result-source)) 187 | 188 | (defn render-results [xtdb-node {:keys [basis running? results query timing] :as r}] 189 | (cond 190 | ;; Query is running 191 | running? 192 | (loading "Querying...") 193 | 194 | ;; No query has been made yet and no query is running 195 | (nil? results) 196 | (h/html [:span]) 197 | 198 | ;; Query has been run and results are available 199 | :else 200 | (let [[result-source count-source] results 201 | db (xt/db xtdb-node) 202 | headers (unpack-find-defs query)] 203 | (h/html 204 | [:div 205 | [:div.text-sm 206 | [::h/live count-source 207 | #(h/html [:span 208 | (h/dyn! %) 209 | " results in " 210 | (h/dyn! (duration (/ (- (System/nanoTime) timing) 1e6)))])]] 211 | (ui.tabs/tabs 212 | {:label "Table" 213 | :render 214 | (fn [] 215 | (query-results-table db headers result-source))} 216 | (when (bar-chartable? headers) 217 | {:label "Bar chart" 218 | :render 219 | (fn [] 220 | (let [{[label-header] false 221 | [value-header] true} 222 | (group-by #(str/starts-with? (:label %) "(count ") 223 | headers) 224 | value-accessor (:accessor value-header) 225 | label-accessor (:accessor label-header)] 226 | (h/html 227 | [:div 228 | (ui.chart/bar-chart 229 | {:value-accessor value-accessor 230 | :label-accessor label-accessor} 231 | result-source)])))}))])))) 232 | 233 | (defn saved-queries [db] 234 | (xt/q db '{:find [?e ?n] 235 | :keys [id name] 236 | :where [[?e :xtdb-inspector.saved-query/name ?n]]})) 237 | 238 | (defn save-query! [xtdb-node in-args name query] 239 | (when (and (not (str/blank? name)) 240 | (not (str/blank? query))) 241 | (let [existing-query-id (ffirst 242 | (xt/q (xt/db xtdb-node) 243 | '{:find [?q] 244 | :where [[?q :xtdb-inspector.saved-query/name ?n]] 245 | :in [?n]} 246 | name))] 247 | (xt/submit-tx 248 | xtdb-node 249 | [[::xt/put {:xt/id (or existing-query-id (java.util.UUID/randomUUID)) 250 | :xtdb-inspector.saved-query/name name 251 | :xtdb-inspector.saved-query/query query 252 | :xtdb-inspector.saved-query/in-args in-args}]])))) 253 | 254 | (defn saved-queries-ui [xtdb-node state] 255 | (let [[query load-query!] (source/use-state nil) 256 | query-source 257 | (source/computed 258 | #(str "window.location.search='?query='+encodeURIComponent('" % "')") 259 | query)] 260 | (h/html 261 | [:div 262 | (js/eval-js-from-source query-source) 263 | 264 | [:div.flex.flex-row 265 | [:div.form-control 266 | [:div.input-group.input-group-sm 267 | [:input#save-query-as.input.input-bordered.input-sm {:placeholder "Save query as"}] 268 | [:button.btn.btn-square.btn-sm 269 | {:on-click (js/js (fn [name query-text] 270 | (save-query! xtdb-node 271 | (:in-args (state-val state)) 272 | name query-text)) 273 | (js/input-value "save-query-as") 274 | "editor.getDoc().getValue()")} 275 | "Save"]]] 276 | [:div.divider.divider-horizontal] 277 | [::h/live (rx/q {:node xtdb-node :should-update? (constantly true)} saved-queries) 278 | (fn [queries] 279 | (h/html 280 | [:select.select.select-bordered.w-full.max-w-xs.select-sm 281 | {:name "saved-query" 282 | :on-change (js/js load-query! js/change-value)} 283 | [:option {:disabled true :selected true} "Load saved query"] 284 | [::h/for [{:keys [name]} queries] 285 | [:option {:value name} name]]]))]]]))) 286 | 287 | (defn- saved-query-by-name [db name] 288 | (ffirst 289 | (xt/q db '{:find [(pull query [*])] 290 | :where [[query :xtdb-inspector.saved-query/name name]] 291 | :in [name]} 292 | name))) 293 | 294 | (defn- in-args-table [set-arg! {:keys [in-args-names in-args] :as foo}] 295 | (h/html 296 | [:div {:class "query-in-args w-1/2"} 297 | [::h/when (seq in-args-names) 298 | [:table.table.table-compact 299 | [:thead 300 | [:tr [:td "Argument"] [:td "Value"]]] 301 | [:tbody 302 | [::h/for [arg in-args-names 303 | :let [arg-name (name arg)]] 304 | [:tr 305 | [:td arg-name] 306 | [:td (ui/input-any #(set-arg! arg %) 307 | (get in-args arg ::ui/empty))]]]]]]])) 308 | 309 | (defn validate! [swap-state! text] 310 | (when-let [{q :q} (safe-read text)] 311 | (swap-state! assoc :in-args-names (:in q)))) 312 | 313 | 314 | 315 | (defn render [{:keys [xtdb-node request]}] 316 | (def *r request) 317 | (let [{:keys [query-text in-args] :as foo} 318 | (or 319 | (some-> request :params (get "query") 320 | (as-> n (saved-query-by-name (xt/db xtdb-node) n)) 321 | (set/rename-keys {:xtdb-inspector.saved-query/query :query-text 322 | :xtdb-inspector.saved-query/in-args :in-args})) 323 | @last-query) 324 | 325 | [src _ :as state] 326 | (source/use-state {:query nil 327 | :in-args-names (some-> query-text safe-read :q :in) 328 | :in-args (or in-args {}) 329 | :running? false 330 | :results nil 331 | :error? false}) 332 | query! (partial query! xtdb-node state) 333 | on-change! (js/js-debounced 334 | 500 335 | (partial validate! (partial swap-state! state)) 336 | "editor.getDoc().getValue()")] 337 | (h/html 338 | [:div 339 | [::h/for [js codemirror-js] 340 | [:script {:src js}]] 341 | [:link {:rel "stylesheet" :href codemirror-css}] 342 | [:h2 "Query"] 343 | [:div.flex.flex-row.w-full 344 | [:div {:class "unreset w-1/2"} 345 | [:textarea#query query-text]] 346 | [:div.divider.divider-vertical] 347 | [::h/live (source/c= (select-keys %src [:in-args-names :in-args])) 348 | (partial in-args-table 349 | (fn [arg val] 350 | (swap-state! state update :in-args assoc arg val)))] 351 | [:script 352 | (h/out! 353 | "var editor = CodeMirror.fromTextArea(document.getElementById('query'), {" 354 | "lineNumbers: true," 355 | "autoCloseBrackets: true," 356 | "matchBrackets: true," 357 | "mode: 'text/x-clojure'" 358 | "}); " 359 | "editor.on('change', _=> document.getElementById('validateq').click());")] 360 | [:button#validateq.hidden {:on-click on-change!}]] 361 | 362 | (saved-queries-ui xtdb-node state) 363 | [::h/live (source/c= (select-keys %src [:error? :error-message])) 364 | (fn [{:keys [error? error-message] :as f}] 365 | (h/html 366 | [:span 367 | [::h/if error? 368 | [:div.bg-red-300.border-2 error-message] 369 | [:span]]]))] 370 | [:div.flex.py-2 371 | [:button.btn.btn-primary.btn-sm 372 | {:on-click (js/js query! 373 | "editor.getDoc().getValue()")} 374 | "Run query"] 375 | 376 | [:form#export-f.px-2 {:name :export 377 | :action "query/export" 378 | :method :POST 379 | :enctype "application/x-www-form-urlencoded"} 380 | [:input#export-i {:type :hidden :name :query}] 381 | [:button.btn.btn-secondary.btn-sm 382 | {:on-click "document.querySelector('#export-i').value=editor.getDoc().getValue(); document.forms['export'].submit();"} 383 | "Export (EDN)"]]] 384 | 385 | [:div.divider.divider-vertical] 386 | 387 | [::h/live (source/c= (select-keys %src 388 | [:basis :running? :results :query :timing])) 389 | (partial render-results xtdb-node)]]))) 390 | 391 | (defn export-query [{xtdb-node :xtdb-node :as _ctx} 392 | {{query "query"} :params :as _req}] 393 | 394 | (let [{:keys [q error]} (safe-read query)] 395 | (if q 396 | {:status 200 397 | :headers {"Content-Type" "application/edn" 398 | "Content-Disposition" 399 | (str "attachment; filename=\"query-" 400 | (.format (java.text.SimpleDateFormat. "yyyy-MM-dd_HH:mm:ss") (java.util.Date.)) 401 | ".edn\"")} 402 | :body (ring-io/piped-input-stream 403 | (fn [ostream] 404 | (let [db (xt/db xtdb-node)] 405 | (with-open [out (io/writer ostream)] 406 | (.write out "{:query ") 407 | (.write out (pr-str q)) 408 | (.write out "\n :db-basis ") 409 | (.write out (pr-str (xt/db-basis db))) 410 | (.write out "\n :results [\n") 411 | (with-open [results (xt/open-q db q)] 412 | (doseq [result (iterator-seq results)] 413 | (.write out (pr-str result)) 414 | (.write out "\n"))) 415 | (.write out "]}")))))} 416 | 417 | {:status 400 418 | :body (str "Don't understand query: " error)}))) 419 | -------------------------------------------------------------------------------- /src/xtdb_inspector/page/tx.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.page.tx 2 | "Transaction log page" 3 | (:require [xtdb.api :as xt] 4 | [ripley.html :as h] 5 | [xtdb-inspector.ui :as ui] 6 | [xtdb-inspector.ui.table :as ui.table] 7 | [ripley.live.source :as source] 8 | [ripley.live.protocols :as p] 9 | [xtdb-inspector.ui.edn :as ui.edn] 10 | [ripley.live.collection :as collection])) 11 | 12 | (defn latest-tx-source [xtdb-node] 13 | (let [source-atom (atom nil) 14 | listener (xt/listen xtdb-node {::xt/event-type ::xt/indexed-tx} 15 | (fn [{tx-id ::xt/tx-id}] 16 | (when-let [source @source-atom] 17 | (p/write! source tx-id)))) 18 | [source _ _] (source/source-with-listeners 19 | #(::xt/tx-id (xt/latest-completed-tx xtdb-node)) 20 | #(.close listener))] 21 | (reset! source-atom source))) 22 | 23 | (defn transactions [xtdb-node] 24 | (source/computed 25 | #(with-open [log (xt/open-tx-log xtdb-node (max 0 (- % 100)) false)] 26 | (vec (reverse (iterator-seq log)))) 27 | (latest-tx-source xtdb-node))) 28 | 29 | (defn tx-table [tx-log-source on-tx-selected] 30 | (ui.table/table 31 | {:key ::xt/tx-id 32 | :columns [{:label "#" :accessor ::xt/tx-id} 33 | {:label "Timestamp" :accessor ::xt/tx-time 34 | :render #(h/out! (ui/format-inst %))} 35 | {:label "Events" :accessor ::xt/tx-events 36 | :render #(h/out! (count %))}] 37 | :on-row-click on-tx-selected} 38 | tx-log-source)) 39 | 40 | (defn tx-op->maps [[op & data :as tx-op]] 41 | (case op 42 | ::xt/put 43 | (let [[{id :xt/id :as doc} 44 | valid-time-start valid-time-end] data] 45 | [{:operation op 46 | :id id 47 | :payload doc 48 | :valid-time-start valid-time-start 49 | :valid-time-end valid-time-end}]) 50 | 51 | ::xt/delete 52 | (let [[document valid-time-start valid-time-end] data] 53 | [{:operation op 54 | :payload document 55 | :valid-time-start valid-time-start 56 | :valid-time-end valid-time-end}]) 57 | 58 | ::xt/match 59 | (let [[id valid-time & ops] data] 60 | (into [{:operation op 61 | :id id 62 | :payload "" 63 | :valid-time-start valid-time 64 | :valid-time-end valid-time}] 65 | (tx-op->maps ops))) 66 | 67 | ::xt/evict 68 | [{:operation op 69 | :id (first data)}] 70 | 71 | ;; Fallback 72 | [{:operation op 73 | :payload tx-op}])) 74 | 75 | (defn- fetch-tx-ops [xtdb-node tx-id] 76 | (with-open [log (xt/open-tx-log xtdb-node (dec tx-id) true)] 77 | (::xt/tx-ops (.next log)))) 78 | 79 | (defn tx-op-card [{:keys [operation id payload valid-time-start valid-time-end] :as _tx-op}] 80 | (let [op-name (name operation)] 81 | (h/html 82 | [:div.card.bg-base-100.w-full.shadow-xl.mb-2 83 | [:div.card-body 84 | [:div.card-title 85 | [:h2 op-name " " 86 | (ui/format-value (constantly true) id)]] 87 | [:div 88 | (ui.edn/edn payload)] 89 | 90 | [::h/when valid-time-start 91 | [:div 92 | "Valid time start: " 93 | (ui/format-value (constantly false) valid-time-start)]] 94 | [::h/when valid-time-end 95 | [:div 96 | "Valid time end: " 97 | (ui/format-value (constantly false) valid-time-end)]]]]))) 98 | 99 | (defn tx-details [xtdb-node tx-details-source] 100 | (collection/live-collection 101 | {:key identity 102 | :render tx-op-card 103 | :source (source/computed 104 | #(let [{id ::xt/tx-id} %] 105 | (when id 106 | (into [] 107 | (mapcat tx-op->maps) 108 | (fetch-tx-ops xtdb-node (::xt/tx-id %))))) 109 | tx-details-source)})) 110 | 111 | (defn render [{:keys [xtdb-node] :as _ctx}] 112 | (let [[tx-details-source set-tx!] (source/use-state {})] 113 | (h/html 114 | [:div.transactions.flex 115 | [:div {:class "w-2/6"} 116 | [:h3 "Latest transactions"] 117 | (tx-table (transactions xtdb-node) set-tx!)] 118 | [:div.divider.divider-horizontal] 119 | [:div {:class "w-4/6"} 120 | [:h3 "Transaction details"] 121 | (tx-details xtdb-node tx-details-source)]]))) 122 | -------------------------------------------------------------------------------- /src/xtdb_inspector/ui.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.ui 2 | "Common UI components for inspector." 3 | (:require [xtdb-inspector.id :as id] 4 | [ripley.html :as h] 5 | [clojure.string :as str] 6 | [ripley.js :as js] 7 | [xtdb-inspector.util :refer [root-path]] 8 | [xtdb-inspector.ui.edn :as ui.edn] 9 | [ripley.live.source :as source] 10 | [ripley.live.protocols :as p]) 11 | (:import (java.time LocalDate LocalTime LocalDateTime Duration Instant) 12 | (java.time.format DateTimeFormatter FormatStyle))) 13 | 14 | 15 | (defn format-inst [inst] 16 | (.format (java.text.SimpleDateFormat. "yyyy-MM-dd HH:mm:ss.SSS") inst)) 17 | 18 | 19 | (defn link [href title] 20 | (h/html 21 | [:a.underline.bg-blue-200.px-1 {:href href} title])) 22 | 23 | ;; Method to display (as human readable), should return string 24 | (defmulti display (fn [value] (type value))) 25 | 26 | (defmethod display LocalDate 27 | [date] 28 | (.format date (DateTimeFormatter/ofLocalizedDate FormatStyle/MEDIUM))) 29 | 30 | (defmethod display LocalTime 31 | [time] 32 | (.format time (DateTimeFormatter/ofLocalizedTime FormatStyle/MEDIUM))) 33 | 34 | (defmethod display LocalDateTime 35 | [date-time] 36 | (.format date-time (DateTimeFormatter/ofLocalizedDateTime FormatStyle/MEDIUM))) 37 | 38 | (defmethod display Duration 39 | [d] 40 | (str d)) 41 | 42 | (defmethod display Instant [i] (str i)) 43 | 44 | (defmethod display :default [_] ::no-custom-display) 45 | 46 | (def short-class-name (comp last #(str/split % #"\.") str)) 47 | (def class-of (comp short-class-name type)) 48 | 49 | (defn format-value 50 | "Format a given value, if it is a valid id, render a link to view it." 51 | [is-id? value] 52 | (if (vector? value) 53 | (ui.edn/edn {:render-item (fn [_ctx v] 54 | (format-value is-id? v))} 55 | value) 56 | (let [href (when (is-id? value) 57 | (str root-path "/doc/" (id/doc-id-param value))) 58 | disp (display value) 59 | custom-display? (not= ::no-custom-display disp) 60 | stringified (if-not custom-display? 61 | (pr-str value) 62 | value) 63 | type (when (not= disp ::no-custom-display) 64 | (class-of value))] 65 | (h/html 66 | [::h/if href 67 | (link href stringified) 68 | [::h/if custom-display? 69 | [:div.inline-block 70 | stringified 71 | [::h/when type 72 | [:span.text-xs " (" type ")"]]] 73 | [:div.inline-block 74 | (ui.edn/edn value)]]])))) 75 | 76 | (defmulti editor-widget-for 77 | (fn [value-type _initial-value _set-value!] 78 | value-type)) 79 | 80 | (defn input [type initial-value set-value! & {:keys [placeholder]}] 81 | (let [id (str (gensym "edit"))] 82 | (h/html 83 | [:div 84 | [:input.input.input-sm.input-bordered.w-full 85 | {:autofocus true 86 | :type type 87 | :value initial-value 88 | :placeholder (or placeholder "") 89 | :id id 90 | :on-key-press "if(event.keyCode==13) event.target.blur()" 91 | :on-blur (js/js set-value! (js/input-value id))}]]))) 92 | 93 | (defn- format-for-edit [formatter value] 94 | (if (= value ::empty) 95 | "" 96 | (formatter value))) 97 | 98 | ;; Create an editor widget for the given value by type 99 | (defmethod editor-widget-for LocalDate 100 | [_ date set-value!] 101 | (input "date" (format-for-edit str date) 102 | #(-> % LocalDate/parse set-value!))) 103 | 104 | (defmethod editor-widget-for LocalTime 105 | [_ date set-value!] 106 | (input "time" (format-for-edit str date) 107 | #(-> % LocalTime/parse set-value!))) 108 | 109 | (defmethod editor-widget-for LocalDateTime 110 | [_ datetime set-value!] 111 | (input "datetime-local" (format-for-edit str datetime) 112 | #(-> % LocalDateTime/parse set-value!))) 113 | 114 | (defmethod editor-widget-for Duration 115 | [_ duration set-value!] 116 | (input "text" (format-for-edit str duration) 117 | #(-> % Duration/parse set-value!))) 118 | 119 | (defn parse-edn [edn-string] 120 | (binding [*read-eval* false] 121 | (read-string edn-string))) 122 | 123 | (defmethod editor-widget-for Instant 124 | [_ instant set-value!] 125 | (input "text" (format-for-edit str instant) 126 | #(-> % Instant/parse set-value!))) 127 | 128 | (defmethod editor-widget-for :default [_ v set-value!] 129 | (input "text" (format-for-edit pr-str v) (comp set-value! parse-edn) 130 | :placeholder "EDN")) 131 | 132 | (defn editor-types [] 133 | (-> editor-widget-for 134 | methods 135 | (dissoc :default) 136 | keys)) 137 | 138 | (defn input-any 139 | "Generic component to edit an input value. Defaults to EDN 140 | text input, but shows a selection to input any value that 141 | has an editor multimethod." 142 | [on-change! value] 143 | (let [type (or (some #(when (= (class value) %) %) (editor-types)) 144 | :edn) 145 | 146 | [value-type set-value-type!] (source/use-state type)] 147 | (h/html 148 | [:div.form-control 149 | [:div.input-group.input-group-sm 150 | [:select.select.select-sm.select-bordered 151 | {:on-change (js/js #(set-value-type! 152 | (or (first (filter (fn [t] 153 | (= % (short-class-name t))) 154 | (editor-types))) 155 | :edn)) 156 | "event.target.value")} 157 | [:option {:value "EDN" 158 | :selected (= :edn type)} "EDN"] 159 | [::h/for [cls (editor-types) 160 | :let [class-name (short-class-name cls) 161 | selected (= cls type)]] 162 | [:option {:value class-name :selected selected} class-name]]] 163 | 164 | [::h/live value-type 165 | (fn [type] 166 | (editor-widget-for 167 | type value 168 | (fn [to] 169 | (on-change! to))))]]]))) 170 | -------------------------------------------------------------------------------- /src/xtdb_inspector/ui/chart.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.ui.chart 2 | (:require [ripley.html :as h] 3 | [ripley.live.source :as source] 4 | [ripley.live.collection :as collection] 5 | [ripley.template :as template])) 6 | 7 | 8 | (defn bar-chart 9 | "Simple top to bottom bar chart for showing relative 10 | counts of different items. 11 | 12 | Options: 13 | :width Width of the SVG image. Defaults to \"100%\". 14 | :bar-height Height of a single bar. Defaults to 30. 15 | The SVG height will be :bar-height * (count bars). 16 | 17 | :label-accessor 18 | Accessor to get the label for an item. 19 | Defaults to :label. 20 | :value-accessor 21 | Accessor to get the value for an item. 22 | Defaults to :value. 23 | 24 | Bars-source must be a source that provides a collection 25 | of bars, which are maps. 26 | Each item must be a map contain a value and a label obtained 27 | by calling value-accessor and label-accessor respectively. 28 | 29 | eg. 30 | (bar-chart 31 | {:width 600 :bar-height 20} 32 | [{:label \"Yes\" :value 420} 33 | {:label \"No\" :value 67} 34 | {:label \"Undecided\" :value 10}]) 35 | " 36 | [{:keys [width bar-height 37 | value-accessor label-accessor] 38 | :or {width "100%" 39 | bar-height 30 40 | value-accessor :value 41 | label-accessor :label}} 42 | bars-source] 43 | (let [max-source (source/computed 44 | #(reduce max 1 (map value-accessor %)) 45 | bars-source) 46 | top 30 47 | height (source/computed #(+ top (* bar-height (count %))) bars-source) 48 | viewbox (source/computed #(str "0 0 600 " (+ top (* bar-height (count %)))) bars-source) 49 | ;; Add indexes to our bars so that we can calculate y position 50 | bars-source (source/computed 51 | (fn [bars] 52 | (let [max (reduce max 1 (map value-accessor bars))] 53 | (into [] 54 | (map-indexed 55 | (fn [i item] 56 | (let [value (value-accessor item) 57 | label (label-accessor item) 58 | y (double (+ top (* bar-height (+ i 0.1)))) 59 | w (double (* 300 (/ value max))) 60 | value-and-label (str value " " label)] 61 | {:i i 62 | :y y 63 | :w w 64 | :text-y (+ y (/ bar-height 2)) 65 | :value-and-label value-and-label}))) 66 | bars))) 67 | bars-source) 68 | tick-source (fn [pct] 69 | (source/computed 70 | (fn [max height] 71 | {:value (Math/round (* pct max)) 72 | :max max 73 | :height height}) 74 | max-source height)) 75 | tick (fn [{:keys [value max height]}] 76 | (let [x (double (* 300 (/ value max)))] 77 | (h/html 78 | [:g 79 | [:text {:text-anchor "middle" 80 | :x x :y 15 81 | :font-size "0.5em"} 82 | value] 83 | [:line {:x1 x :x2 x 84 | :y1 25 :y2 height 85 | :stroke-dasharray "3 3" 86 | :stroke-width "3" 87 | :stroke "black"}]]))) 88 | id (gensym "barchart")] 89 | (h/html 90 | [:span 91 | (template/use-template 92 | (fn [{:keys [y w text-y value-and-label]}] 93 | (h/html 94 | [:g 95 | [:rect.text-primary {:y y 96 | :width w 97 | :height (* 0.8 bar-height)}] 98 | [:text.text-info {:x 310 99 | :y text-y} 100 | value-and-label]])) 101 | (str "#" id) 102 | bars-source) 103 | [:svg {:width width 104 | :viewBox [::h/live viewbox] 105 | :fill "currentColor" 106 | :preserveAspectRatio "xMinYMin"} 107 | [:g {:id id}] 108 | [:g.ticks 109 | ;; add 25%, 50% and 75% ticks 110 | [::h/live (tick-source 0.25) tick] 111 | [::h/live (tick-source 0.50) tick] 112 | [::h/live (tick-source 0.75) tick]]]]))) 113 | 114 | 115 | (defn- pie-items 116 | [{:keys [value-accessor label-accessor max-items other-label] 117 | :or {value-accessor :value 118 | label-accessor :label 119 | max-items 4 120 | other-label "Other"}} items] 121 | (let [items (reverse 122 | (sort-by second (mapv (juxt label-accessor value-accessor) items)))] 123 | (if (> (count items) (inc max-items)) 124 | ;; If too many items, summarize rest 125 | (conj (vec (take max-items items)) 126 | [other-label 127 | (reduce + (map second (drop max-items items)))]) 128 | 129 | ;; Return items as is 130 | items))) 131 | 132 | (defn pie 133 | "Render a pie chart. 134 | 135 | Options: 136 | :width Width in CSS (defaults to 100%) 137 | :height Height in CSS (defaults to 100%) 138 | :label-accessor 139 | Function to get label from item (defaults to :label) 140 | :value-accessor 141 | Function to get value from item (defaults to :value) 142 | :max-items How many items to render (defaults to 4). 143 | Shows top N items as own slices and summarizes 144 | extra items as the \"other\" slice. 145 | :other-label 146 | Label to show for items that are summed (defaults to \"Other\"). 147 | :colors Optional vector of colors to use. 148 | If there are less colors than max-items+1 149 | the same color will be repeated. 150 | :format-value 151 | Function to format value to show after legend. 152 | Defaults to (constantly \"\") (eg. not showing it) 153 | 154 | " 155 | [{:keys [width height colors format-value] :as config 156 | :or {width "100%" height "100%" 157 | colors ["#6050DC" "#D52DB7" "#FF2E7E" "#FF6B45" "#FFAB05"] 158 | format-value (constantly "")}} 159 | slices-source] 160 | (let [x (fn x 161 | ([ang] (x 1.0 ang)) 162 | ([r ang] (* r (Math/cos ang)))) 163 | y (fn y 164 | ([ang] (y 1.0 ang)) 165 | ([r ang] (* r (Math/sin ang)))) 166 | pos (fn pos 167 | ([ang] (pos 1.0 ang)) 168 | ([r ang] 169 | (str (x r ang) "," (y r ang)))) 170 | slices (source/computed 171 | (fn [items] 172 | (let [items (pie-items config items) 173 | sum (reduce + 0 (map second items)) 174 | rads #(- (* 2 Math/PI (/ % sum)) (/ Math/PI 2))] 175 | (loop [[s & items] items 176 | acc [] 177 | total 0 178 | [c & colors] (cycle colors)] 179 | (if-not s 180 | acc 181 | (let [[label value] s 182 | start (rads total) 183 | end (rads (+ total value)) 184 | large (if (< (- end start) Math/PI) "0" "1") 185 | half-ang (+ start (/ (- end start) 2)) 186 | tx (x 0.7 half-ang) 187 | ty (y 0.7 half-ang)] 188 | (recur items 189 | (conj acc {:value (format-value value) 190 | :label label 191 | :start start 192 | :end end 193 | :d (str "M " (pos start) 194 | " A 1 1 0 " large " 1 " 195 | (pos end) 196 | " L 0,0") 197 | :color c 198 | :text-x tx :text-y ty 199 | :percentage (format "%.1f%%" 200 | (* 100.0 (/ value sum))) 201 | :legend-style (str "background-color:" c ";")}) 202 | (+ total value) 203 | colors)))))) 204 | slices-source) 205 | slices-id (gensym "pie") 206 | legend-id (gensym "lg")] 207 | 208 | ;; Render SVG pie slices using template 209 | (template/use-template 210 | (fn [{:keys [d color text-x text-y percentage]}] 211 | (h/html 212 | [:g.slice 213 | [:path {:d d 214 | :fill color 215 | :stroke "black" 216 | :stroke-width 0.01}] 217 | [:text {:x text-x :y text-y 218 | :text-anchor "middle" 219 | :alignment-baseline "middle" 220 | :font-size "0.1"} percentage]])) 221 | (str "#" slices-id) 222 | slices) 223 | 224 | ;; Render legend using template 225 | (template/use-template 226 | (fn [{:keys [legend-style label value]}] 227 | (h/html 228 | [:div.whitespace-nowrap 229 | [:div.inline-block.w-4.h-4 {:style legend-style}] 230 | [:span.mx-2 label " " value]])) 231 | (str "#" legend-id) 232 | slices) 233 | 234 | (h/html 235 | [:div.flex.items-center 236 | [:svg {:viewBox "-1.1 -1.1 2.2 2.2"} 237 | [:g.pie.text-primary {:id slices-id}]] 238 | [:div.flex.flex-col {:id legend-id}]]))) 239 | -------------------------------------------------------------------------------- /src/xtdb_inspector/ui/edn.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.ui.edn 2 | "Pretty HTML rendering of arbitrary EDN." 3 | (:require [ripley.html :as h])) 4 | 5 | (defmulti render (fn [_ctx item] (type item))) 6 | 7 | (defmethod render :default [_ item] 8 | (let [str (pr-str item)] 9 | (h/html [:span str]))) 10 | 11 | (defmethod render java.lang.String [_ctx str] 12 | (h/html [:span.text-lime-500 "\"" str "\""])) 13 | 14 | (defmethod render java.lang.Number [_ctx num] 15 | (let [str (pr-str num)] 16 | (h/html [:span.text-red-300 str]))) 17 | 18 | (defmethod render clojure.lang.Keyword [_ctx kw] 19 | (let [str (pr-str kw)] 20 | (h/html [:span.text-emerald-700 str]))) 21 | 22 | (defmethod render clojure.lang.PersistentVector [{render-item :render-item :as ctx 23 | :or {render-item render}} vec] 24 | (let [cls (str "inline-flex space-x-2 flex-wrap " 25 | (if (every? map? vec) 26 | "flex-col" 27 | "flex-row"))] 28 | (h/html 29 | [:div.flex 30 | "[" 31 | [:div {:class cls} 32 | [::h/for [v vec] 33 | [:div.inline-block (render-item (dissoc ctx :render-item) v)]]] 34 | "]"]))) 35 | 36 | (defmethod render clojure.lang.IPersistentMap [ctx m] 37 | (if (empty? m) 38 | (h/html [:div.inline-block "{}"]) 39 | (let [entries (seq m) 40 | normal-entries (butlast entries) 41 | last-entry (last entries)] 42 | (h/html 43 | [:div.inline-block.flex 44 | "{" 45 | [:table 46 | [::h/for [[key val] normal-entries] 47 | [:tr.whitespace-pre 48 | [:td.align-top.py-0.pl-0.pr-2 (render ctx key)] 49 | [:td.align-top.p-0 (render ctx val)]]] 50 | [:tr.whitespace-pre 51 | [:td.align-top.py-0.pl-0.pr-2 (render ctx (key last-entry))] 52 | [:td.align-top.p-0 [:div.inline-block (render ctx (val last-entry))] "}"]]]])))) 53 | 54 | (defn edn 55 | ([thing] (edn {} thing)) 56 | ([ctx thing] 57 | (render ctx thing))) 58 | -------------------------------------------------------------------------------- /src/xtdb_inspector/ui/table.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.ui.table 2 | "A table component with filtering." 3 | (:require [ripley.html :as h] 4 | [ripley.live.source :as source] 5 | [ripley.live.collection :as collection] 6 | [clojure.string :as str] 7 | [ripley.js :as js])) 8 | 9 | (defn- default-filter-fn 10 | "A very simple text filter, just checks if printed representation 11 | includes the string (ignoring case)." 12 | [item text] 13 | (str/includes? (str/lower-case (pr-str item)) 14 | (str/lower-case text))) 15 | 16 | (def generic-comparator 17 | (reify java.util.Comparator 18 | (compare [_ o1 o2] 19 | (if (and (instance? java.lang.Comparable o1) 20 | (= (type o1) (type o2))) 21 | ;; Compare comparables of the same type 22 | (.compareTo o1 o2) 23 | 24 | ;; Fallback to comparing string representations 25 | (let [s1 (pr-str o1) 26 | s2 (pr-str o2)] 27 | (.compareTo s1 s2)))))) 28 | 29 | (defn- filter-items [ordered-source? filter-fn items text [order-by order-direction]] 30 | (let [items (into [] 31 | (filter #(filter-fn % text)) 32 | items)] 33 | (if (and order-by (not ordered-source?)) 34 | ((case order-direction 35 | :asc identity 36 | :desc reverse) 37 | (sort-by order-by generic-comparator items)) 38 | items))) 39 | 40 | (defn- render-row [{:keys [columns row-class on-row-click]} row] 41 | (let [cls (str row-class 42 | (when on-row-click 43 | " cursor-pointer"))] 44 | (h/html 45 | [:tr {:class cls :on-click (when on-row-click 46 | #(on-row-click row))} 47 | [::h/for [{:keys [accessor render render-full]} columns 48 | :let [data (accessor row)]] 49 | [:td.align-top.px-2 50 | (cond 51 | render-full (render-full row) 52 | render (render data) 53 | :else (h/out! (str data)))]]]))) 54 | 55 | (defn- filter-input [set-filter!] 56 | (let [id (str (gensym "table-filter"))] 57 | (h/html 58 | [:div {:class "my-2 flex sm:flex-row flex-col"} 59 | [:div.block.relative 60 | [:span.h-full.absolute.inset-y-0.left-0.flex.items-center.pl-2 61 | [:svg.h-4.w-4.fill-current.text-gray-500 {:viewBox "0 0 24 24"} 62 | [:path {:d "M10 4a6 6 0 100 12 6 6 0 000-12zm-8 6a8 8 0 1114.32 4.906l5.387 5.387a1 1 0 01-1.414 1.414l-5.387-5.387A8 8 0 012 10z"}]]] 63 | [:input {:id id 64 | :class "appearance-none rounded-r rounded-l sm:rounded-l-none border border-gray-400 border-b block pl-8 pr-6 py-2 w-full bg-white text-sm placeholder-gray-400 text-gray-700 focus:bg-white focus:placeholder-gray-600 focus:text-gray-700 focus:outline-none" 65 | :placeholder "Filter..." 66 | :on-input (js/js-debounced 300 set-filter! 67 | (js/input-value id))}]]]))) 68 | 69 | (defn- header [{:keys [columns]} set-order! [order-by order-direction]] 70 | (h/html 71 | [:thead 72 | [:tr 73 | [::h/for [{:keys [label accessor order-by?] 74 | :or {order-by? true}} columns] 75 | [:th.text-left {:on-click #(when order-by? 76 | (set-order! [accessor (case order-direction 77 | :asc :desc 78 | :desc :asc)]))} 79 | label 80 | (when (and (= order-by accessor)) 81 | (h/out! (case order-direction 82 | :asc " \u2303" 83 | :desc " \u2304")))]]]])) 84 | 85 | (defn table 86 | "A data table that allows ordering by columns and filtering. 87 | 88 | Takes two arguments: an options map and the live source for items. 89 | 90 | Options: 91 | 92 | :columns collection of columns for the table. Each column is a map 93 | containing at least :label and :accessor. 94 | 95 | Column may contain :render which is called to render the value. 96 | Default render just stringifies the value. 97 | 98 | The :render function is called with just the value. 99 | To pass the whole row to the render function, use :render-full 100 | instead. 101 | 102 | 103 | If :order-by? is false, then this column can't be ordered by. 104 | 105 | Example: [{:label \"Name\" :accessor :name} 106 | {:label \"Email\" :accessor :email}] 107 | 108 | :filter-fn predicate that is called with item and current filter text 109 | default implementation just checks the printed representation 110 | of the item for a substring match. 111 | 112 | :order the initial order [accessor direction] (eg. [:name :asc]) 113 | 114 | :set-order! if specified, ordering will be done at the source level 115 | and not by the table. If the source is an XTDB query, 116 | it should handle the ordering in the query. 117 | If not set, the items are ordered by using clojure builtin 118 | `sort-by` function. 119 | 120 | :class Class to apply to the main table. 121 | Defaults to \"table table-compact table-zebra\". 122 | 123 | :row-class Class to apply to rows. 124 | defaults to slightly striped coloring of alternate rows 125 | 126 | :on-row-click 127 | Add optional callback to when the row is clicked. 128 | The function is called with the full row data. 129 | 130 | " 131 | [{:keys [key filter-fn order set-order! render-after empty-message class] 132 | :or {filter-fn default-filter-fn 133 | key identity 134 | order [nil :asc] 135 | class "table table-compact table-zebra"} :as table-def} data-source] 136 | (let [[filter-source set-filter!] (source/use-state "") 137 | [order-source set-table-order!] (source/use-state order) 138 | rows-source (source/computed 139 | (partial filter-items (some? set-order!) filter-fn) 140 | data-source filter-source order-source)] 141 | (h/html 142 | [:div.mx-2.font-mono 143 | (filter-input set-filter!) 144 | [:table {:class class} 145 | [::h/live order-source (partial header table-def 146 | #(do 147 | (when set-order! 148 | (set-order! %)) 149 | (set-table-order! %)))] 150 | [::h/when empty-message 151 | [::h/live (source/computed empty? rows-source) 152 | #(let [cols (count (:columns table-def))] 153 | (h/html 154 | [:tbody 155 | [::h/when % 156 | [:tr 157 | [:td {:colspan cols} 158 | empty-message]]]]))]] 159 | 160 | (collection/live-collection 161 | {:render (partial render-row table-def) 162 | :key key 163 | :container-element :tbody 164 | :source rows-source}) 165 | (when render-after 166 | (render-after))]]))) 167 | -------------------------------------------------------------------------------- /src/xtdb_inspector/ui/tabs.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.ui.tabs 2 | "Tabbed panel" 3 | (:require [ripley.html :as h] 4 | [ripley.live.source :as source])) 5 | 6 | (defn- tab-button [label select! selected?] 7 | (let [cls (source/computed 8 | #(str "tab tab-bordered" 9 | (when % 10 | " tab-active")) 11 | selected?)] 12 | (h/html 13 | [:a {:on-click select! 14 | :class [::h/live cls]} 15 | label]))) 16 | 17 | (defn tabs [& tabs] 18 | (let [[selected-idx set-selected-idx!] (source/use-state 0) 19 | tabs (remove nil? tabs) 20 | tab-count (count tabs)] 21 | (h/html 22 | [:div 23 | [:div.tabs 24 | (doseq [i (range tab-count) 25 | :let [{:keys [label]} (nth tabs i)]] 26 | (tab-button label 27 | #(set-selected-idx! i) 28 | (source/computed #(= i %) selected-idx)))] 29 | [::h/live selected-idx 30 | #(h/html 31 | [:div.tab-content 32 | ((:render (nth tabs %)))])]]))) 33 | -------------------------------------------------------------------------------- /src/xtdb_inspector/util.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.util 2 | (:import (java.net URLEncoder)) 3 | (:require [clojure.string :as str])) 4 | 5 | (defn enc 6 | "URLEncode a thing" 7 | [x] 8 | (-> x 9 | URLEncoder/encode 10 | (str/replace "+" "%20"))) 11 | 12 | (def root-path 13 | (or (System/getenv "XTDB_INSPECTOR_URI_PREFIX") "")) 14 | 15 | (defn ->route 16 | [s] 17 | (str root-path s)) 18 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: { 3 | enabled: true, 4 | content: ['./src/**/*.clj'], 5 | }, 6 | theme: { 7 | extend: { 8 | colors: { 9 | primary: "#146a8e" 10 | }, 11 | }, 12 | }, 13 | variants: { 14 | extend: {}, 15 | }, 16 | plugins: [require("daisyui")], 17 | }; 18 | --------------------------------------------------------------------------------