selection creation
This commit is contained in:
parent
10959e6c2b
commit
e8d017314b
89 changed files with 2238 additions and 337 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ target
|
||||||
node_modules
|
node_modules
|
||||||
js_dist
|
js_dist
|
||||||
pgdata
|
pgdata
|
||||||
|
.vite
|
||||||
|
|
|
||||||
123
Cargo.lock
generated
123
Cargo.lock
generated
|
|
@ -215,7 +215,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"hmac 0.11.0",
|
"hmac 0.11.0",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.9.9",
|
"sha2 0.9.9",
|
||||||
|
|
@ -261,6 +261,7 @@ checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"axum-macros",
|
"axum-macros",
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -280,8 +281,10 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper 1.0.2",
|
"sync_wrapper 1.0.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -776,6 +779,12 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
@ -1624,7 +1633,30 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|
@ -1638,10 +1670,13 @@ dependencies = [
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"interim-models",
|
||||||
|
"interim-pgtypes",
|
||||||
"nom 8.0.0",
|
"nom 8.0.0",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"reqwest 0.12.15",
|
"reqwest 0.12.15",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -1885,7 +1920,7 @@ dependencies = [
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
@ -1936,7 +1971,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -2238,8 +2273,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -2249,7 +2294,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -2261,6 +2316,15 @@ dependencies = [
|
||||||
"getrandom 0.2.16",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.12"
|
version = "0.5.12"
|
||||||
|
|
@ -2438,7 +2502,7 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
@ -2781,7 +2845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2950,7 +3014,7 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
|
@ -2990,7 +3054,7 @@ dependencies = [
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
|
@ -3335,6 +3399,18 @@ dependencies = [
|
||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
|
|
@ -3513,6 +3589,23 @@ version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.18.0"
|
version = "1.18.0"
|
||||||
|
|
@ -3582,6 +3675,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
|
||||||
50
Cargo.toml
50
Cargo.toml
|
|
@ -1,45 +1,27 @@
|
||||||
[package]
|
|
||||||
name = "interim"
|
|
||||||
version = "0.0.1"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "3"
|
||||||
|
members = ["interim-*"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.0.1"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = { version = "1.0.91", features = ["backtrace"] }
|
anyhow = { version = "1.0.91", features = ["backtrace"] }
|
||||||
chrono = { version = "0.4.41", features = ["serde"] }
|
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"] }
|
reqwest = { version = "0.12.8", features = ["json"] }
|
||||||
serde = { version = "1.0.213", features = ["derive"] }
|
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"
|
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"
|
thiserror = "2.0.12"
|
||||||
tokio = { workspace = true }
|
tokio = { version = "1.42.0", features = ["full"] }
|
||||||
tower = "0.5.2"
|
|
||||||
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] }
|
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
|
uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] }
|
||||||
uuid = { workspace = true }
|
|
||||||
validator = { version = "0.20.0", features = ["derive"] }
|
validator = { version = "0.20.0", features = ["derive"] }
|
||||||
|
|
|
||||||
90
bacon.toml
90
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"
|
default_job = "check"
|
||||||
env.CARGO_TERM_COLOR = "always"
|
env.CARGO_TERM_COLOR = "always"
|
||||||
|
|
||||||
|
[jobs.clippy]
|
||||||
|
command = ["cargo", "clippy"]
|
||||||
|
need_stdout = false
|
||||||
|
watch = ["interim-*"]
|
||||||
|
|
||||||
[jobs.check]
|
[jobs.check]
|
||||||
command = ["cargo", "check"]
|
command = ["cargo", "check"]
|
||||||
need_stdout = false
|
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]
|
[jobs.run-worker]
|
||||||
command = [
|
command = [
|
||||||
"cargo", "run", "worker",
|
"cargo", "run", "worker",
|
||||||
# put launch parameters for your program behind a `--` separator
|
|
||||||
]
|
]
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
allow_warnings = true
|
allow_warnings = true
|
||||||
background = true
|
background = true
|
||||||
default_watch = false
|
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]
|
[jobs.run-server]
|
||||||
command = ["cargo", "run", "serve"]
|
command = ["cargo", "run", "serve"]
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
|
|
@ -91,21 +26,22 @@ allow_warnings = true
|
||||||
background = false
|
background = false
|
||||||
on_change_strategy = "kill_then_restart"
|
on_change_strategy = "kill_then_restart"
|
||||||
kill = ["kill", "-s", "INT"]
|
kill = ["kill", "-s", "INT"]
|
||||||
watch = ["src", "templates"]
|
watch = ["interim-*", "components/src"]
|
||||||
|
|
||||||
# This parameterized job runs the example of your choice, as soon
|
[jobs.watch-components]
|
||||||
# as the code compiles.
|
command = ["deno", "task", "--cwd=components", "build"]
|
||||||
# Call it as
|
default_watch = false
|
||||||
# bacon ex -- my-example
|
watch = ["components/src"]
|
||||||
[jobs.ex]
|
|
||||||
command = ["cargo", "run", "--example"]
|
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
allow_warnings = true
|
allow_warnings = true
|
||||||
|
background = false
|
||||||
|
|
||||||
# You may define here keybindings that would be specific to
|
# You may define here keybindings that would be specific to
|
||||||
# a project, for example a shortcut to launch a specific job.
|
# a project, for example a shortcut to launch a specific job.
|
||||||
# Shortcuts to internal functions (scrolling, toggling, etc.)
|
# Shortcuts to internal functions (scrolling, toggling, etc.)
|
||||||
# should go in your personal global prefs.toml file instead.
|
# should go in your personal global prefs.toml file instead.
|
||||||
[keybindings]
|
[keybindings]
|
||||||
|
b = "job:watch-components"
|
||||||
|
c = "job:clippy"
|
||||||
r = "job:run-server"
|
r = "job:run-server"
|
||||||
w = "job:run-worker"
|
w = "job:run-worker"
|
||||||
|
|
|
||||||
6
components/deno.lock
generated
6
components/deno.lock
generated
|
|
@ -1,11 +1,17 @@
|
||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
|
"jsr:@std/path@*": "1.0.8",
|
||||||
"npm:@lit/context@^1.1.2": "1.1.2",
|
"npm:@lit/context@^1.1.2": "1.1.2",
|
||||||
"npm:lit@^3.2.0": "3.2.1",
|
"npm:lit@^3.2.0": "3.2.1",
|
||||||
"npm:vite@*": "5.4.5",
|
"npm:vite@*": "5.4.5",
|
||||||
"npm:vite@^5.2.10": "5.4.5"
|
"npm:vite@^5.2.10": "5.4.5"
|
||||||
},
|
},
|
||||||
|
"jsr": {
|
||||||
|
"@std/path@1.0.8": {
|
||||||
|
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
|
||||||
|
}
|
||||||
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
"@esbuild/aix-ppc64@0.21.5": {
|
"@esbuild/aix-ppc64@0.21.5": {
|
||||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||||
|
|
|
||||||
58
components/src/add-selection-button.tsx
Normal file
58
components/src/add-selection-button.tsx
Normal file
|
|
@ -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<HTMLDialogElement> = 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`
|
||||||
|
<button type="button" class="th" @click="${this.showModal}">+</button>
|
||||||
|
<dialog ${ref(this._dialogRef)} closedby="any">
|
||||||
|
<add-selection-modal-content
|
||||||
|
columns="${this.columns}"
|
||||||
|
></add-selection-modal-content>
|
||||||
|
</dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
components/src/add-selection-modal-contents.tsx
Normal file
88
components/src/add-selection-modal-contents.tsx
Normal file
|
|
@ -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`
|
||||||
|
<div class="container">
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="${classMap({
|
||||||
|
tab: true,
|
||||||
|
"tab-active": this._tab === "existing_col",
|
||||||
|
})}"
|
||||||
|
type="button"
|
||||||
|
@click="${() => this.setTab("existing_col")}"
|
||||||
|
>
|
||||||
|
Select Existing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="${classMap({
|
||||||
|
tab: true,
|
||||||
|
"tab-active": this._tab === "new_col",
|
||||||
|
})}"
|
||||||
|
type="button"
|
||||||
|
@click="${() => this.setTab("new_col")}"
|
||||||
|
>
|
||||||
|
Create Column
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tab-controlled">
|
||||||
|
<div style="${styleMap({
|
||||||
|
display: this._tab === "existing_col" ? "block" : "none",
|
||||||
|
})}">
|
||||||
|
<form method="post" action="add-selection">
|
||||||
|
<label for="column-select">Column:</label>
|
||||||
|
<select name="column" id="column-select">
|
||||||
|
${this.columns.map((column) =>
|
||||||
|
html`
|
||||||
|
<option value="${column.attname}">${column.attname}</option>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Add to Table</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { CellText } from "./cell-text.ts";
|
|
||||||
export { CellUuid } from "./cell-uuid.ts";
|
|
||||||
2
components/src/entrypoints/cells.ts
Normal file
2
components/src/entrypoints/cells.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { CellText } from "../cell-text.ts";
|
||||||
|
export { CellUuid } from "../cell-uuid.ts";
|
||||||
32
components/src/entrypoints/custom-icon.ts
Normal file
32
components/src/entrypoints/custom-icon.ts
Normal file
|
|
@ -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`
|
||||||
|
<img src="${new URL(
|
||||||
|
path,
|
||||||
|
import.meta.url,
|
||||||
|
).href}" alt="${this.alt}">
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
components/src/entrypoints/dev-reloader.ts
Normal file
118
components/src/entrypoints/dev-reloader.ts
Normal file
|
|
@ -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`
|
||||||
|
<button type="button" class="widget" @click="${this._toggleAuto}">
|
||||||
|
<div class="${classMap({
|
||||||
|
indicator: true,
|
||||||
|
connected: this._connected,
|
||||||
|
})}"></div>
|
||||||
|
<div class="label">
|
||||||
|
${this.auto ? "Disable" : "Enable"} auto-reload
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
components/src/entrypoints/lens-controls.ts
Normal file
1
components/src/entrypoints/lens-controls.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { LensControls } from "../lens-controls.ts";
|
||||||
1
components/src/entrypoints/viewer-components.tsx
Normal file
1
components/src/entrypoints/viewer-components.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { AddSelectionButton } from "../add-selection-button.tsx";
|
||||||
214
components/src/lens-controls-shell.ts
Normal file
214
components/src/lens-controls-shell.ts
Normal file
|
|
@ -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<HTMLDivElement>();
|
||||||
|
|
||||||
|
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`
|
||||||
|
<div id="container-positioner">
|
||||||
|
<div id="container">
|
||||||
|
<ul id="tab-box" @click="${this.open}">
|
||||||
|
${this.tabs.map(({ icon, label, value }) =>
|
||||||
|
html`
|
||||||
|
<li>
|
||||||
|
<button type="button" class="${classMap({
|
||||||
|
active: this.activeTab === value,
|
||||||
|
})}" @click="${() => {
|
||||||
|
this.dispatchEvent(new SelectTabEvent(value));
|
||||||
|
}}">
|
||||||
|
${icon
|
||||||
|
? html`<custom-icon name=${icon} alt=${label}></custom-label>`
|
||||||
|
: label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div id="control-bar" class="${classMap({
|
||||||
|
open: this._isOpen,
|
||||||
|
})}" @click="${this.open}">
|
||||||
|
<slot name="control-bar"></slot>
|
||||||
|
</div>
|
||||||
|
<div id="control-buttons">
|
||||||
|
<slot name="control-buttons"></slot>
|
||||||
|
</div>
|
||||||
|
<div id="control-panel-positioner">
|
||||||
|
<div
|
||||||
|
id="control-panel-container"
|
||||||
|
popover="auto"
|
||||||
|
${ref(
|
||||||
|
this._controlPanelRef,
|
||||||
|
)}
|
||||||
|
@toggle="${(ev: ToggleEvent) => {
|
||||||
|
this._isOpen = ev.newState === "open";
|
||||||
|
}}"
|
||||||
|
>
|
||||||
|
<div id="control-panel">
|
||||||
|
<slot name="control-panel"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
191
components/src/lens-controls.ts
Normal file
191
components/src/lens-controls.ts
Normal file
|
|
@ -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<LensControlsShell>();
|
||||||
|
|
||||||
|
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`
|
||||||
|
<form method="post" action="update-lens">
|
||||||
|
<lens-controls-shell tabs="${JSON.stringify(
|
||||||
|
TABS,
|
||||||
|
)}" active-tab="columns" ${ref(this._shellRef)}>
|
||||||
|
<div slot="control-bar" id="control-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Configure columns..."
|
||||||
|
@keydown="${() => {
|
||||||
|
this._shellRef.value?.open();
|
||||||
|
}}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div slot="control-buttons" id="control-buttons">
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
</div>
|
||||||
|
<div slot="control-panel" id="selections">
|
||||||
|
${this.selections.map((selection) =>
|
||||||
|
html`
|
||||||
|
<div class="selection">
|
||||||
|
<div class="visibility">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="selection_visibility_${selection.id}"
|
||||||
|
?checked="${selection.visible}"
|
||||||
|
@input="${(ev: InputEvent) =>
|
||||||
|
this._handleVisibilityInput(ev, selection.id)}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="selection-filters">
|
||||||
|
<selection-filters
|
||||||
|
selection-id="${selection.id}"
|
||||||
|
filters="${JSON.stringify(selection.attr_filters)}"
|
||||||
|
@change="${(ev: Event) =>
|
||||||
|
this._handleSelectionFiltersChange(ev, selection.id)}"
|
||||||
|
></selection-filters>
|
||||||
|
</div>
|
||||||
|
<div>Text</div>
|
||||||
|
<div class="cast">
|
||||||
|
<custom-icon name="arrow-right"></custom-icon>
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="selection_label_${selection.id}"
|
||||||
|
@change="${(
|
||||||
|
ev: Event,
|
||||||
|
) => {
|
||||||
|
this.selections = this.selections.map((
|
||||||
|
iterSelection,
|
||||||
|
) => (iterSelection.id === selection.id
|
||||||
|
? {
|
||||||
|
...iterSelection,
|
||||||
|
label: (ev.target as HTMLInputElement).value.trim() ??
|
||||||
|
undefined,
|
||||||
|
}
|
||||||
|
: iterSelection)
|
||||||
|
);
|
||||||
|
}}"
|
||||||
|
value="${selection.label ?? ""}"
|
||||||
|
placeholder="Customize name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</lens-controls-shell>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
259
components/src/lens-controls.ts.bak
Normal file
259
components/src/lens-controls.ts.bak
Normal file
|
|
@ -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<HTMLDivElement>();
|
||||||
|
|
||||||
|
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`
|
||||||
|
<div id="container-positioner">
|
||||||
|
<div id="container">
|
||||||
|
<ul id="tab-box">
|
||||||
|
<li>
|
||||||
|
<button type="button">Filters</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="active">Columns</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div id="control-bar" class="${classMap({ open: this._isOpen })}">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="control-bar-input"
|
||||||
|
placeholder="2 of 2 shown"
|
||||||
|
@click="${this.open}"
|
||||||
|
@keydown="${this.open}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div id="control-panel-positioner">
|
||||||
|
<div
|
||||||
|
id="control-panel-container"
|
||||||
|
popover="auto"
|
||||||
|
${ref(
|
||||||
|
this._controlPanelDialog,
|
||||||
|
)}
|
||||||
|
@toggle="${(ev: ToggleEvent) => {
|
||||||
|
this._isOpen = ev.newState === "open";
|
||||||
|
}}"
|
||||||
|
>
|
||||||
|
<div id="control-panel">
|
||||||
|
<div id="selections">
|
||||||
|
${this.selections.map((selection) =>
|
||||||
|
html`
|
||||||
|
<div class="selection">
|
||||||
|
<div class="visibility">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
?checked="${selection.visible}"
|
||||||
|
@input="${(ev: InputEvent) =>
|
||||||
|
this._handleVisibilityInput(ev, selection.id)}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="selection-filters">
|
||||||
|
<selection-filters
|
||||||
|
selection-id="${selection.id}"
|
||||||
|
filters="${JSON.stringify(selection.attr_filters)}"
|
||||||
|
@change="${(ev: Event) =>
|
||||||
|
this._handleSelectionFiltersChange(ev, selection.id)}"
|
||||||
|
></selection-filters>
|
||||||
|
</div>
|
||||||
|
<div class="conversions"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
components/src/sel-item.ts
Normal file
28
components/src/sel-item.ts
Normal file
|
|
@ -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`
|
||||||
|
<div class="sel-item">
|
||||||
|
<button class="remove" @click="${this._handleDelete}">del</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
components/src/selection-filters.ts
Normal file
57
components/src/selection-filters.ts
Normal file
|
|
@ -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`
|
||||||
|
<ol id="selection-filters">
|
||||||
|
${this.filters.map((filter) =>
|
||||||
|
html`
|
||||||
|
<li class="selection-filter">
|
||||||
|
${"NameEq" in filter
|
||||||
|
? html`
|
||||||
|
<name-eq-filter>${filter.NameEq}</name-eq-filter>
|
||||||
|
`
|
||||||
|
: undefined} ${"NameMatches" in filter
|
||||||
|
? html`
|
||||||
|
<name-matches-filter>${filter.NameMatches}</name-matches-filter>
|
||||||
|
`
|
||||||
|
: undefined} ${"TypeEq" in filter
|
||||||
|
? html`
|
||||||
|
<type-eq-filter>${filter.TypeEq}</type-eq-filter>
|
||||||
|
`
|
||||||
|
: undefined}
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement("name-eq-filter")
|
||||||
|
export class NameEqFilter extends LitElement {
|
||||||
|
protected override render() {
|
||||||
|
return html`
|
||||||
|
<div><slot></slot></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
components/src/selections.ts
Normal file
21
components/src/selections.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import * as path from "jsr:@std/path";
|
||||||
|
|
||||||
import "lit";
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: ["src/cells.ts"],
|
entry,
|
||||||
formats: ["es"],
|
formats: ["es"],
|
||||||
},
|
},
|
||||||
outDir: "../js_dist",
|
outDir: "../js_dist",
|
||||||
|
|
|
||||||
12
interim-models/Cargo.toml
Normal file
12
interim-models/Cargo.toml
Normal file
|
|
@ -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 }
|
||||||
3
interim-models/migrations/20250528233517_lenses.down.sql
Normal file
3
interim-models/migrations/20250528233517_lenses.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
drop table if exists lens_selections;
|
||||||
|
drop table if exists lenses;
|
||||||
|
drop type if exists lens_display_type;
|
||||||
21
interim-models/migrations/20250528233517_lenses.up.sql
Normal file
21
interim-models/migrations/20250528233517_lenses.up.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
129
interim-models/src/lens.rs
Normal file
129
interim-models/src/lens.rs
Normal file
|
|
@ -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<Option<Self>, 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<Vec<Self>, 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<Vec<Selection>, sqlx::Error> {
|
||||||
|
query_as!(
|
||||||
|
Selection,
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
|
||||||
|
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<Lens, sqlx::Error> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
interim-models/src/lib.rs
Normal file
4
interim-models/src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod lens;
|
||||||
|
pub mod selection;
|
||||||
|
|
||||||
|
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
|
||||||
116
interim-models/src/selection.rs
Normal file
116
interim-models/src/selection.rs
Normal file
|
|
@ -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<Vec<AttrFilter>>,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub display_type: Option<SelectionDisplayType>,
|
||||||
|
pub visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selection {
|
||||||
|
pub fn insertable_builder() -> InsertableSelectionBuilder {
|
||||||
|
InsertableSelectionBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec<Field> {
|
||||||
|
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<String>,
|
||||||
|
pub display_type: Option<SelectionDisplayType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Builder, Clone, Debug)]
|
||||||
|
pub struct InsertableSelection {
|
||||||
|
lens_id: Uuid,
|
||||||
|
attr_filters: Vec<AttrFilter>,
|
||||||
|
#[builder(default, setter(strip_option))]
|
||||||
|
label: Option<String>,
|
||||||
|
#[builder(default, setter(strip_option))]
|
||||||
|
display_type: Option<SelectionDisplayType>,
|
||||||
|
#[builder(default = true)]
|
||||||
|
visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InsertableSelection {
|
||||||
|
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Selection, sqlx::Error> {
|
||||||
|
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<Vec<AttrFilter>>",
|
||||||
|
label,
|
||||||
|
display_type as "display_type: SelectionDisplayType",
|
||||||
|
visible
|
||||||
|
"#,
|
||||||
|
Uuid::now_v7(),
|
||||||
|
self.lens_id,
|
||||||
|
sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json<Vec<AttrFilter>>,
|
||||||
|
self.label,
|
||||||
|
self.display_type as Option<SelectionDisplayType>,
|
||||||
|
self.visible,
|
||||||
|
)
|
||||||
|
.fetch_one(app_db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
11
interim-pgtypes/Cargo.toml
Normal file
11
interim-pgtypes/Cargo.toml
Normal file
|
|
@ -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 }
|
||||||
1
interim-pgtypes/src/lib.rs
Normal file
1
interim-pgtypes/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod pg_attribute;
|
||||||
|
|
@ -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 {
|
pub struct PgAttribute {
|
||||||
/// The table this column belongs to
|
/// The table this column belongs to
|
||||||
pub attrelid: Oid,
|
pub attrelid: Oid,
|
||||||
36
interim-server/Cargo.toml
Normal file
36
interim-server/Cargo.toml
Normal file
|
|
@ -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 }
|
||||||
|
|
@ -40,7 +40,7 @@ pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
|
||||||
.set_redirect_uri(
|
.set_redirect_uri(
|
||||||
RedirectUrl::new(format!(
|
RedirectUrl::new(format!(
|
||||||
"{}{}/auth/callback",
|
"{}{}/auth/callback",
|
||||||
settings.frontend_host, settings.base_path
|
settings.frontend_host, settings.root_path
|
||||||
))
|
))
|
||||||
.context("failed to create new redirection URL")?,
|
.context("failed to create new redirection URL")?,
|
||||||
))
|
))
|
||||||
|
|
@ -59,7 +59,7 @@ async fn start_login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
State(Settings {
|
State(Settings {
|
||||||
auth: auth_settings,
|
auth: auth_settings,
|
||||||
base_path,
|
root_path,
|
||||||
..
|
..
|
||||||
}): State<Settings>,
|
}): State<Settings>,
|
||||||
State(session_store): State<PgStore>,
|
State(session_store): State<PgStore>,
|
||||||
|
|
@ -74,7 +74,7 @@ async fn start_login(
|
||||||
|
|
||||||
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
|
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
|
||||||
tracing::debug!("already logged in, redirecting...");
|
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());
|
assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none());
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ async fn start_login(
|
||||||
/// HTTP get handler for /logout
|
/// HTTP get handler for /logout
|
||||||
async fn logout(
|
async fn logout(
|
||||||
State(Settings {
|
State(Settings {
|
||||||
base_path,
|
root_path,
|
||||||
auth: auth_settings,
|
auth: auth_settings,
|
||||||
..
|
..
|
||||||
}): State<Settings>,
|
}): State<Settings>,
|
||||||
|
|
@ -134,7 +134,7 @@ async fn logout(
|
||||||
}
|
}
|
||||||
let jar = jar.remove(Cookie::from(auth_settings.cookie_name));
|
let jar = jar.remove(Cookie::from(auth_settings.cookie_name));
|
||||||
tracing::debug!("Removed session cookie from jar.");
|
tracing::debug!("Removed session cookie from jar.");
|
||||||
Ok((jar, Redirect::to(&format!("{}/", base_path))))
|
Ok((jar, Redirect::to(&format!("{}/", root_path))))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -150,7 +150,7 @@ async fn callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
State(Settings {
|
State(Settings {
|
||||||
auth: auth_settings,
|
auth: auth_settings,
|
||||||
base_path,
|
root_path,
|
||||||
..
|
..
|
||||||
}): State<Settings>,
|
}): State<Settings>,
|
||||||
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
|
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
|
||||||
|
|
@ -205,7 +205,7 @@ async fn callback(
|
||||||
}
|
}
|
||||||
tracing::debug!("successfully authenticated");
|
tracing::debug!("successfully authenticated");
|
||||||
Ok(Redirect::to(
|
Ok(Redirect::to(
|
||||||
&redirect_target.unwrap_or(format!("{}/", base_path)),
|
&redirect_target.unwrap_or(format!("{}/", root_path)),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::Request,
|
||||||
|
|
@ -10,8 +12,7 @@ use clap::{Parser, Subcommand};
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tower::ServiceBuilder;
|
use tower::ServiceBuilder;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
compression::CompressionLayer, normalize_path::NormalizePathLayer,
|
compression::CompressionLayer, set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
|
||||||
set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -51,7 +52,6 @@ pub async fn serve_command(state: AppState) -> Result<()> {
|
||||||
CONTENT_SECURITY_POLICY,
|
CONTENT_SECURITY_POLICY,
|
||||||
HeaderValue::from_static("frame-ancestors 'none'"),
|
HeaderValue::from_static("frame-ancestors 'none'"),
|
||||||
))
|
))
|
||||||
.layer(NormalizePathLayer::trim_trailing_slash())
|
|
||||||
.service(new_router(state.clone()));
|
.service(new_router(state.clone()));
|
||||||
|
|
||||||
let listener =
|
let listener =
|
||||||
|
|
@ -62,12 +62,15 @@ pub async fn serve_command(state: AppState) -> Result<()> {
|
||||||
"App running at http://{}:{}{}",
|
"App running at http://{}:{}{}",
|
||||||
state.settings.host,
|
state.settings.host,
|
||||||
state.settings.port,
|
state.settings.port,
|
||||||
state.settings.base_path
|
state.settings.root_path
|
||||||
);
|
);
|
||||||
|
|
||||||
axum::serve(listener, ServiceExt::<Request>::into_make_service(router))
|
axum::serve(
|
||||||
.await
|
listener,
|
||||||
.map_err(Into::into)
|
ServiceExt::<Request>::into_make_service_with_connect_info::<SocketAddr>(router),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn worker_command(args: &WorkerArgs, state: AppState) -> Result<()> {
|
pub async fn worker_command(args: &WorkerArgs, state: AppState) -> Result<()> {
|
||||||
|
|
@ -2,14 +2,16 @@ use std::fmt::Display;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use derive_builder::Builder;
|
use interim_models::selection::SelectionDisplayType;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
|
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
|
||||||
error::BoxDynError,
|
error::BoxDynError,
|
||||||
postgres::{PgRow, PgTypeInfo, PgValueRef},
|
postgres::{PgRow, PgTypeInfo, PgValueRef},
|
||||||
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
|
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z";
|
||||||
|
|
||||||
pub enum Value {
|
pub enum Value {
|
||||||
Text(Option<String>),
|
Text(Option<String>),
|
||||||
Integer(Option<i32>),
|
Integer(Option<i32>),
|
||||||
|
|
@ -17,29 +19,11 @@ pub enum Value {
|
||||||
Uuid(Option<Uuid>),
|
Uuid(Option<Uuid>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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<Vec<String>>,
|
|
||||||
|
|
||||||
/// Text to display in place of actual column name
|
|
||||||
#[builder(default)]
|
|
||||||
pub label: Option<String>,
|
|
||||||
|
|
||||||
#[builder(default = "true")]
|
|
||||||
pub editable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ToHtmlString {
|
pub trait ToHtmlString {
|
||||||
fn to_html_string(&self, options: &FieldOptions) -> String;
|
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO rewrite with thiserror
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FromSqlError {
|
pub struct FromSqlError {
|
||||||
message: String,
|
message: String,
|
||||||
|
|
@ -72,7 +56,7 @@ impl Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToHtmlString for Value {
|
impl ToHtmlString for Value {
|
||||||
fn to_html_string(&self, options: &FieldOptions) -> String {
|
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String {
|
||||||
macro_rules! cell_html {
|
macro_rules! cell_html {
|
||||||
($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => {
|
($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => {
|
||||||
{
|
{
|
||||||
|
|
@ -97,7 +81,7 @@ impl ToHtmlString for Value {
|
||||||
Self::Timestamptz(value) => cell_html!(
|
Self::Timestamptz(value) => cell_html!(
|
||||||
"cell-timestamptz",
|
"cell-timestamptz",
|
||||||
value,
|
value,
|
||||||
"format" => options.date_format
|
"format" => DEFAULT_TIMESTAMP_FORMAT
|
||||||
),
|
),
|
||||||
Self::Uuid(value) => cell_html!("cell-uuid", value),
|
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<Field>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Field {
|
|
||||||
pub options: FieldOptions,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
72
interim-server/src/lenses.rs
Normal file
72
interim-server/src/lenses.rs
Normal file
|
|
@ -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<Vec<AttrFilter>>,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub display_type: Option<SelectionDisplayType>,
|
||||||
|
pub visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selection {
|
||||||
|
pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec<Field> {
|
||||||
|
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<String>,
|
||||||
|
pub display_type: Option<SelectionDisplayType>,
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use clap::Parser as _;
|
use clap::Parser as _;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
|
use interim_models::MIGRATOR;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app_state::{App, AppState},
|
app_state::{App, AppState},
|
||||||
cli::{serve_command, worker_command, Cli, Commands},
|
cli::{Cli, Commands, serve_command, worker_command},
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -17,9 +18,9 @@ mod bases;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod data_layer;
|
mod data_layer;
|
||||||
mod db_conns;
|
mod db_conns;
|
||||||
|
mod lenses;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod pg_acls;
|
mod pg_acls;
|
||||||
mod pg_attributes;
|
|
||||||
mod pg_classes;
|
mod pg_classes;
|
||||||
mod pg_databases;
|
mod pg_databases;
|
||||||
mod pg_roles;
|
mod pg_roles;
|
||||||
|
|
@ -44,8 +45,8 @@ async fn main() {
|
||||||
|
|
||||||
let state: AppState = App::from_settings(settings.clone()).await.unwrap().into();
|
let state: AppState = App::from_settings(settings.clone()).await.unwrap().into();
|
||||||
|
|
||||||
if settings.run_database_migrations == Some(1) {
|
if settings.run_database_migrations != 0 {
|
||||||
sqlx::migrate!().run(&state.app_db).await.unwrap();
|
MIGRATOR.run(&state.app_db).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
extract::{ws::WebSocket, ConnectInfo, WebSocketUpgrade},
|
||||||
http::{header::CACHE_CONTROL, HeaderValue},
|
http::{header::CACHE_CONTROL, HeaderValue},
|
||||||
routing::{get, post},
|
response::Response,
|
||||||
|
routing::{any, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use axum_extra::routing::RouterExt as _;
|
||||||
use tower::ServiceBuilder;
|
use tower::ServiceBuilder;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
services::{ServeDir, ServeFile},
|
services::{ServeDir, ServeFile},
|
||||||
|
|
@ -12,38 +17,67 @@ use tower_http::{
|
||||||
use crate::{app_state::AppState, auth, routes};
|
use crate::{app_state::AppState, auth, routes};
|
||||||
|
|
||||||
pub fn new_router(state: AppState) -> Router<()> {
|
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()
|
let app = Router::new()
|
||||||
.route("/databases", get(routes::bases::list_bases_page))
|
.route_with_tsr("/databases/", get(routes::bases::list_bases_page))
|
||||||
.route("/databases/add", post(routes::bases::add_base_page))
|
.route_with_tsr("/databases/add/", post(routes::bases::add_base_page))
|
||||||
.route(
|
.route_with_tsr(
|
||||||
"/d/{base_id}/config",
|
"/d/{base_id}/config/",
|
||||||
get(routes::bases::base_config_page_get),
|
get(routes::bases::base_config_page_get),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/d/{base_id}/config",
|
"/d/{base_id}/config/",
|
||||||
post(routes::bases::base_config_page_post),
|
post(routes::bases::base_config_page_post),
|
||||||
)
|
)
|
||||||
.route(
|
.route_with_tsr(
|
||||||
"/d/{base_id}/relations",
|
"/d/{base_id}/relations/",
|
||||||
get(routes::relations::list_relations_page),
|
get(routes::relations::list_relations_page),
|
||||||
)
|
)
|
||||||
.route(
|
.route_with_tsr(
|
||||||
"/d/{base_id}/r/{class_oid}/rbac",
|
"/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),
|
get(routes::relations::rel_rbac_page),
|
||||||
)
|
)
|
||||||
.route(
|
.route_with_tsr(
|
||||||
"/d/{base_id}/r/{class_oid}/rbac/invite",
|
"/d/{base_id}/r/{class_oid}/rbac/invite/",
|
||||||
get(routes::relations::rel_rbac_invite_page_get),
|
get(routes::relations::rel_rbac_invite_page_get),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/d/{base_id}/r/{class_oid}/rbac/invite",
|
"/d/{base_id}/r/{class_oid}/rbac/invite",
|
||||||
post(routes::relations::rel_rbac_invite_page_post),
|
post(routes::relations::rel_rbac_invite_page_post),
|
||||||
)
|
)
|
||||||
.route(
|
.route_with_tsr(
|
||||||
"/d/{base_id}/r/{class_oid}/viewer",
|
"/d/{base_id}/r/{class_oid}/lenses/",
|
||||||
get(routes::relations::viewer_page),
|
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())
|
.nest("/auth", auth::new_router())
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
.layer(SetResponseHeaderLayer::if_not_present(
|
||||||
CACHE_CONTROL,
|
CACHE_CONTROL,
|
||||||
|
|
@ -54,7 +88,9 @@ pub fn new_router(state: AppState) -> Router<()> {
|
||||||
ServiceBuilder::new()
|
ServiceBuilder::new()
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
.layer(SetResponseHeaderLayer::if_not_present(
|
||||||
CACHE_CONTROL,
|
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(
|
.service(
|
||||||
ServeDir::new("js_dist").not_found_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<SocketAddr>,
|
||||||
|
) -> 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)]
|
// #[derive(Deserialize)]
|
||||||
// struct RbacIndexPath {
|
// struct RbacIndexPath {
|
||||||
// oid: u32,
|
// oid: u32,
|
||||||
|
|
@ -16,14 +16,12 @@ use crate::{
|
||||||
base_user_perms::sync_perms_for_base,
|
base_user_perms::sync_perms_for_base,
|
||||||
bases::Base,
|
bases::Base,
|
||||||
db_conns::{escape_identifier, init_role},
|
db_conns::{escape_identifier, init_role},
|
||||||
pg_databases::PgDatabase,
|
|
||||||
pg_roles::PgRole,
|
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
users::CurrentUser,
|
users::CurrentUser,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn list_bases_page(
|
pub async fn list_bases_page(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
|
@ -33,14 +31,14 @@ pub async fn list_bases_page(
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "list_bases.html")]
|
#[template(path = "list_bases.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
base_path: String,
|
|
||||||
bases: Vec<Base>,
|
bases: Vec<Base>,
|
||||||
|
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(
|
pub async fn add_base_page(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
|
@ -62,7 +60,7 @@ values ($1, $2, $3, 'configure')",
|
||||||
)
|
)
|
||||||
.execute(&mut *app_db)
|
.execute(&mut *app_db)
|
||||||
.await?;
|
.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)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -71,7 +69,7 @@ pub struct BaseConfigPagePath {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn base_config_page_get(
|
pub async fn base_config_page_get(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
Path(params): Path<BaseConfigPagePath>,
|
Path(params): Path<BaseConfigPagePath>,
|
||||||
|
|
@ -84,9 +82,9 @@ pub async fn base_config_page_get(
|
||||||
#[template(path = "base_config.html")]
|
#[template(path = "base_config.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
base: Base,
|
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)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -96,7 +94,7 @@ pub struct BaseConfigPageForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn base_config_page_post(
|
pub async fn base_config_page_post(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
State(mut base_pooler): State<BasePooler>,
|
State(mut base_pooler): State<BasePooler>,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
|
@ -139,5 +137,5 @@ pub async fn base_config_page_post(
|
||||||
.await?;
|
.await?;
|
||||||
sync_perms_for_base(base.id, &mut app_db, &mut client).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())
|
||||||
}
|
}
|
||||||
283
interim-server/src/routes/lenses.rs
Normal file
283
interim-server/src/routes/lenses.rs
Normal file
|
|
@ -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<Settings>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// 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<Lens>,
|
||||||
|
settings: Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
base_id,
|
||||||
|
class_oid,
|
||||||
|
lenses,
|
||||||
|
settings,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_lens_page_get(
|
||||||
|
State(settings): State<Settings>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// 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<Settings>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
|
||||||
|
Form(AddLensPagePostForm { name }): Form<AddLensPagePostForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// 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<Settings>,
|
||||||
|
State(mut base_pooler): State<BasePooler>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Path(LensPagePath {
|
||||||
|
base_id,
|
||||||
|
class_oid,
|
||||||
|
lens_id,
|
||||||
|
}): Path<LensPagePath>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// 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<Field> = 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::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
escape_identifier(&class.relname),
|
||||||
|
))
|
||||||
|
.bind(FRONTEND_ROW_LIMIT)
|
||||||
|
.fetch_all(&mut *client)
|
||||||
|
.await?;
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "lens.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
fields: Vec<Field>,
|
||||||
|
all_columns: Vec<PgAttribute>,
|
||||||
|
rows: Vec<PgRow>,
|
||||||
|
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<Settings>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Path(LensPagePath {
|
||||||
|
base_id,
|
||||||
|
class_oid,
|
||||||
|
lens_id,
|
||||||
|
}): Path<LensPagePath>,
|
||||||
|
Form(form): Form<AddSelectionPageForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<Settings>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Path(LensPagePath {
|
||||||
|
base_id,
|
||||||
|
class_oid,
|
||||||
|
lens_id,
|
||||||
|
}): Path<LensPagePath>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<Settings>,
|
||||||
|
State(mut base_pooler): State<BasePooler>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Path(params): Path<ViewerPagePath>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
todo!("not yet implemented");
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod bases;
|
pub mod bases;
|
||||||
|
pub mod lenses;
|
||||||
pub mod relations;
|
pub mod relations;
|
||||||
|
|
@ -8,10 +8,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{
|
use sqlx::postgres::types::Oid;
|
||||||
postgres::{types::Oid, PgRow},
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -19,10 +16,8 @@ use crate::{
|
||||||
app_state::AppDbConn,
|
app_state::AppDbConn,
|
||||||
base_pooler::BasePooler,
|
base_pooler::BasePooler,
|
||||||
bases::Base,
|
bases::Base,
|
||||||
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
|
db_conns::init_role,
|
||||||
db_conns::{escape_identifier, init_role},
|
|
||||||
pg_acls::PgPrivilegeType,
|
pg_acls::PgPrivilegeType,
|
||||||
pg_attributes::fetch_attributes_for_rel,
|
|
||||||
pg_classes::{PgClass, PgRelKind},
|
pg_classes::{PgClass, PgRelKind},
|
||||||
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
|
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
|
||||||
rel_invitations::RelInvitation,
|
rel_invitations::RelInvitation,
|
||||||
|
|
@ -36,7 +31,7 @@ pub struct ListRelationsPagePath {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relations_page(
|
pub async fn list_relations_page(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
State(mut base_pooler): State<BasePooler>,
|
State(mut base_pooler): State<BasePooler>,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
|
@ -81,16 +76,16 @@ pub async fn list_relations_page(
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "list_rels.html")]
|
#[template(path = "list_rels.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
base_path: String,
|
|
||||||
base: Base,
|
base: Base,
|
||||||
rels: Vec<PgClass>,
|
rels: Vec<PgClass>,
|
||||||
|
settings: Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
ResponseTemplate {
|
ResponseTemplate {
|
||||||
base,
|
base,
|
||||||
base_path,
|
|
||||||
rels: accessible_rels,
|
rels: accessible_rels,
|
||||||
|
settings,
|
||||||
}
|
}
|
||||||
.render()?,
|
.render()?,
|
||||||
)
|
)
|
||||||
|
|
@ -98,91 +93,26 @@ pub async fn list_relations_page(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ViewerPagePath {
|
pub struct RelPagePath {
|
||||||
base_id: Uuid,
|
base_id: Uuid,
|
||||||
class_oid: u32,
|
class_oid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn viewer_page(
|
pub async fn rel_index_page(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
State(mut base_pooler): State<BasePooler>,
|
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
Path(params): Path<ViewerPagePath>,
|
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let base = Base::fetch_by_id(params.base_id, &mut *app_db)
|
todo!();
|
||||||
.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::<Vec<_>>()
|
|
||||||
.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<Field>,
|
|
||||||
rows: Vec<PgRow>,
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rel_rbac_page(
|
pub async fn rel_rbac_page(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
State(mut base_pooler): State<BasePooler>,
|
State(mut base_pooler): State<BasePooler>,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
|
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// FIXME: auth
|
// FIXME: auth
|
||||||
let base = Base::fetch_by_id(base_id, &mut *app_db)
|
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 all_invites = RelInvitation::fetch_by_class_oid(Oid(class_oid), &mut *app_db).await?;
|
||||||
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
|
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
|
||||||
for invite in all_invites {
|
for invite in all_invites {
|
||||||
let entry = invites_by_email
|
let entry = invites_by_email.entry(invite.email.clone()).or_default();
|
||||||
.entry(invite.email.clone())
|
|
||||||
.or_insert(vec![]);
|
|
||||||
entry.push(invite);
|
entry.push(invite);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "rel_rbac.html")]
|
#[template(path = "rel_rbac.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
base_path: String,
|
|
||||||
base: Base,
|
base: Base,
|
||||||
interim_users: HashMap<String, User>,
|
interim_users: HashMap<String, User>,
|
||||||
invites_by_email: HashMap<String, Vec<RelInvitation>>,
|
invites_by_email: HashMap<String, Vec<RelInvitation>>,
|
||||||
pg_class: PgClass,
|
pg_class: PgClass,
|
||||||
|
settings: Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
ResponseTemplate {
|
ResponseTemplate {
|
||||||
base,
|
base,
|
||||||
base_path,
|
|
||||||
interim_users,
|
interim_users,
|
||||||
invites_by_email,
|
invites_by_email,
|
||||||
pg_class: class,
|
pg_class: class,
|
||||||
|
settings,
|
||||||
}
|
}
|
||||||
.render()?,
|
.render()?,
|
||||||
)
|
)
|
||||||
|
|
@ -245,14 +173,14 @@ pub async fn rel_rbac_page(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rel_rbac_invite_page_get(
|
pub async fn rel_rbac_invite_page_get(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "rbac_invite.html")]
|
#[template(path = "rbac_invite.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
base_path: String,
|
settings: Settings,
|
||||||
}
|
}
|
||||||
Ok(Html(ResponseTemplate { base_path }.render()?).into_response())
|
Ok(Html(ResponseTemplate { settings }.render()?).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -261,10 +189,10 @@ pub struct RbacInvitePagePostForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rel_rbac_invite_page_post(
|
pub async fn rel_rbac_invite_page_post(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
|
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
|
||||||
Form(form): Form<RbacInvitePagePostForm>,
|
Form(form): Form<RbacInvitePagePostForm>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// FIXME auth
|
// FIXME auth
|
||||||
|
|
@ -288,5 +216,9 @@ pub async fn rel_rbac_invite_page_post(
|
||||||
.upsert(&mut *app_db)
|
.upsert(&mut *app_db)
|
||||||
.await?;
|
.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())
|
||||||
}
|
}
|
||||||
|
|
@ -12,7 +12,12 @@ pub struct Settings {
|
||||||
/// slash but no trailing slash, for example "/app". For default behavior,
|
/// slash but no trailing slash, for example "/app". For default behavior,
|
||||||
/// leave as empty string.
|
/// leave as empty string.
|
||||||
#[serde(default)]
|
#[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.
|
/// postgresql:// URL for Interim's application database.
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
|
|
@ -21,7 +26,8 @@ pub struct Settings {
|
||||||
pub app_db_max_connections: u32,
|
pub app_db_max_connections: u32,
|
||||||
|
|
||||||
/// When set to 1, embedded SQLx migrations will be run on startup.
|
/// When set to 1, embedded SQLx migrations will be run on startup.
|
||||||
pub run_database_migrations: Option<u8>,
|
#[serde(default)]
|
||||||
|
pub run_database_migrations: u8,
|
||||||
|
|
||||||
/// Address for server to bind to
|
/// Address for server to bind to
|
||||||
#[serde(default = "default_host")]
|
#[serde(default = "default_host")]
|
||||||
|
|
@ -74,7 +74,7 @@ where
|
||||||
SESSION_KEY_AUTH_REDIRECT,
|
SESSION_KEY_AUTH_REDIRECT,
|
||||||
uri.path_and_query()
|
uri.path_and_query()
|
||||||
.map(|value| value.to_string())
|
.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? {
|
if let Some(cookie_value) = app_state.session_store.store_session(session).await? {
|
||||||
tracing::debug!("adding session cookie to jar");
|
tracing::debug!("adding session cookie to jar");
|
||||||
|
|
@ -96,7 +96,7 @@ where
|
||||||
};
|
};
|
||||||
return Err(Self::Rejection::SetCookiesAndRedirect(
|
return Err(Self::Rejection::SetCookiesAndRedirect(
|
||||||
jar,
|
jar,
|
||||||
format!("{}/auth/login", app_state.settings.base_path),
|
format!("{}/auth/login", app_state.settings.root_path),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let current_user = if let Some(value) =
|
let current_user = if let Some(value) =
|
||||||
11
interim-server/templates/add_lens.html
Normal file
11
interim-server/templates/add_lens.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<form method="post" action="">
|
||||||
|
<div>
|
||||||
|
<label for="lens-name-input">Lens name:</label>
|
||||||
|
<input type="text" name="name" id="lens-name-input">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
16
interim-server/templates/base.html
Normal file
16
interim-server/templates/base.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<title>{% block title %}Interim{% endblock %}</title>
|
||||||
|
{% include "meta_tags.html" %}
|
||||||
|
<link rel="stylesheet" href="{{ settings.root_path }}/modern-normalize.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ settings.root_path }}/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block main %}{% endblock main %}
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/dev-reloader.mjs"></script>
|
||||||
|
{% if settings.dev != 0 %}
|
||||||
|
<dev-reloader ws="ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz" auto=""></dev-reloader>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<script type="module" src="{{ base_path }}/js_dist/cells.mjs"></script>
|
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
<th>
|
<th>
|
||||||
<div>{{ field.options.label.clone().unwrap_or(field.name.clone()) }}</div>
|
<div>{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
|
||||||
</th>
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -19,7 +20,7 @@
|
||||||
<td>
|
<td>
|
||||||
{% match Value::get_from_row(row, field.name.as_str()) %}
|
{% match Value::get_from_row(row, field.name.as_str()) %}
|
||||||
{% when Ok with (value) %}
|
{% when Ok with (value) %}
|
||||||
{{ value.to_html_string(&field.options) | safe }}
|
{{ value.to_html_string(&field.display_type) | safe }}
|
||||||
{% when Err with (err) %}
|
{% when Err with (err) %}
|
||||||
<span class="pg-value-error">{{ err }}</span>
|
<span class="pg-value-error">{{ err }}</span>
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
|
|
@ -29,4 +30,5 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<lens-controls selections="{{ selections_json }}"></lens-controls>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
39
interim-server/templates/lens.html
Normal file
39
interim-server/templates/lens.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-components.mjs"></script>
|
||||||
|
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
|
||||||
|
<table class="viewer">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for field in fields %}
|
||||||
|
<th>
|
||||||
|
<div class="padded-cell">{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>
|
||||||
|
<add-selection-button columns="{{ all_columns | json }}"></add-selection-button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
{% for field in fields %}
|
||||||
|
<td>
|
||||||
|
{% 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) %}
|
||||||
|
<span class="pg-value-error">{{ err }}</span>
|
||||||
|
{% endmatch %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<lens-controls selections="{{ selections_json }}"></lens-controls>
|
||||||
|
{% endblock %}
|
||||||
19
interim-server/templates/lenses.html
Normal file
19
interim-server/templates/lenses.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{% for lens in lenses %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="{{ settings.root_path }}/d/{{ base_id.simple() }}/r/{{ class_oid }}/l/{{ lens.id.simple() }}"
|
||||||
|
>
|
||||||
|
{{ lens.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<form method="post" action="{{ base_path }}/databases/add">
|
<form method="post" action="{{ settings.root_path }}/databases/add">
|
||||||
<button type="submit">Add Database</button>
|
<button type="submit">Add Database</button>
|
||||||
</form>
|
</form>
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
{% for base in bases %}
|
{% for base in bases %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ base_path }}/d/{{ base.id.simple() }}/config">
|
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/config">
|
||||||
{{ base.name }}
|
{{ base.name }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
{% for rel in rels %}
|
{% for rel in rels %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ base_path }}/d/{{ base.id.simple() }}/r/{{ rel.oid.0 }}">
|
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ rel.oid.0 }}">
|
||||||
{{ rel.relname }}
|
{{ rel.relname }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="robots" content="noindex,nofollow">
|
<meta name="robots" content="noindex,nofollow">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="icon" href="{{ base_path }}/favicon.ico">
|
<link rel="icon" href="{{ settings.root_path }}/favicon.ico">
|
||||||
7
interim-server/templates/nav.html
Normal file
7
interim-server/templates/nav.html
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="{{ settings.root_path }}/auth/login">Login</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
23
interim-server/templates/rbac.html
Normal file
23
interim-server/templates/rbac.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>User ID</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.user.email }}</td>
|
||||||
|
<td>{{ role_prefix }}{{ user.user.id.simple() }}</td>
|
||||||
|
<td></td>
|
||||||
|
<td>...</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<h2>Invitations</h2>
|
<h2>Invitations</h2>
|
||||||
<a href="{{ base_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
|
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
|
||||||
Invite Collaborators
|
Invite Collaborators
|
||||||
</a>
|
</a>
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
:root {
|
html {
|
||||||
--bs-font-sans-serif: Geist, "Noto Sans", Roboto, "Segoe UI", system-ui, -apple-system, "Helvetica Neue", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji";
|
font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
button, input[type="submit"] {
|
||||||
--bs-body-bg: rgb(27, 28, 30);
|
font-family: inherit;
|
||||||
--bs-tertiary-bg-rgb: 36, 38, 40;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Geist;
|
font-family: "Averia Serif Libre";
|
||||||
src: url("./geist/geist_variable.ttf");
|
src: url("./averia_serif_libre/averia_serif_libre_regular.ttf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Funnel Sans";
|
||||||
|
src: url("./funnel_sans/funnel_sans_variable.ttf");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
static/modern-normalize.min.css
vendored
Normal file
9
static/modern-normalize.min.css
vendored
Normal file
|
|
@ -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 */
|
||||||
36
static/viewer.css
Normal file
36
static/viewer.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en" data-bs-theme="dark">
|
|
||||||
<head>
|
|
||||||
<title>{% block title %}Interim{% endblock %}</title>
|
|
||||||
{% include "meta_tags.html" %}
|
|
||||||
<link rel="stylesheet" href="{{ base_path }}/main.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% block main %}{% endblock main %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="{{ base_path }}/auth/login">Login</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
Loading…
Add table
Reference in a new issue