diff --git a/.gitignore b/.gitignore index be81faf..762de3c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ target node_modules js_dist pgdata +.vite diff --git a/Cargo.lock b/Cargo.lock index 17de9f2..c5f86fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,7 +215,7 @@ dependencies = [ "chrono", "hmac 0.11.0", "log", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2 0.9.9", @@ -261,6 +261,7 @@ checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", "axum-macros", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -280,8 +281,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper 1.0.2", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -776,6 +779,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" + [[package]] name = "der" version = "0.7.10" @@ -1624,7 +1633,30 @@ dependencies = [ ] [[package]] -name = "interim" +name = "interim-models" +version = "0.0.1" +dependencies = [ + "derive_builder", + "interim-pgtypes", + "regex", + "serde", + "sqlx", + "uuid", +] + +[[package]] +name = "interim-pgtypes" +version = "0.0.1" +dependencies = [ + "derive_builder", + "regex", + "serde", + "sqlx", + "uuid", +] + +[[package]] +name = "interim-server" version = "0.0.1" dependencies = [ "anyhow", @@ -1638,10 +1670,13 @@ dependencies = [ "derive_builder", "dotenvy", "futures", + "interim-models", + "interim-pgtypes", "nom 8.0.0", "oauth2", "percent-encoding", - "rand", + "rand 0.8.5", + "regex", "reqwest 0.12.15", "serde", "serde_json", @@ -1885,7 +1920,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1936,7 +1971,7 @@ dependencies = [ "chrono", "getrandom 0.2.16", "http 0.2.12", - "rand", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", @@ -2238,8 +2273,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2249,7 +2294,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -2261,6 +2316,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "redox_syscall" version = "0.5.12" @@ -2438,7 +2502,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2781,7 +2845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2950,7 +3014,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -2990,7 +3054,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2 0.10.9", @@ -3335,6 +3399,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -3513,6 +3589,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.1", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typenum" version = "1.18.0" @@ -3582,6 +3675,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7fccb6b..42851d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,45 +1,27 @@ -[package] -name = "interim" -version = "0.0.1" -edition = "2021" - [workspace] -resolver = "2" +resolver = "3" +members = ["interim-*"] + +[workspace.package] +version = "0.0.1" +edition = "2024" [workspace.dependencies] anyhow = { version = "1.0.91", features = ["backtrace"] } chrono = { version = "0.4.41", features = ["serde"] } +derive_builder = "0.20.2" +futures = "0.3.31" +interim-models = { path = "./interim-models" } +interim-pgtypes = { path = "./interim-pgtypes" } +interim-server = { path = "./interim-server" } +rand = "0.8.5" +regex = "1.11.1" reqwest = { version = "0.12.8", features = ["json"] } serde = { version = "1.0.213", features = ["derive"] } -sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] } -uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] } -tokio = { version = "1.42.0", features = ["full"] } - -[dependencies] -anyhow = { workspace = true } -askama = { version = "0.14.0", features = ["urlencode"] } -async-session = "3.0.0" -axum = { version = "0.8.1", features = ["macros"] } -axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] } -chrono = { workspace = true } -clap = { version = "4.5.31", features = ["derive"] } -config = "0.14.1" -derive_builder = "0.20.2" -dotenvy = "0.15.7" -futures = "0.3.31" -nom = "8.0.0" -oauth2 = "4.4.2" -percent-encoding = "2.3.1" -rand = "0.8.5" -reqwest = { workspace = true } -serde = { workspace = true } serde_json = "1.0.132" -sqlx = { workspace = true } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] } thiserror = "2.0.12" -tokio = { workspace = true } -tower = "0.5.2" -tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] } +tokio = { version = "1.42.0", features = ["full"] } tracing = "0.1.40" -tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] } -uuid = { workspace = true } +uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] } validator = { version = "0.20.0", features = ["derive"] } diff --git a/bacon.toml b/bacon.toml index 527ad3e..058cff5 100644 --- a/bacon.toml +++ b/bacon.toml @@ -1,89 +1,24 @@ -# This is a configuration file for the bacon tool -# -# Complete help on configuration: https://dystroy.org/bacon/config/ -# -# You may check the current default at -# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml - default_job = "check" env.CARGO_TERM_COLOR = "always" +[jobs.clippy] +command = ["cargo", "clippy"] +need_stdout = false +watch = ["interim-*"] + [jobs.check] command = ["cargo", "check"] need_stdout = false -[jobs.check-all] -command = ["cargo", "check", "--all-targets"] -need_stdout = false - -# Run clippy on the default target -[jobs.clippy] -command = ["cargo", "clippy"] -need_stdout = false - -# Run clippy on all targets -# To disable some lints, you may change the job this way: -# [jobs.clippy-all] -# command = [ -# "cargo", "clippy", -# "--all-targets", -# "--", -# "-A", "clippy::bool_to_int_with_if", -# "-A", "clippy::collapsible_if", -# "-A", "clippy::derive_partial_eq_without_eq", -# ] -# need_stdout = false -[jobs.clippy-all] -command = ["cargo", "clippy", "--all-targets"] -need_stdout = false - -# This job lets you run -# - all tests: bacon test -# - a specific test: bacon test -- config::test_default_files -# - the tests of a package: bacon test -- -- -p config -[jobs.test] -command = ["cargo", "test"] -need_stdout = true - -[jobs.nextest] -command = [ - "cargo", "nextest", "run", - "--hide-progress-bar", "--failure-output", "final" -] -need_stdout = true -analyzer = "nextest" - -[jobs.doc] -command = ["cargo", "doc", "--no-deps"] -need_stdout = false - -# If the doc compiles, then it opens in your browser and bacon switches -# to the previous job -[jobs.doc-open] -command = ["cargo", "doc", "--no-deps", "--open"] -need_stdout = false -on_success = "back" # so that we don't open the browser at each change - -# You can run your application and have the result displayed in bacon, -# if it makes sense for this crate. [jobs.run-worker] command = [ "cargo", "run", "worker", - # put launch parameters for your program behind a `--` separator ] need_stdout = true allow_warnings = true background = true default_watch = false -# Run your long-running application (eg server) and have the result displayed in bacon. -# For programs that never stop (eg a server), `background` is set to false -# to have the cargo run output immediately displayed instead of waiting for -# program's end. -# 'on_change_strategy' is set to `kill_then_restart` to have your program restart -# on every change (an alternative would be to use the 'F5' key manually in bacon). -# If you often use this job, it makes sense to override the 'r' key by adding -# a binding `r = job:run-long` at the end of this file . [jobs.run-server] command = ["cargo", "run", "serve"] need_stdout = true @@ -91,21 +26,22 @@ allow_warnings = true background = false on_change_strategy = "kill_then_restart" kill = ["kill", "-s", "INT"] -watch = ["src", "templates"] +watch = ["interim-*", "components/src"] -# This parameterized job runs the example of your choice, as soon -# as the code compiles. -# Call it as -# bacon ex -- my-example -[jobs.ex] -command = ["cargo", "run", "--example"] +[jobs.watch-components] +command = ["deno", "task", "--cwd=components", "build"] +default_watch = false +watch = ["components/src"] need_stdout = true allow_warnings = true +background = false # You may define here keybindings that would be specific to # a project, for example a shortcut to launch a specific job. # Shortcuts to internal functions (scrolling, toggling, etc.) # should go in your personal global prefs.toml file instead. [keybindings] +b = "job:watch-components" +c = "job:clippy" r = "job:run-server" w = "job:run-worker" diff --git a/components/deno.lock b/components/deno.lock index 09561eb..dfe8b55 100644 --- a/components/deno.lock +++ b/components/deno.lock @@ -1,11 +1,17 @@ { "version": "5", "specifiers": { + "jsr:@std/path@*": "1.0.8", "npm:@lit/context@^1.1.2": "1.1.2", "npm:lit@^3.2.0": "3.2.1", "npm:vite@*": "5.4.5", "npm:vite@^5.2.10": "5.4.5" }, + "jsr": { + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + } + }, "npm": { "@esbuild/aix-ppc64@0.21.5": { "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", diff --git a/components/src/add-selection-button.tsx b/components/src/add-selection-button.tsx new file mode 100644 index 0000000..89a7ce2 --- /dev/null +++ b/components/src/add-selection-button.tsx @@ -0,0 +1,58 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { createRef, type Ref, ref } from "lit/directives/ref.js"; + +import "./add-selection-modal-contents.tsx"; + +@customElement("add-selection-button") +export class AddSelectionButton extends LitElement { + @property({ attribute: true }) + columns = ""; + + private _dialogRef: Ref = createRef(); + + static override styles = css` + button.th { + appearance: none; + border: none; + width: 100%; + height: 100%; + font-weight: inherit; + font-size: inherit; + font-family: inherit; + cursor: pointer; + background: none; + } + + dialog { + border: solid 1px #ccc; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 0.5rem #3333; + font-family: + "Averia Serif Libre", + "Open Sans", + "Helvetica Neue", + Arial, + sans-serif; + } + + dialog::backdrop { + background: #0001; + } + `; + + showModal() { + this._dialogRef.value?.showModal(); + } + + protected override render() { + return html` + + + + + `; + } +} diff --git a/components/src/add-selection-modal-contents.tsx b/components/src/add-selection-modal-contents.tsx new file mode 100644 index 0000000..e1c45d5 --- /dev/null +++ b/components/src/add-selection-modal-contents.tsx @@ -0,0 +1,88 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { styleMap } from "lit/directives/style-map.js"; + +@customElement("add-selection-modal-content") +export class AddSelectionModalContents extends LitElement { + @property({ attribute: true, type: Array }) + columns: { + attname: string; + atttypid: number; + }[] = []; + + @state() + private _tab: "existing_col" | "new_col" = "existing_col"; + + static override styles = css` + .container {} + + .tabs { + display: flex; + justify-content: flex-start; + } + + button.tab { + appearance: none; + background: none; + border: none; + border-bottom: solid 2px #4474; + font: inherit; + padding: 0.25rem 0.5rem; + + &.tab-active { + border-bottom-color: #447; + } + } + `; + + setTab(key: typeof this._tab) { + this._tab = key; + } + + protected override render() { + return html` +
+
+ + +
+
+
+
+ + + +
+
+
+
+ `; + } +} diff --git a/components/src/cells.ts b/components/src/cells.ts deleted file mode 100644 index 550195d..0000000 --- a/components/src/cells.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CellText } from "./cell-text.ts"; -export { CellUuid } from "./cell-uuid.ts"; diff --git a/components/src/entrypoints/cells.ts b/components/src/entrypoints/cells.ts new file mode 100644 index 0000000..6a75df5 --- /dev/null +++ b/components/src/entrypoints/cells.ts @@ -0,0 +1,2 @@ +export { CellText } from "../cell-text.ts"; +export { CellUuid } from "../cell-uuid.ts"; diff --git a/components/src/entrypoints/custom-icon.ts b/components/src/entrypoints/custom-icon.ts new file mode 100644 index 0000000..8f4c9c5 --- /dev/null +++ b/components/src/entrypoints/custom-icon.ts @@ -0,0 +1,32 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("custom-icon") +export class CustomIcon extends LitElement { + @property({ attribute: true }) + name = ""; + + @property({ attribute: true }) + alt?: string; + + static override styles = css` + :host { + display: flex; + align-items: center; + } + + img { + display: block; + } + `; + + protected override render() { + const path = `../heroicons/16/solid/${this.name}.svg`; + return html` + ${this.alt} + `; + } +} diff --git a/components/src/entrypoints/dev-reloader.ts b/components/src/entrypoints/dev-reloader.ts new file mode 100644 index 0000000..48abb0d --- /dev/null +++ b/components/src/entrypoints/dev-reloader.ts @@ -0,0 +1,118 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; + +@customElement("dev-reloader") +export class DevReloader extends LitElement { + @property({ attribute: true }) + ws = ""; + + @property({ attribute: true, type: Boolean, reflect: true }) + auto = false; + + @state() + private _connected = false; + + private _armed = false; + + private _socket?: WebSocket; + + static override styles = css` + button { + appearance: none; + background: none; + border: none; + padding: none; + } + + .widget { + position: fixed; + z-index: 999; + bottom: 1rem; + left: 1rem; + border: solid 1px #0002; + padding: 1rem; + border-radius: 9999px; + cursor: pointer; + display: flex; + align-items: center; + box-shadow: 0 0.5rem 1rem #0002; + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + + .indicator { + width: 8px; + height: 8px; + border-radius: 9999px; + background: #f60; + + &.connected { + background: #06f; + } + } + + .label { + margin-left: 1rem; + } + `; + + override connectedCallback() { + super.connectedCallback(); + this._connected = true; + this._handleDisconnect(); + } + + private _handleDisconnect() { + if (this._connected) { + console.log("dev-reloader: disconnected"); + this._connected = false; + this._socket = undefined; + const intvl = setInterval(() => { + if (!this._socket || this._socket.readyState === WebSocket.CLOSED) { + try { + this._socket = new WebSocket(this.ws); + this._socket.addEventListener("open", () => { + if (this.auto && this._armed) { + globalThis.location.reload(); + } + console.log("dev-reloader: connected"); + this._connected = true; + this._armed = true; + clearInterval(intvl); + }); + this._socket.addEventListener( + "close", + this._handleDisconnect.bind(this), + ); + this._socket.addEventListener( + "error", + this._handleDisconnect.bind(this), + ); + } catch { /* no-op */ } + } + }, 500); + } + } + + private _toggleAuto() { + this.auto = !this.auto; + } + + protected override render() { + return html` + + `; + } +} diff --git a/components/src/entrypoints/lens-controls.ts b/components/src/entrypoints/lens-controls.ts new file mode 100644 index 0000000..959167b --- /dev/null +++ b/components/src/entrypoints/lens-controls.ts @@ -0,0 +1 @@ +export { LensControls } from "../lens-controls.ts"; diff --git a/components/src/entrypoints/viewer-components.tsx b/components/src/entrypoints/viewer-components.tsx new file mode 100644 index 0000000..5b2e7e3 --- /dev/null +++ b/components/src/entrypoints/viewer-components.tsx @@ -0,0 +1 @@ +export { AddSelectionButton } from "../add-selection-button.tsx"; diff --git a/components/src/lens-controls-shell.ts b/components/src/lens-controls-shell.ts new file mode 100644 index 0000000..435ba67 --- /dev/null +++ b/components/src/lens-controls-shell.ts @@ -0,0 +1,214 @@ +import { css, html, LitElement } from "lit"; +import { classMap } from "lit/directives/class-map.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { customElement, property, state } from "lit/decorators.js"; + +import "./entrypoints/custom-icon.ts"; + +export type TabItem = { + icon: string; + label: string; + value: string; +}; + +export class SelectTabEvent extends Event { + value: string; + + constructor(value: string) { + super("select-tab"); + this.value = value; + } +} + +@customElement("lens-controls-shell") +export class LensControlsShell extends LitElement { + @property({ attribute: true, type: Array }) + tabs: TabItem[] = []; + + @property({ attribute: "active-tab" }) + activeTab?: string; + + @state() + private _isOpen = false; + + private _controlPanelRef = createRef(); + + static override styles = css` + :host { + --shadow: 0 0.5rem 0.5rem #3333; + --background: #fff; + --border-color: #ccc; + --border-radius: 0.5rem; + } + + #container-positioner { + position: fixed; + bottom: 2rem; + display: flex; + justify-content: center; + align-items: flex-end; + overflow: visible; + width: 100%; + height: 0; + } + + #container { + display: grid; + grid-template-columns: max-content max-content 1rem max-content; + filter: drop-shadow(var(--shadow)); + } + + #tab-box { + height: 2rem; + display: flex; + align-items: center; + list-style-type: none; + padding: 0.5rem; + margin: 0; + border: solid 1px var(--border-color); + border-right: none; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + background: var(--background); + grid-row: 2; + + & button { + appearance: none; + background: none; + border: none; + font-size: inherit; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: inherit; + cursor: pointer; + height: 2rem; + } + + & button.active { + background: #39f3; + } + } + + #control-bar { + height: 3rem; + flex-shrink: 0; + border: solid 1px var(--border-color); + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + overflow: hidden; + width: 40rem; + grid-row: 2; + background: var(--background); + + &.open { + border-top-right-radius: 0; + } + } + + #control-buttons { + height: 3rem; + grid-row: 2; + grid-column: 4; + height: 100%; + } + + #control-panel-positioner { + grid-template-columns: subgrid; + grid-column: 2; + grid-row: 1; + position: relative; + overflow: visible; + /* Flexbox positioning is required for Safari */ + display: flex; + align-items: flex-end; + anchor-name: --control-bar; + } + + #control-panel-container:popover-open { + inset: unset; + border: solid 1px var(--border-color); + border-bottom: none; + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + margin: 0; + position: fixed; + display: block; + width: 40rem; + /* Anchor positioning is required for Chromium */ + position-anchor: --control-bar; + position-area: top; + padding: 0; + background: var(--background); + box-shadow: var(--shadow); + /* Clip drop shadow */ + clip-path: polygon( + -100% -100%, + 200% -100%, + 200% 200%, + 100% 200%, + 100% 100%, + -100% 100% + ); + } + + #control-panel { + padding: 0.5rem; + overflow: auto; + max-height: 8rem; + } + `; + + open(): void { + this._controlPanelRef.value?.showPopover(); + } + + protected override render() { + return html` +
+
+
    + ${this.tabs.map(({ icon, label, value }) => + html` +
  • + +
  • + ` + )} +
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+
+ `; + } +} diff --git a/components/src/lens-controls.ts b/components/src/lens-controls.ts new file mode 100644 index 0000000..0c18579 --- /dev/null +++ b/components/src/lens-controls.ts @@ -0,0 +1,191 @@ +import { css, html, LitElement } from "lit"; +import { createRef, ref } from "lit/directives/ref.js"; +import { customElement, property } from "lit/decorators.js"; + +import "./lens-controls-shell.ts"; +import "./entrypoints/custom-icon.ts"; +import { type LensControlsShell } from "./lens-controls-shell.ts"; +import { type Selection } from "./selections.ts"; +export { SelectionFilters } from "./selection-filters.ts"; + +const TABS = [ + { icon: "funnel", label: "Filters", value: "filters" }, + { icon: "view-columns", label: "Columns", value: "columns" }, +]; + +@customElement("lens-controls") +export class LensControls extends LitElement { + @property({ attribute: true, type: Array }) + selections: Selection[] = []; + + private _shellRef = createRef(); + + static override styles = css` + #control-bar { + width: 100%; + height: 100%; + display: flex; + align-items: stretch; + + & input { + appearance: none; + flex: 1; + border: none; + outline: none; + font-size: inherit; + font-family: "Funnel Sans"; + padding: 0.5rem; + } + + & .actions { + flex: 0; + display: flex; + align-items: stretch; + padding: 0.5rem; + } + } + + #control-buttons { + height: 100%; + display: flex; + align-items: stretch; + + & button { + appearance: none; + background: #447; + border: none; + color: #fff; + font-size: inherit; + font-family: inherit; + border-radius: 0.5rem; + padding: 0.5rem 1rem; + cursor: pointer; + } + } + + #selections { + display: grid; + grid-template-columns: max-content max-content max-content max-content 1fr; + grid-gap: 0 1rem; + } + + .selection { + display: grid; + grid-column: 1 / 7; + grid-template-columns: subgrid; + justify-content: start; + align-items: center; + padding: 0.5rem; + border-radius: 0.25rem; + transition: background 0.2s ease; + + &:hover { + background: #9991; + } + + & .selection-filters { + font-family: "Funnel Sans"; + } + } + + .label input { + font-family: "Funnel Sans"; + font-size: inherit; + outline: none; + background: transparent; + border: none; + height: 100%; + width: 100%; + } + `; + + private _handleVisibilityInput(ev: InputEvent, selectionId: string): void { + this.selections = this.selections.map(( + selection, + ) => (selection.id === selectionId + ? { + ...selection, + visible: (ev.target as HTMLInputElement).checked, + } + : selection) + ); + } + + private _handleSelectionFiltersChange(ev: Event, selectionId: string): void { + // TODO + } + + protected override render() { + return html` +
+ +
+ +
+
+ +
+
+ ${this.selections.map((selection) => + html` +
+
+ +
+
+ +
+
Text
+
+ +
+
+ +
+
+ ` + )} +
+
+
+ `; + } +} diff --git a/components/src/lens-controls.ts.bak b/components/src/lens-controls.ts.bak new file mode 100644 index 0000000..8c923b0 --- /dev/null +++ b/components/src/lens-controls.ts.bak @@ -0,0 +1,259 @@ +import { css, html, LitElement } from "lit"; +import { classMap } from "lit/directives/class-map.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { customElement, property, state } from "lit/decorators.js"; + +import { type Selection } from "./selections.ts"; + +export { SelectionFilters } from "./selection-filters.ts"; + +@customElement("lens-controls") +export class LensControls extends LitElement { + @property({ attribute: true, type: Array }) + selections: Selection[] = []; + + @state() + private _isOpen = false; + + private _controlPanelDialog = createRef(); + + static override styles = css` + :host { + --shadow: 0 0.5rem 0.5rem #3333; + --background: #fff; + --border-color: #ccc; + --border-radius: 0.5rem; + } + + #container-positioner { + position: fixed; + bottom: 2rem; + display: flex; + justify-content: center; + align-items: flex-end; + overflow: visible; + width: 100%; + height: 0; + } + + #container { + display: grid; + grid-template-columns: max-content max-content; + filter: drop-shadow(var(--shadow)); + } + + #tab-box { + height: 2rem; + display: flex; + align-items: center; + list-style-type: none; + padding: 0.5rem; + margin: 0; + border: solid 1px var(--border-color); + border-right: none; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + background: var(--background); + grid-row: 2; + + & button { + appearance: none; + background: none; + border: none; + font-size: inherit; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: inherit; + } + + & button.active { + background: #39f3; + } + } + + #control-bar { + height: 3rem; + flex-shrink: 0; + border: solid 1px var(--border-color); + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + display: flex; + justify-content: stretch; + align-items: stretch; + overflow: hidden; + width: 40rem; + grid-row: 2; + + & input { + appearance: none; + width: 100%; + border: none; + outline: none; + font-size: inherit; + font-family: "Funnel Sans"; + padding: 0.5rem; + } + + &.open { + border-top-right-radius: 0; + } + } + + #control-panel-positioner { + grid-template-columns: subgrid; + grid-column: 2; + grid-row: 1; + position: relative; + overflow: visible; + /* Flexbox positioning is required for Safari */ + display: flex; + align-items: flex-end; + anchor-name: --control-bar; + } + + #control-panel-container:popover-open { + inset: unset; + border: solid 1px var(--border-color); + border-bottom: none; + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + margin: 0; + position: fixed; + display: block; + width: 40rem; + /* Anchor positioning is required for Chromium */ + position-anchor: --control-bar; + position-area: top; + padding: 0; + background: var(--background); + box-shadow: var(--shadow); + /* Clip drop shadow */ + clip-path: polygon( + -100% -100%, + 200% -100%, + 200% 200%, + 100% 200%, + 100% 100%, + -100% 100% + ); + } + + #control-panel { + padding: 1rem; + overflow: auto; + max-height: 8rem; + } + + .selections { + display: grid; + grid-template-columns: max-content 1fr 1fr; + } + + .selection { + display: grid; + grid-template-columns: subgrid; + justify-content: start; + align-items: center; + grid-gap: 1rem; + padding: 0.25rem 0; + + & .visibility { + grid-column: 1; + } + + & .selection-filters { + grid-column: 2; + } + + & .conversions { + grid-column: 3; + } + } + `; + + open(): void { + this._controlPanelDialog.value?.showPopover(); + } + + private _handleVisibilityInput(ev: InputEvent, selectionId: string): void { + this.selections = this.selections.map(( + selection, + ) => (selection.id === selectionId + ? { + ...selection, + visible: (ev.target as HTMLInputElement).checked, + } + : selection) + ); + } + + private _handleSelectionFiltersChange(ev: Event, selectionId: string): void { + // TODO + } + + protected override render() { + return html` +
+
+
    +
  • + +
  • +
  • + +
  • +
+
+ +
+
+
+
+
+ ${this.selections.map((selection) => + html` +
+
+ +
+
+ +
+
+
+ ` + )} +
+
+
+
+
+
+ `; + } +} diff --git a/components/src/sel-item.ts b/components/src/sel-item.ts new file mode 100644 index 0000000..dedfd58 --- /dev/null +++ b/components/src/sel-item.ts @@ -0,0 +1,28 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("sel-item") +export class SelItem extends LitElement { + @property({ attribute: "display-type", type: Object, reflect: true }) + displayType?: unknown; + + @property({ attribute: true, type: Boolean, reflect: true }) + visible!: boolean; + + @property({ attribute: true, type: String, reflect: true }) + label?: string; + + private _handleDelete() { + this.dispatchEvent( + new Event("sel-item-deleted", { bubbles: true, composed: true }), + ); + } + + protected override render() { + return html` +
+ +
+ `; + } +} diff --git a/components/src/selection-filters.ts b/components/src/selection-filters.ts new file mode 100644 index 0000000..ff659a8 --- /dev/null +++ b/components/src/selection-filters.ts @@ -0,0 +1,57 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { type AttrFilter } from "./selections.ts"; + +@customElement("selection-filters") +export class SelectionFilters extends LitElement { + @property({ attribute: "selection-id" }) + selectionId = ""; + + @property({ attribute: true, type: Array }) + filters: AttrFilter[] = []; + + static override styles = css` + #selection-filters { + display: flex; + list-style-type: none; + margin: 0; + padding: 0; + } + `; + + protected override render() { + return html` +
    + ${this.filters.map((filter) => + html` +
  1. + ${"NameEq" in filter + ? html` + ${filter.NameEq} + ` + : undefined} ${"NameMatches" in filter + ? html` + ${filter.NameMatches} + ` + : undefined} ${"TypeEq" in filter + ? html` + ${filter.TypeEq} + ` + : undefined} +
  2. + ` + )} +
+ `; + } +} + +@customElement("name-eq-filter") +export class NameEqFilter extends LitElement { + protected override render() { + return html` +
+ `; + } +} diff --git a/components/src/selections.ts b/components/src/selections.ts new file mode 100644 index 0000000..2c42d79 --- /dev/null +++ b/components/src/selections.ts @@ -0,0 +1,21 @@ +export type Selection = { + id: string; + attr_filters: AttrFilter[]; + display_type?: SelectionDisplayType; + label?: string; + visible: boolean; +}; + +export type AttrFilter = { + NameEq: string; +} | { + NameMatches: string; +} | { + TypeEq: string; +}; + +export type SelectionDisplayType = "Text" | "InterimUser" | { + Timestamp: { + format: string; + }; +}; diff --git a/components/vite.config.mjs b/components/vite.config.mjs index 8da3822..b7549a4 100644 --- a/components/vite.config.mjs +++ b/components/vite.config.mjs @@ -1,12 +1,18 @@ import { defineConfig } from "vite"; +import * as path from "jsr:@std/path"; import "lit"; +const entrypointsDir = path.join(import.meta.dirname, "src/entrypoints"); +const entry = [...Deno.readDirSync(entrypointsDir)].map(({ name }) => + path.join(entrypointsDir, name) +); + // https://vitejs.dev/config/ export default defineConfig({ build: { lib: { - entry: ["src/cells.ts"], + entry, formats: ["es"], }, outDir: "../js_dist", diff --git a/interim-models/Cargo.toml b/interim-models/Cargo.toml new file mode 100644 index 0000000..16e610d --- /dev/null +++ b/interim-models/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "interim-models" +edition.workspace = true +version.workspace = true + +[dependencies] +derive_builder = { workspace = true } +interim-pgtypes = { path = "../interim-pgtypes" } +regex = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } diff --git a/migrations/.keep b/interim-models/migrations/.keep similarity index 100% rename from migrations/.keep rename to interim-models/migrations/.keep diff --git a/migrations/20241125232658_users.down.sql b/interim-models/migrations/20241125232658_users.down.sql similarity index 100% rename from migrations/20241125232658_users.down.sql rename to interim-models/migrations/20241125232658_users.down.sql diff --git a/migrations/20241125232658_users.up.sql b/interim-models/migrations/20241125232658_users.up.sql similarity index 100% rename from migrations/20241125232658_users.up.sql rename to interim-models/migrations/20241125232658_users.up.sql diff --git a/migrations/20250108211839_sessions.down.sql b/interim-models/migrations/20250108211839_sessions.down.sql similarity index 100% rename from migrations/20250108211839_sessions.down.sql rename to interim-models/migrations/20250108211839_sessions.down.sql diff --git a/migrations/20250108211839_sessions.up.sql b/interim-models/migrations/20250108211839_sessions.up.sql similarity index 100% rename from migrations/20250108211839_sessions.up.sql rename to interim-models/migrations/20250108211839_sessions.up.sql diff --git a/migrations/20250522224809_bases.down.sql b/interim-models/migrations/20250522224809_bases.down.sql similarity index 100% rename from migrations/20250522224809_bases.down.sql rename to interim-models/migrations/20250522224809_bases.down.sql diff --git a/migrations/20250522224809_bases.up.sql b/interim-models/migrations/20250522224809_bases.up.sql similarity index 100% rename from migrations/20250522224809_bases.up.sql rename to interim-models/migrations/20250522224809_bases.up.sql diff --git a/migrations/20250528060837_rel_invitations.down.sql b/interim-models/migrations/20250528060837_rel_invitations.down.sql similarity index 100% rename from migrations/20250528060837_rel_invitations.down.sql rename to interim-models/migrations/20250528060837_rel_invitations.down.sql diff --git a/migrations/20250528060837_rel_invitations.up.sql b/interim-models/migrations/20250528060837_rel_invitations.up.sql similarity index 100% rename from migrations/20250528060837_rel_invitations.up.sql rename to interim-models/migrations/20250528060837_rel_invitations.up.sql diff --git a/interim-models/migrations/20250528233517_lenses.down.sql b/interim-models/migrations/20250528233517_lenses.down.sql new file mode 100644 index 0000000..b9deaa2 --- /dev/null +++ b/interim-models/migrations/20250528233517_lenses.down.sql @@ -0,0 +1,3 @@ +drop table if exists lens_selections; +drop table if exists lenses; +drop type if exists lens_display_type; diff --git a/interim-models/migrations/20250528233517_lenses.up.sql b/interim-models/migrations/20250528233517_lenses.up.sql new file mode 100644 index 0000000..ef68156 --- /dev/null +++ b/interim-models/migrations/20250528233517_lenses.up.sql @@ -0,0 +1,21 @@ +create type lens_display_type as enum ('table'); + +create table if not exists lenses ( + id uuid not null primary key, + name text not null, + base_id uuid not null references bases(id) on delete cascade, + class_oid oid not null, + filter jsonb not null default '{}'::jsonb, + order_by jsonb not null default '[]'::jsonb, + display_type lens_display_type not null default 'table' +); +create index on lenses (base_id); + +create table if not exists lens_selections ( + id uuid not null primary key, + lens_id uuid not null references lenses(id) on delete cascade, + attr_filters jsonb not null default '[]'::jsonb, + label text, + display_type text, + visible boolean not null default true +); diff --git a/interim-models/src/lens.rs b/interim-models/src/lens.rs new file mode 100644 index 0000000..6fd036c --- /dev/null +++ b/interim-models/src/lens.rs @@ -0,0 +1,129 @@ +use derive_builder::Builder; +use serde::Serialize; +use sqlx::{PgExecutor, postgres::types::Oid, query_as}; +use uuid::Uuid; + +use crate::selection::{AttrFilter, Selection, SelectionDisplayType}; + +#[derive(Clone, Debug, Serialize)] +pub struct Lens { + pub id: Uuid, + pub name: String, + pub base_id: Uuid, + pub class_oid: Oid, + pub display_type: LensDisplayType, +} + +impl Lens { + pub fn insertable_builder() -> InsertableLensBuilder { + InsertableLensBuilder::default() + } + + pub async fn fetch_by_id<'a, E: PgExecutor<'a>>( + id: Uuid, + app_db: E, + ) -> Result, sqlx::Error> { + query_as!( + Self, + r#" +select + id, + name, + base_id, + class_oid, + display_type as "display_type: LensDisplayType" +from lenses +where id = $1 +"#, + id + ) + .fetch_optional(app_db) + .await + } + + pub async fn fetch_by_rel<'a, E: PgExecutor<'a>>( + base_id: Uuid, + rel_oid: Oid, + app_db: E, + ) -> Result, sqlx::Error> { + query_as!( + Self, + r#" +select + id, + name, + base_id, + class_oid, + display_type as "display_type: LensDisplayType" +from lenses +where base_id = $1 and class_oid = $2 +"#, + base_id, + rel_oid + ) + .fetch_all(app_db) + .await + } + + pub async fn fetch_selections<'a, E: PgExecutor<'a>>( + &self, + app_db: E, + ) -> Result, sqlx::Error> { + query_as!( + Selection, + r#" +select + id, + attr_filters as "attr_filters: sqlx::types::Json>", + label, + display_type as "display_type: SelectionDisplayType", + visible +from lens_selections +where lens_id = $1 +"#, + self.id + ) + .fetch_all(app_db) + .await + } +} + +#[derive(Clone, Debug, Serialize, sqlx::Type)] +#[sqlx(type_name = "lens_display_type", rename_all = "lowercase")] +pub enum LensDisplayType { + Table, +} + +#[derive(Builder, Clone, Debug)] +pub struct InsertableLens { + name: String, + base_id: Uuid, + class_oid: Oid, + display_type: LensDisplayType, +} + +impl InsertableLens { + pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result { + query_as!( + Lens, + r#" +insert into lenses +(id, base_id, class_oid, name, display_type) +values ($1, $2, $3, $4, $5) +returning + id, + name, + base_id, + class_oid, + display_type as "display_type: LensDisplayType" +"#, + Uuid::now_v7(), + self.base_id, + self.class_oid, + self.name, + self.display_type as LensDisplayType + ) + .fetch_one(app_db) + .await + } +} diff --git a/interim-models/src/lib.rs b/interim-models/src/lib.rs new file mode 100644 index 0000000..ac6fe06 --- /dev/null +++ b/interim-models/src/lib.rs @@ -0,0 +1,4 @@ +pub mod lens; +pub mod selection; + +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); diff --git a/interim-models/src/selection.rs b/interim-models/src/selection.rs new file mode 100644 index 0000000..d2e225c --- /dev/null +++ b/interim-models/src/selection.rs @@ -0,0 +1,116 @@ +use derive_builder::Builder; +use interim_pgtypes::pg_attribute::PgAttribute; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sqlx::{PgExecutor, query_as}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize)] +pub struct Selection { + pub id: Uuid, + pub attr_filters: sqlx::types::Json>, + pub label: Option, + pub display_type: Option, + pub visible: bool, +} + +impl Selection { + pub fn insertable_builder() -> InsertableSelectionBuilder { + InsertableSelectionBuilder::default() + } + + pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec { + if self.visible { + let mut filtered_attrs = all_attrs.to_owned(); + for attr_filter in self.attr_filters.0.clone() { + filtered_attrs.retain(|attr| attr_filter.matches(attr)); + } + filtered_attrs + .into_iter() + .map(|attr| Field { + name: attr.attname.clone(), + label: self.label.clone(), + display_type: self.display_type.clone(), + }) + .collect() + } else { + vec![] + } + } +} + +#[derive(Clone, Debug, Serialize, sqlx::Type)] +#[sqlx(type_name = "TEXT")] +#[sqlx(rename_all = "lowercase")] +pub enum SelectionDisplayType { + Text, + InterimUser, + Timestamp, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum AttrFilter { + NameEq(String), + NameMatches(String), + TypeEq(String), +} + +impl AttrFilter { + pub fn matches(&self, attr: &PgAttribute) -> bool { + match self { + Self::NameEq(name) => &attr.attname == name, + Self::NameMatches(pattern) => Regex::new(pattern) + .map(|re| re.is_match(&attr.attname)) + .unwrap_or(false), + Self::TypeEq(_) => todo!("attr type filter is not yet implemented"), + } + } +} + +/// A single column which can be passed to a front-end viewer. A Selection may +/// resolve to zero or more Fields. +#[derive(Clone, Debug, Serialize)] +pub struct Field { + pub name: String, + pub label: Option, + pub display_type: Option, +} + +#[derive(Builder, Clone, Debug)] +pub struct InsertableSelection { + lens_id: Uuid, + attr_filters: Vec, + #[builder(default, setter(strip_option))] + label: Option, + #[builder(default, setter(strip_option))] + display_type: Option, + #[builder(default = true)] + visible: bool, +} + +impl InsertableSelection { + pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result { + query_as!( + Selection, + r#" +insert into lens_selections +(id, lens_id, attr_filters, label, display_type, visible) +values ($1, $2, $3, $4, $5, $6) +returning + id, + attr_filters as "attr_filters: sqlx::types::Json>", + label, + display_type as "display_type: SelectionDisplayType", + visible +"#, + Uuid::now_v7(), + self.lens_id, + sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json>, + self.label, + self.display_type as Option, + self.visible, + ) + .fetch_one(app_db) + .await + } +} diff --git a/interim-pgtypes/Cargo.toml b/interim-pgtypes/Cargo.toml new file mode 100644 index 0000000..16fb691 --- /dev/null +++ b/interim-pgtypes/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "interim-pgtypes" +edition.workspace = true +version.workspace = true + +[dependencies] +derive_builder = { workspace = true } +regex = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } diff --git a/interim-pgtypes/src/lib.rs b/interim-pgtypes/src/lib.rs new file mode 100644 index 0000000..5ed3c7d --- /dev/null +++ b/interim-pgtypes/src/lib.rs @@ -0,0 +1 @@ +pub mod pg_attribute; diff --git a/src/pg_attributes.rs b/interim-pgtypes/src/pg_attribute.rs similarity index 96% rename from src/pg_attributes.rs rename to interim-pgtypes/src/pg_attribute.rs index 9a663fa..c9cea0f 100644 --- a/src/pg_attributes.rs +++ b/interim-pgtypes/src/pg_attribute.rs @@ -1,5 +1,7 @@ -use sqlx::{postgres::types::Oid, query_as, PgExecutor}; +use serde::Serialize; +use sqlx::{PgExecutor, postgres::types::Oid, query_as}; +#[derive(Clone, Serialize)] pub struct PgAttribute { /// The table this column belongs to pub attrelid: Oid, diff --git a/interim-server/Cargo.toml b/interim-server/Cargo.toml new file mode 100644 index 0000000..35adf2b --- /dev/null +++ b/interim-server/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "interim-server" +edition.workspace = true +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +askama = { version = "0.14.0", features = ["serde_json", "urlencode"] } +async-session = "3.0.0" +axum = { version = "0.8.1", features = ["macros", "ws"] } +axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] } +chrono = { workspace = true } +clap = { version = "4.5.31", features = ["derive"] } +config = "0.14.1" +derive_builder = { workspace = true } +dotenvy = "0.15.7" +futures = { workspace = true } +interim-models = { workspace = true } +interim-pgtypes = { workspace = true } +nom = "8.0.0" +oauth2 = "4.4.2" +percent-encoding = "2.3.1" +rand = { workspace = true } +regex = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true} +sqlx = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tower = "0.5.2" +tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] } +uuid = { workspace = true } +validator = { workspace = true } diff --git a/src/abstract_.rs b/interim-server/src/abstract_.rs similarity index 100% rename from src/abstract_.rs rename to interim-server/src/abstract_.rs diff --git a/src/app_error.rs b/interim-server/src/app_error.rs similarity index 100% rename from src/app_error.rs rename to interim-server/src/app_error.rs diff --git a/src/app_state.rs b/interim-server/src/app_state.rs similarity index 100% rename from src/app_state.rs rename to interim-server/src/app_state.rs diff --git a/src/auth.rs b/interim-server/src/auth.rs similarity index 96% rename from src/auth.rs rename to interim-server/src/auth.rs index 18381b0..7e1d2fd 100644 --- a/src/auth.rs +++ b/interim-server/src/auth.rs @@ -40,7 +40,7 @@ pub fn new_oauth_client(settings: &Settings) -> Result { .set_redirect_uri( RedirectUrl::new(format!( "{}{}/auth/callback", - settings.frontend_host, settings.base_path + settings.frontend_host, settings.root_path )) .context("failed to create new redirection URL")?, )) @@ -59,7 +59,7 @@ async fn start_login( State(state): State, State(Settings { auth: auth_settings, - base_path, + root_path, .. }): State, State(session_store): State, @@ -74,7 +74,7 @@ async fn start_login( if session.get::(SESSION_KEY_AUTH_INFO).is_some() { tracing::debug!("already logged in, redirecting..."); - return Ok(Redirect::to(&format!("{}/", base_path)).into_response()); + return Ok(Redirect::to(&format!("{}/", root_path)).into_response()); } assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none()); @@ -99,7 +99,7 @@ async fn start_login( /// HTTP get handler for /logout async fn logout( State(Settings { - base_path, + root_path, auth: auth_settings, .. }): State, @@ -134,7 +134,7 @@ async fn logout( } let jar = jar.remove(Cookie::from(auth_settings.cookie_name)); tracing::debug!("Removed session cookie from jar."); - Ok((jar, Redirect::to(&format!("{}/", base_path)))) + Ok((jar, Redirect::to(&format!("{}/", root_path)))) } #[derive(Debug, Deserialize)] @@ -150,7 +150,7 @@ async fn callback( State(state): State, State(Settings { auth: auth_settings, - base_path, + root_path, .. }): State, State(ReqwestClient(reqwest_client)): State, @@ -205,7 +205,7 @@ async fn callback( } tracing::debug!("successfully authenticated"); Ok(Redirect::to( - &redirect_target.unwrap_or(format!("{}/", base_path)), + &redirect_target.unwrap_or(format!("{}/", root_path)), )) } diff --git a/src/base_pooler.rs b/interim-server/src/base_pooler.rs similarity index 100% rename from src/base_pooler.rs rename to interim-server/src/base_pooler.rs diff --git a/src/base_user_perms.rs b/interim-server/src/base_user_perms.rs similarity index 100% rename from src/base_user_perms.rs rename to interim-server/src/base_user_perms.rs diff --git a/src/bases.rs b/interim-server/src/bases.rs similarity index 100% rename from src/bases.rs rename to interim-server/src/bases.rs diff --git a/src/cli.rs b/interim-server/src/cli.rs similarity index 87% rename from src/cli.rs rename to interim-server/src/cli.rs index a5a3bd3..ae95f20 100644 --- a/src/cli.rs +++ b/interim-server/src/cli.rs @@ -1,3 +1,5 @@ +use std::net::SocketAddr; + use anyhow::Result; use axum::{ extract::Request, @@ -10,8 +12,7 @@ use clap::{Parser, Subcommand}; use tokio::time::sleep; use tower::ServiceBuilder; use tower_http::{ - compression::CompressionLayer, normalize_path::NormalizePathLayer, - set_header::response::SetResponseHeaderLayer, trace::TraceLayer, + compression::CompressionLayer, set_header::response::SetResponseHeaderLayer, trace::TraceLayer, }; use crate::{ @@ -51,7 +52,6 @@ pub async fn serve_command(state: AppState) -> Result<()> { CONTENT_SECURITY_POLICY, HeaderValue::from_static("frame-ancestors 'none'"), )) - .layer(NormalizePathLayer::trim_trailing_slash()) .service(new_router(state.clone())); let listener = @@ -62,12 +62,15 @@ pub async fn serve_command(state: AppState) -> Result<()> { "App running at http://{}:{}{}", state.settings.host, state.settings.port, - state.settings.base_path + state.settings.root_path ); - axum::serve(listener, ServiceExt::::into_make_service(router)) - .await - .map_err(Into::into) + axum::serve( + listener, + ServiceExt::::into_make_service_with_connect_info::(router), + ) + .await + .map_err(Into::into) } pub async fn worker_command(args: &WorkerArgs, state: AppState) -> Result<()> { diff --git a/src/data_layer.rs b/interim-server/src/data_layer.rs similarity index 80% rename from src/data_layer.rs rename to interim-server/src/data_layer.rs index 7ed0fdc..3209f56 100644 --- a/src/data_layer.rs +++ b/interim-server/src/data_layer.rs @@ -2,14 +2,16 @@ use std::fmt::Display; use anyhow::Result; use chrono::{DateTime, Utc}; -use derive_builder::Builder; +use interim_models::selection::SelectionDisplayType; use sqlx::{ + ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, error::BoxDynError, postgres::{PgRow, PgTypeInfo, PgValueRef}, - ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, }; use uuid::Uuid; +const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z"; + pub enum Value { Text(Option), Integer(Option), @@ -17,29 +19,11 @@ pub enum Value { Uuid(Option), } -#[derive(Builder)] -#[builder(pattern = "owned", setter(prefix = "with"))] -pub struct FieldOptions { - /// Format with which to render timestamptz values - #[builder(default = "\"%Y-%m-%dT%H:%M:%S%.f%:z\".to_owned()")] - pub date_format: String, - - /// If some, treat text column like an enum - #[builder(default)] - pub select_options: Option>, - - /// Text to display in place of actual column name - #[builder(default)] - pub label: Option, - - #[builder(default = "true")] - pub editable: bool, -} - pub trait ToHtmlString { - fn to_html_string(&self, options: &FieldOptions) -> String; + fn to_html_string(&self, display_type: &Option) -> String; } +// TODO rewrite with thiserror #[derive(Clone, Debug)] pub struct FromSqlError { message: String, @@ -72,7 +56,7 @@ impl Value { } impl ToHtmlString for Value { - fn to_html_string(&self, options: &FieldOptions) -> String { + fn to_html_string(&self, display_type: &Option) -> String { macro_rules! cell_html { ($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => { { @@ -97,7 +81,7 @@ impl ToHtmlString for Value { Self::Timestamptz(value) => cell_html!( "cell-timestamptz", value, - "format" => options.date_format + "format" => DEFAULT_TIMESTAMP_FORMAT ), Self::Uuid(value) => cell_html!("cell-uuid", value), } @@ -142,12 +126,3 @@ impl<'a> Decode<'a, Postgres> for Value { } } } - -pub struct Lens { - pub fields: Vec, -} - -pub struct Field { - pub options: FieldOptions, - pub name: String, -} diff --git a/src/db_conns.rs b/interim-server/src/db_conns.rs similarity index 100% rename from src/db_conns.rs rename to interim-server/src/db_conns.rs diff --git a/src/flexi_row.rs b/interim-server/src/flexi_row.rs similarity index 100% rename from src/flexi_row.rs rename to interim-server/src/flexi_row.rs diff --git a/src/iclient.rs b/interim-server/src/iclient.rs similarity index 100% rename from src/iclient.rs rename to interim-server/src/iclient.rs diff --git a/interim-server/src/lenses.rs b/interim-server/src/lenses.rs new file mode 100644 index 0000000..2f97ede --- /dev/null +++ b/interim-server/src/lenses.rs @@ -0,0 +1,72 @@ +use derive_builder::Builder; +use interim_pgtypes::pg_attribute::PgAttribute; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sqlx::{PgExecutor, postgres::types::Oid, query_as}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize)] +pub struct Selection { + pub id: Uuid, + pub attr_filters: sqlx::types::Json>, + pub label: Option, + pub display_type: Option, + pub visible: bool, +} + +impl Selection { + pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec { + if self.visible { + let mut filtered_attrs = all_attrs.to_owned(); + for attr_filter in self.attr_filters.0.clone() { + filtered_attrs.retain(|attr| attr_filter.matches(attr)); + } + filtered_attrs + .into_iter() + .map(|attr| Field { + name: attr.attname.clone(), + label: self.label.clone(), + display_type: self.display_type.clone(), + }) + .collect() + } else { + vec![] + } + } +} + +#[derive(Clone, Debug, Serialize, sqlx::Type)] +#[sqlx(rename_all = "lowercase")] +pub enum SelectionDisplayType { + Text, + InterimUser, + Timestamp, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum AttrFilter { + NameEq(String), + NameMatches(String), + TypeEq(String), +} + +impl AttrFilter { + pub fn matches(&self, attr: &PgAttribute) -> bool { + match self { + Self::NameEq(name) => &attr.attname == name, + Self::NameMatches(pattern) => Regex::new(pattern) + .map(|re| re.is_match(&attr.attname)) + .unwrap_or(false), + Self::TypeEq(_) => todo!("attr type filter is not yet implemented"), + } + } +} + +/// A single column which can be passed to a front-end viewer. A Selection may +/// resolve to zero or more Fields. +#[derive(Clone, Debug, Serialize)] +pub struct Field { + pub name: String, + pub label: Option, + pub display_type: Option, +} diff --git a/src/main.rs b/interim-server/src/main.rs similarity index 83% rename from src/main.rs rename to interim-server/src/main.rs index d7fd46e..81077cb 100644 --- a/src/main.rs +++ b/interim-server/src/main.rs @@ -1,10 +1,11 @@ use clap::Parser as _; use dotenvy::dotenv; +use interim_models::MIGRATOR; use tracing_subscriber::EnvFilter; use crate::{ app_state::{App, AppState}, - cli::{serve_command, worker_command, Cli, Commands}, + cli::{Cli, Commands, serve_command, worker_command}, settings::Settings, }; @@ -17,9 +18,9 @@ mod bases; mod cli; mod data_layer; mod db_conns; +mod lenses; mod middleware; mod pg_acls; -mod pg_attributes; mod pg_classes; mod pg_databases; mod pg_roles; @@ -44,8 +45,8 @@ async fn main() { let state: AppState = App::from_settings(settings.clone()).await.unwrap().into(); - if settings.run_database_migrations == Some(1) { - sqlx::migrate!().run(&state.app_db).await.unwrap(); + if settings.run_database_migrations != 0 { + MIGRATOR.run(&state.app_db).await.unwrap(); } let cli = Cli::parse(); diff --git a/src/middleware.rs b/interim-server/src/middleware.rs similarity index 100% rename from src/middleware.rs rename to interim-server/src/middleware.rs diff --git a/src/nav.rs b/interim-server/src/nav.rs similarity index 100% rename from src/nav.rs rename to interim-server/src/nav.rs diff --git a/src/pg_acls.rs b/interim-server/src/pg_acls.rs similarity index 100% rename from src/pg_acls.rs rename to interim-server/src/pg_acls.rs diff --git a/src/pg_classes.rs b/interim-server/src/pg_classes.rs similarity index 100% rename from src/pg_classes.rs rename to interim-server/src/pg_classes.rs diff --git a/src/pg_databases.rs b/interim-server/src/pg_databases.rs similarity index 100% rename from src/pg_databases.rs rename to interim-server/src/pg_databases.rs diff --git a/src/pg_roles.rs b/interim-server/src/pg_roles.rs similarity index 100% rename from src/pg_roles.rs rename to interim-server/src/pg_roles.rs diff --git a/src/rel_invitations.rs b/interim-server/src/rel_invitations.rs similarity index 100% rename from src/rel_invitations.rs rename to interim-server/src/rel_invitations.rs diff --git a/src/router.rs b/interim-server/src/router.rs similarity index 67% rename from src/router.rs rename to interim-server/src/router.rs index d9e2b80..12f7e80 100644 --- a/src/router.rs +++ b/interim-server/src/router.rs @@ -1,8 +1,13 @@ +use std::net::SocketAddr; + use axum::{ + extract::{ws::WebSocket, ConnectInfo, WebSocketUpgrade}, http::{header::CACHE_CONTROL, HeaderValue}, - routing::{get, post}, + response::Response, + routing::{any, get, post}, Router, }; +use axum_extra::routing::RouterExt as _; use tower::ServiceBuilder; use tower_http::{ services::{ServeDir, ServeFile}, @@ -12,38 +17,67 @@ use tower_http::{ use crate::{app_state::AppState, auth, routes}; pub fn new_router(state: AppState) -> Router<()> { - let base_path = state.settings.base_path.clone(); + let base_path = state.settings.root_path.clone(); let app = Router::new() - .route("/databases", get(routes::bases::list_bases_page)) - .route("/databases/add", post(routes::bases::add_base_page)) - .route( - "/d/{base_id}/config", + .route_with_tsr("/databases/", get(routes::bases::list_bases_page)) + .route_with_tsr("/databases/add/", post(routes::bases::add_base_page)) + .route_with_tsr( + "/d/{base_id}/config/", get(routes::bases::base_config_page_get), ) .route( - "/d/{base_id}/config", + "/d/{base_id}/config/", post(routes::bases::base_config_page_post), ) - .route( - "/d/{base_id}/relations", + .route_with_tsr( + "/d/{base_id}/relations/", get(routes::relations::list_relations_page), ) - .route( - "/d/{base_id}/r/{class_oid}/rbac", + .route_with_tsr( + "/d/{base_id}/r/{class_oid}/", + get(routes::relations::rel_index_page), + ) + .route_with_tsr( + "/d/{base_id}/r/{class_oid}/rbac/", get(routes::relations::rel_rbac_page), ) - .route( - "/d/{base_id}/r/{class_oid}/rbac/invite", + .route_with_tsr( + "/d/{base_id}/r/{class_oid}/rbac/invite/", get(routes::relations::rel_rbac_invite_page_get), ) .route( "/d/{base_id}/r/{class_oid}/rbac/invite", post(routes::relations::rel_rbac_invite_page_post), ) - .route( - "/d/{base_id}/r/{class_oid}/viewer", - get(routes::relations::viewer_page), + .route_with_tsr( + "/d/{base_id}/r/{class_oid}/lenses/", + get(routes::lenses::lenses_page), ) + .route_with_tsr( + "/d/{base_id}/r/{class_oid}/lenses/add/", + get(routes::lenses::add_lens_page_get), + ) + .route( + "/d/{base_id}/r/{class_oid}/lenses/add", + post(routes::lenses::add_lens_page_post), + ) + .route_with_tsr( + "/d/{base_id}/r/{class_oid}/l/{lens_id}/", + get(routes::lenses::lens_page), + ) + .route( + "/d/{base_id}/r/{class_oid}/l/{lens_id}/update-lens", + post(routes::lenses::update_lens_page_post), + ) + .route( + "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection", + post(routes::lenses::add_selection_page_post), + ) + .route_with_tsr( + "/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/", + get(routes::lenses::viewer_page), + ) + .route("/__dev-healthz", any(dev_healthz_handler)) .nest("/auth", auth::new_router()) .layer(SetResponseHeaderLayer::if_not_present( CACHE_CONTROL, @@ -54,7 +88,9 @@ pub fn new_router(state: AppState) -> Router<()> { ServiceBuilder::new() .layer(SetResponseHeaderLayer::if_not_present( CACHE_CONTROL, - HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"), + // FIXME: restore production value + // HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"), + HeaderValue::from_static("no-cache"), )) .service( ServeDir::new("js_dist").not_found_service( @@ -94,6 +130,19 @@ pub fn new_router(state: AppState) -> Router<()> { } } +async fn dev_healthz_handler( + ws: WebSocketUpgrade, + ConnectInfo(addr): ConnectInfo, +) -> Response { + tracing::info!("{addr} connected"); + ws.on_upgrade(move |socket| handle_dev_healthz_socket(socket, addr)) +} + +async fn handle_dev_healthz_socket(mut socket: WebSocket, _: SocketAddr) { + // Keep socket open indefinitely until the entire server exits + while let Some(Ok(_)) = socket.recv().await {} +} + // #[derive(Deserialize)] // struct RbacIndexPath { // oid: u32, diff --git a/src/routes/bases.rs b/interim-server/src/routes/bases.rs similarity index 85% rename from src/routes/bases.rs rename to interim-server/src/routes/bases.rs index 6cca2e5..b989617 100644 --- a/src/routes/bases.rs +++ b/interim-server/src/routes/bases.rs @@ -16,14 +16,12 @@ use crate::{ base_user_perms::sync_perms_for_base, bases::Base, db_conns::{escape_identifier, init_role}, - pg_databases::PgDatabase, - pg_roles::PgRole, settings::Settings, users::CurrentUser, }; pub async fn list_bases_page( - State(Settings { base_path, .. }): State, + State(settings): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, ) -> Result { @@ -33,14 +31,14 @@ pub async fn list_bases_page( #[derive(Template)] #[template(path = "list_bases.html")] struct ResponseTemplate { - base_path: String, bases: Vec, + settings: Settings, } - Ok(Html(ResponseTemplate { base_path, bases }.render()?).into_response()) + Ok(Html(ResponseTemplate { bases, settings }.render()?).into_response()) } pub async fn add_base_page( - State(Settings { base_path, .. }): State, + State(settings): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, ) -> Result { @@ -62,7 +60,7 @@ values ($1, $2, $3, 'configure')", ) .execute(&mut *app_db) .await?; - Ok(Redirect::to(&format!("{}/d/{}/config", base_path, base.id)).into_response()) + Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base.id)).into_response()) } #[derive(Deserialize)] @@ -71,7 +69,7 @@ pub struct BaseConfigPagePath { } pub async fn base_config_page_get( - State(Settings { base_path, .. }): State, + State(settings): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, Path(params): Path, @@ -84,9 +82,9 @@ pub async fn base_config_page_get( #[template(path = "base_config.html")] struct ResponseTemplate { base: Base, - base_path: String, + settings: Settings, } - Ok(Html(ResponseTemplate { base, base_path }.render()?).into_response()) + Ok(Html(ResponseTemplate { base, settings }.render()?).into_response()) } #[derive(Deserialize)] @@ -96,7 +94,7 @@ pub struct BaseConfigPageForm { } pub async fn base_config_page_post( - State(Settings { base_path, .. }): State, + State(settings): State, State(mut base_pooler): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, @@ -139,5 +137,5 @@ pub async fn base_config_page_post( .await?; sync_perms_for_base(base.id, &mut app_db, &mut client).await?; } - Ok(Redirect::to(&format!("{}/d/{}/config", base_path, base_id)).into_response()) + Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base_id)).into_response()) } diff --git a/interim-server/src/routes/lenses.rs b/interim-server/src/routes/lenses.rs new file mode 100644 index 0000000..e96beb1 --- /dev/null +++ b/interim-server/src/routes/lenses.rs @@ -0,0 +1,283 @@ +use std::collections::HashMap; + +use askama::Template; +use axum::{ + extract::{Path, State}, + response::{Html, IntoResponse as _, Redirect, Response}, +}; +use axum_extra::extract::Form; +use interim_models::{ + lens::{Lens, LensDisplayType}, + selection::{AttrFilter, Field, Selection}, +}; +use interim_pgtypes::pg_attribute::{PgAttribute, fetch_attributes_for_rel}; +use serde::Deserialize; +use sqlx::{ + postgres::{PgRow, types::Oid}, + query, +}; +use uuid::Uuid; + +use crate::{ + app_error::{AppError, not_found}, + app_state::AppDbConn, + base_pooler::BasePooler, + bases::Base, + data_layer::{ToHtmlString as _, Value}, + db_conns::{escape_identifier, init_role}, + settings::Settings, + users::CurrentUser, +}; + +#[derive(Deserialize)] +pub struct LensesPagePath { + base_id: Uuid, + class_oid: u32, +} + +pub async fn lenses_page( + State(settings): State, + AppDbConn(mut app_db): AppDbConn, + Path(LensesPagePath { base_id, class_oid }): Path, +) -> Result { + // FIXME auth + let lenses = Lens::fetch_by_rel(base_id, Oid(class_oid), &mut *app_db).await?; + #[derive(Template)] + #[template(path = "lenses.html")] + struct ResponseTemplate { + base_id: Uuid, + class_oid: u32, + lenses: Vec, + settings: Settings, + } + + Ok(Html( + ResponseTemplate { + base_id, + class_oid, + lenses, + settings, + } + .render()?, + ) + .into_response()) +} + +pub async fn add_lens_page_get( + State(settings): State, + AppDbConn(mut app_db): AppDbConn, + Path(LensesPagePath { base_id, class_oid }): Path, +) -> Result { + // FIXME auth + #[derive(Template)] + #[template(path = "add_lens.html")] + struct ResponseTemplate { + base_id: Uuid, + class_oid: u32, + settings: Settings, + } + + Ok(Html( + ResponseTemplate { + base_id, + class_oid, + settings, + } + .render()?, + ) + .into_response()) +} + +#[derive(Deserialize)] +pub struct AddLensPagePostForm { + name: String, +} + +pub async fn add_lens_page_post( + State(Settings { root_path, .. }): State, + AppDbConn(mut app_db): AppDbConn, + Path(LensesPagePath { base_id, class_oid }): Path, + Form(AddLensPagePostForm { name }): Form, +) -> Result { + // FIXME auth + // FIXME csrf + let lens = Lens::insertable_builder() + .base_id(base_id) + .class_oid(Oid(class_oid)) + .name(name) + .display_type(LensDisplayType::Table) + .build()? + .insert(&mut *app_db) + .await?; + Ok(Redirect::to(&format!( + "{root_path}/d/{0}/r/{class_oid}/l/{1}", + base_id.simple(), + lens.id.simple() + )) + .into_response()) +} + +#[derive(Deserialize)] +pub struct LensPagePath { + base_id: Uuid, + class_oid: u32, + lens_id: Uuid, +} + +pub async fn lens_page( + State(settings): State, + State(mut base_pooler): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(LensPagePath { + base_id, + class_oid, + lens_id, + }): Path, +) -> Result { + // FIXME auth + let base = Base::fetch_by_id(base_id, &mut *app_db) + .await? + .ok_or(AppError::NotFound("no base found with that id".to_owned()))?; + let mut client = base_pooler.acquire_for(base_id).await?; + + init_role( + &format!("{}{}", &base.user_role_prefix, ¤t_user.id.simple()), + &mut client, + ) + .await?; + + // FIXME auth + + let class = query!( + "select relname from pg_class where oid = $1", + Oid(class_oid) + ) + .fetch_optional(&mut *client) + .await? + .ok_or(AppError::NotFound( + "no relation found with that oid".to_owned(), + ))?; + + let lens = Lens::fetch_by_id(lens_id, &mut *app_db) + .await? + .ok_or(not_found!("no lens found with that id"))?; + + let attrs = fetch_attributes_for_rel(Oid(class_oid), &mut *client).await?; + + let selections = lens.fetch_selections(&mut *app_db).await?; + let mut fields: Vec = Vec::with_capacity(selections.len()); + for selection in selections.clone() { + fields.append(&mut selection.resolve_fields_from_attrs(&attrs)); + } + + const FRONTEND_ROW_LIMIT: i64 = 1000; + let rows = query(&format!( + "select {} from {} limit $1", + attrs + .iter() + .map(|attr| attr.attname.clone()) + .collect::>() + .join(", "), + escape_identifier(&class.relname), + )) + .bind(FRONTEND_ROW_LIMIT) + .fetch_all(&mut *client) + .await?; + #[derive(Template)] + #[template(path = "lens.html")] + struct ResponseTemplate { + fields: Vec, + all_columns: Vec, + rows: Vec, + selections_json: String, + settings: Settings, + } + Ok(Html( + ResponseTemplate { + all_columns: attrs, + fields, + rows, + selections_json: serde_json::to_string(&selections)?, + settings, + } + .render()?, + ) + .into_response()) +} + +#[derive(Debug, Deserialize)] +pub struct AddSelectionPageForm { + column: String, +} + +pub async fn add_selection_page_post( + State(settings): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(LensPagePath { + base_id, + class_oid, + lens_id, + }): Path, + Form(form): Form, +) -> Result { + dbg!(&form); + // FIXME auth + // FIXME csrf + + let lens = Lens::fetch_by_id(lens_id, &mut *app_db) + .await? + .ok_or(not_found!("lens not found"))?; + Selection::insertable_builder() + .lens_id(lens.id) + .attr_filters(vec![AttrFilter::NameEq(form.column)]) + .build()? + .insert(&mut *app_db) + .await?; + + Ok(Redirect::to(&format!( + "{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/", + settings.root_path + )) + .into_response()) +} + +pub async fn update_lens_page_post( + State(settings): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(LensPagePath { + base_id, + class_oid, + lens_id, + }): Path, + Form(form): Form>, +) -> Result { + dbg!(&form); + // FIXME auth + // FIXME csrf + + Ok(Redirect::to(&format!( + "{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/", + settings.root_path + )) + .into_response()) +} + +#[derive(Deserialize)] +pub struct ViewerPagePath { + base_id: Uuid, + class_oid: u32, + lens_id: Uuid, +} + +pub async fn viewer_page( + State(settings): State, + State(mut base_pooler): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(params): Path, +) -> Result { + todo!("not yet implemented"); +} diff --git a/src/routes/mod.rs b/interim-server/src/routes/mod.rs similarity index 68% rename from src/routes/mod.rs rename to interim-server/src/routes/mod.rs index dd38828..8d6ef3f 100644 --- a/src/routes/mod.rs +++ b/interim-server/src/routes/mod.rs @@ -1,2 +1,3 @@ pub mod bases; +pub mod lenses; pub mod relations; diff --git a/src/routes/relations.rs b/interim-server/src/routes/relations.rs similarity index 66% rename from src/routes/relations.rs rename to interim-server/src/routes/relations.rs index 57603d8..b1af361 100644 --- a/src/routes/relations.rs +++ b/interim-server/src/routes/relations.rs @@ -8,10 +8,7 @@ use axum::{ }; use axum_extra::extract::Form; use serde::Deserialize; -use sqlx::{ - postgres::{types::Oid, PgRow}, - query, -}; +use sqlx::postgres::types::Oid; use uuid::Uuid; use crate::{ @@ -19,10 +16,8 @@ use crate::{ app_state::AppDbConn, base_pooler::BasePooler, bases::Base, - data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value}, - db_conns::{escape_identifier, init_role}, + db_conns::init_role, pg_acls::PgPrivilegeType, - pg_attributes::fetch_attributes_for_rel, pg_classes::{PgClass, PgRelKind}, pg_roles::{user_id_from_rolname, PgRole, RoleTree}, rel_invitations::RelInvitation, @@ -36,7 +31,7 @@ pub struct ListRelationsPagePath { } pub async fn list_relations_page( - State(Settings { base_path, .. }): State, + State(settings): State, State(mut base_pooler): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, @@ -81,16 +76,16 @@ pub async fn list_relations_page( #[derive(Template)] #[template(path = "list_rels.html")] struct ResponseTemplate { - base_path: String, base: Base, rels: Vec, + settings: Settings, } Ok(Html( ResponseTemplate { base, - base_path, rels: accessible_rels, + settings, } .render()?, ) @@ -98,91 +93,26 @@ pub async fn list_relations_page( } #[derive(Deserialize)] -pub struct ViewerPagePath { +pub struct RelPagePath { base_id: Uuid, class_oid: u32, } -pub async fn viewer_page( - State(Settings { base_path, .. }): State, - State(mut base_pooler): State, +pub async fn rel_index_page( + State(settings): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, - Path(params): Path, + Path(RelPagePath { base_id, class_oid }): Path, ) -> Result { - let base = Base::fetch_by_id(params.base_id, &mut *app_db) - .await? - .ok_or(AppError::NotFound("no base found with that id".to_owned()))?; - let mut client = base_pooler.acquire_for(params.base_id).await?; - - init_role( - &format!("{}{}", &base.user_role_prefix, ¤t_user.id.simple()), - &mut client, - ) - .await?; - - // FIXME: Ensure user has access to database and relation - - let class = query!( - "select relname from pg_class where oid = $1", - Oid(params.class_oid) - ) - .fetch_optional(&mut *client) - .await? - .ok_or(AppError::NotFound( - "no relation found with that oid".to_owned(), - ))?; - let attrs = fetch_attributes_for_rel(Oid(params.class_oid), &mut *client).await?; - - const FRONTEND_ROW_LIMIT: i64 = 1000; - let rows = query(&format!( - "select {} from {} limit $1", - attrs - .iter() - .map(|attr| attr.attname.clone()) - .collect::>() - .join(", "), - escape_identifier(&class.relname), - )) - .bind(FRONTEND_ROW_LIMIT) - .fetch_all(&mut *client) - .await?; - #[derive(Template)] - #[template(path = "class-viewer.html")] - struct ResponseTemplate { - base_path: String, - fields: Vec, - rows: Vec, - } - Ok(Html( - ResponseTemplate { - base_path, - fields: attrs - .into_iter() - .map(|attr| Field { - options: FieldOptionsBuilder::default().build().unwrap(), - name: attr.attname, - }) - .collect(), - rows, - } - .render()?, - ) - .into_response()) -} - -#[derive(Deserialize)] -pub struct RelRbacPagePath { - base_id: Uuid, - class_oid: u32, + todo!(); } pub async fn rel_rbac_page( - State(Settings { base_path, .. }): State, + State(settings): State, State(mut base_pooler): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, - Path(RelRbacPagePath { base_id, class_oid }): Path, + Path(RelPagePath { base_id, class_oid }): Path, ) -> Result { // FIXME: auth let base = Base::fetch_by_id(base_id, &mut *app_db) @@ -215,29 +145,27 @@ pub async fn rel_rbac_page( let all_invites = RelInvitation::fetch_by_class_oid(Oid(class_oid), &mut *app_db).await?; let mut invites_by_email: HashMap> = HashMap::new(); for invite in all_invites { - let entry = invites_by_email - .entry(invite.email.clone()) - .or_insert(vec![]); + let entry = invites_by_email.entry(invite.email.clone()).or_default(); entry.push(invite); } #[derive(Template)] #[template(path = "rel_rbac.html")] struct ResponseTemplate { - base_path: String, base: Base, interim_users: HashMap, invites_by_email: HashMap>, pg_class: PgClass, + settings: Settings, } Ok(Html( ResponseTemplate { base, - base_path, interim_users, invites_by_email, pg_class: class, + settings, } .render()?, ) @@ -245,14 +173,14 @@ pub async fn rel_rbac_page( } pub async fn rel_rbac_invite_page_get( - State(Settings { base_path, .. }): State, + State(settings): State, ) -> Result { #[derive(Template)] #[template(path = "rbac_invite.html")] struct ResponseTemplate { - base_path: String, + settings: Settings, } - Ok(Html(ResponseTemplate { base_path }.render()?).into_response()) + Ok(Html(ResponseTemplate { settings }.render()?).into_response()) } #[derive(Deserialize)] @@ -261,10 +189,10 @@ pub struct RbacInvitePagePostForm { } pub async fn rel_rbac_invite_page_post( - State(Settings { base_path, .. }): State, + State(settings): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, - Path(RelRbacPagePath { base_id, class_oid }): Path, + Path(RelPagePath { base_id, class_oid }): Path, Form(form): Form, ) -> Result { // FIXME auth @@ -288,5 +216,9 @@ pub async fn rel_rbac_invite_page_post( .upsert(&mut *app_db) .await?; } - Ok(Redirect::to(&format!("{base_path}/d/{base_id}/r/{class_oid}/rbac")).into_response()) + Ok(Redirect::to(&format!( + "{0}/d/{base_id}/r/{class_oid}/rbac", + settings.root_path + )) + .into_response()) } diff --git a/src/schema.rs b/interim-server/src/schema.rs similarity index 100% rename from src/schema.rs rename to interim-server/src/schema.rs diff --git a/src/sessions.rs b/interim-server/src/sessions.rs similarity index 100% rename from src/sessions.rs rename to interim-server/src/sessions.rs diff --git a/src/settings.rs b/interim-server/src/settings.rs similarity index 92% rename from src/settings.rs rename to interim-server/src/settings.rs index d392d1a..2831212 100644 --- a/src/settings.rs +++ b/interim-server/src/settings.rs @@ -12,7 +12,12 @@ pub struct Settings { /// slash but no trailing slash, for example "/app". For default behavior, /// leave as empty string. #[serde(default)] - pub base_path: String, + pub root_path: String, + + /// When set to 1, dev features such as the frontend reloader will be + /// enabled. + #[serde(default)] + pub dev: u8, /// postgresql:// URL for Interim's application database. pub database_url: String, @@ -21,7 +26,8 @@ pub struct Settings { pub app_db_max_connections: u32, /// When set to 1, embedded SQLx migrations will be run on startup. - pub run_database_migrations: Option, + #[serde(default)] + pub run_database_migrations: u8, /// Address for server to bind to #[serde(default = "default_host")] diff --git a/src/users.rs b/interim-server/src/users.rs similarity index 98% rename from src/users.rs rename to interim-server/src/users.rs index 08ca021..2350945 100644 --- a/src/users.rs +++ b/interim-server/src/users.rs @@ -74,7 +74,7 @@ where SESSION_KEY_AUTH_REDIRECT, uri.path_and_query() .map(|value| value.to_string()) - .unwrap_or(format!("{}/", app_state.settings.base_path)), + .unwrap_or(format!("{}/", app_state.settings.root_path)), )?; if let Some(cookie_value) = app_state.session_store.store_session(session).await? { tracing::debug!("adding session cookie to jar"); @@ -96,7 +96,7 @@ where }; return Err(Self::Rejection::SetCookiesAndRedirect( jar, - format!("{}/auth/login", app_state.settings.base_path), + format!("{}/auth/login", app_state.settings.root_path), )); }; let current_user = if let Some(value) = diff --git a/src/worker.rs b/interim-server/src/worker.rs similarity index 100% rename from src/worker.rs rename to interim-server/src/worker.rs diff --git a/interim-server/templates/add_lens.html b/interim-server/templates/add_lens.html new file mode 100644 index 0000000..ed0690c --- /dev/null +++ b/interim-server/templates/add_lens.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block main %} +
+
+ + +
+ +
+{% endblock %} diff --git a/interim-server/templates/base.html b/interim-server/templates/base.html new file mode 100644 index 0000000..0e02f84 --- /dev/null +++ b/interim-server/templates/base.html @@ -0,0 +1,16 @@ + + + + {% block title %}Interim{% endblock %} + {% include "meta_tags.html" %} + + + + + {% block main %}{% endblock main %} + + {% if settings.dev != 0 %} + + {% endif %} + + diff --git a/templates/base_config.html b/interim-server/templates/base_config.html similarity index 100% rename from templates/base_config.html rename to interim-server/templates/base_config.html diff --git a/templates/class-viewer.html b/interim-server/templates/class-viewer.html similarity index 59% rename from templates/class-viewer.html rename to interim-server/templates/class-viewer.html index ccb8c82..a6d101e 100644 --- a/templates/class-viewer.html +++ b/interim-server/templates/class-viewer.html @@ -1,13 +1,14 @@ {% extends "base.html" %} {% block main %} - + + {% for field in fields %} {% endfor %} @@ -19,7 +20,7 @@
-
{{ field.options.label.clone().unwrap_or(field.name.clone()) }}
+
{{ field.label.clone().unwrap_or(field.name.clone()) }}
{% match Value::get_from_row(row, field.name.as_str()) %} {% when Ok with (value) %} - {{ value.to_html_string(&field.options) | safe }} + {{ value.to_html_string(&field.display_type) | safe }} {% when Err with (err) %} {{ err }} {% endmatch %} @@ -29,4 +30,5 @@ {% endfor %}
+ {% endblock %} diff --git a/interim-server/templates/lens.html b/interim-server/templates/lens.html new file mode 100644 index 0000000..6cdc06b --- /dev/null +++ b/interim-server/templates/lens.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block main %} + + + + + + + + {% for field in fields %} + + {% endfor %} + + + + + {% for row in rows %} + + {% for field in fields %} + + {% endfor %} + + {% endfor %} + +
+
{{ field.label.clone().unwrap_or(field.name.clone()) }}
+
+ +
+ {% match Value::get_from_row(row, field.name.as_str()) %} + {% when Ok with (value) %} + {{ value.to_html_string(field.display_type) | safe }} + {% when Err with (err) %} + {{ err }} + {% endmatch %} +
+ +{% endblock %} diff --git a/interim-server/templates/lenses.html b/interim-server/templates/lenses.html new file mode 100644 index 0000000..93acb51 --- /dev/null +++ b/interim-server/templates/lenses.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block main %} + + + {% for lens in lenses %} + + + + {% endfor %} + +
+ + {{ lens.name }} + +
+{% endblock %} diff --git a/templates/list_bases.html b/interim-server/templates/list_bases.html similarity index 65% rename from templates/list_bases.html rename to interim-server/templates/list_bases.html index 11bc6d9..4fe520e 100644 --- a/templates/list_bases.html +++ b/interim-server/templates/list_bases.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block main %} -
+
@@ -9,7 +9,7 @@ {% for base in bases %} diff --git a/templates/list_rels.html b/interim-server/templates/list_rels.html similarity index 71% rename from templates/list_rels.html rename to interim-server/templates/list_rels.html index 9cc2e30..65a6bc5 100644 --- a/templates/list_rels.html +++ b/interim-server/templates/list_rels.html @@ -6,7 +6,7 @@ {% for rel in rels %} diff --git a/templates/meta_tags.html b/interim-server/templates/meta_tags.html similarity index 69% rename from templates/meta_tags.html rename to interim-server/templates/meta_tags.html index 605426e..7e77373 100644 --- a/templates/meta_tags.html +++ b/interim-server/templates/meta_tags.html @@ -1,4 +1,4 @@ - + diff --git a/interim-server/templates/nav.html b/interim-server/templates/nav.html new file mode 100644 index 0000000..b66a46a --- /dev/null +++ b/interim-server/templates/nav.html @@ -0,0 +1,7 @@ + diff --git a/interim-server/templates/rbac.html b/interim-server/templates/rbac.html new file mode 100644 index 0000000..cb48de8 --- /dev/null +++ b/interim-server/templates/rbac.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block main %} +
- + {{ base.name }}
- + {{ rel.relname }}
+ + + + + + + + + + {% for user in users %} + + + + + + {% endfor %} + +
EmailUser IDRolesActions
{{ user.user.email }}{{ role_prefix }}{{ user.user.id.simple() }}...
+{% endblock %} diff --git a/templates/rbac_invite.html b/interim-server/templates/rbac_invite.html similarity index 100% rename from templates/rbac_invite.html rename to interim-server/templates/rbac_invite.html diff --git a/templates/rel_rbac.html b/interim-server/templates/rel_rbac.html similarity index 89% rename from templates/rel_rbac.html rename to interim-server/templates/rel_rbac.html index a68e289..e53b426 100644 --- a/templates/rel_rbac.html +++ b/interim-server/templates/rel_rbac.html @@ -2,7 +2,7 @@ {% block main %}

Invitations

- + Invite Collaborators diff --git a/templates/tmp.html b/interim-server/templates/tmp.html similarity index 100% rename from templates/tmp.html rename to interim-server/templates/tmp.html diff --git a/static/main.css b/static/main.css index d8dd13d..818f479 100644 --- a/static/main.css +++ b/static/main.css @@ -1,13 +1,17 @@ -:root { - --bs-font-sans-serif: Geist, "Noto Sans", Roboto, "Segoe UI", system-ui, -apple-system, "Helvetica Neue", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji"; +html { + font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif; } -[data-bs-theme="dark"] { - --bs-body-bg: rgb(27, 28, 30); - --bs-tertiary-bg-rgb: 36, 38, 40; +button, input[type="submit"] { + font-family: inherit; } @font-face { - font-family: Geist; - src: url("./geist/geist_variable.ttf"); + font-family: "Averia Serif Libre"; + src: url("./averia_serif_libre/averia_serif_libre_regular.ttf"); +} + +@font-face { + font-family: "Funnel Sans"; + src: url("./funnel_sans/funnel_sans_variable.ttf"); } diff --git a/static/modern-normalize.min.css b/static/modern-normalize.min.css new file mode 100644 index 0000000..832e8b6 --- /dev/null +++ b/static/modern-normalize.min.css @@ -0,0 +1,9 @@ +/** + * Minified by jsDelivr using clean-css v5.3.2. + * Original file: /npm/modern-normalize@3.0.1/modern-normalize.css + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */ +*,::after,::before{box-sizing:border-box}html{font-family:system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji';line-height:1.15;-webkit-text-size-adjust:100%;tab-size:4}body{margin:0}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-color:currentcolor}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}legend{padding:0}progress{vertical-align:baseline}::-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} +/*# sourceMappingURL=/sm/d2d8cd206fb9f42f071e97460f3ad9c875edb5e7a4b10f900a83cdf8401c53a9.map */ \ No newline at end of file diff --git a/static/viewer.css b/static/viewer.css new file mode 100644 index 0000000..b696d5d --- /dev/null +++ b/static/viewer.css @@ -0,0 +1,36 @@ +table.viewer { + border-collapse: collapse; +} + +table.viewer > thead > tr > th { + border: solid 1px #ccc; + border-top: none; + font-family: "Funnel Sans"; + background: #0001; + + &:first-child { + border-left: none; + } +} + +table.viewer .padded-cell { + padding: 0.5rem; +} + +table.viewer .clickable-header-cell { + appearance: none; + border: none; + width: 100%; + height: 100%; + font-weight: inherit; + font-size: inherit; + font-family: inherit; +} + +table.viewer > tbody > tr > td { + border: solid 1px #ccc; + + &:first-child { + border-left: none; + } +} diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 4ff9cd2..0000000 --- a/templates/base.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - {% block title %}Interim{% endblock %} - {% include "meta_tags.html" %} - - - - {% block main %}{% endblock main %} - - diff --git a/templates/nav.html b/templates/nav.html deleted file mode 100644 index 9c348dd..0000000 --- a/templates/nav.html +++ /dev/null @@ -1,7 +0,0 @@ -