selection creation

This commit is contained in:
Brent Schroeter 2025-07-08 14:37:03 -07:00
parent 10959e6c2b
commit e8d017314b
89 changed files with 2238 additions and 337 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ target
node_modules
js_dist
pgdata
.vite

123
Cargo.lock generated
View file

@ -215,7 +215,7 @@ dependencies = [
"chrono",
"hmac 0.11.0",
"log",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2 0.9.9",
@ -261,6 +261,7 @@ checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
dependencies = [
"axum-core",
"axum-macros",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@ -280,8 +281,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper 1.0.2",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@ -776,6 +779,12 @@ dependencies = [
"syn",
]
[[package]]
name = "data-encoding"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
[[package]]
name = "der"
version = "0.7.10"
@ -1624,7 +1633,30 @@ dependencies = [
]
[[package]]
name = "interim"
name = "interim-models"
version = "0.0.1"
dependencies = [
"derive_builder",
"interim-pgtypes",
"regex",
"serde",
"sqlx",
"uuid",
]
[[package]]
name = "interim-pgtypes"
version = "0.0.1"
dependencies = [
"derive_builder",
"regex",
"serde",
"sqlx",
"uuid",
]
[[package]]
name = "interim-server"
version = "0.0.1"
dependencies = [
"anyhow",
@ -1638,10 +1670,13 @@ dependencies = [
"derive_builder",
"dotenvy",
"futures",
"interim-models",
"interim-pgtypes",
"nom 8.0.0",
"oauth2",
"percent-encoding",
"rand",
"rand 0.8.5",
"regex",
"reqwest 0.12.15",
"serde",
"serde_json",
@ -1885,7 +1920,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@ -1936,7 +1971,7 @@ dependencies = [
"chrono",
"getrandom 0.2.16",
"http 0.2.12",
"rand",
"rand 0.8.5",
"reqwest 0.11.27",
"serde",
"serde_json",
@ -2238,8 +2273,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
@ -2249,7 +2294,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]]
@ -2261,6 +2316,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "redox_syscall"
version = "0.5.12"
@ -2438,7 +2502,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@ -2781,7 +2845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -2950,7 +3014,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@ -2990,7 +3054,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2 0.10.9",
@ -3335,6 +3399,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.15"
@ -3513,6 +3589,23 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http 1.3.1",
"httparse",
"log",
"rand 0.9.1",
"sha1",
"thiserror 2.0.12",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.18.0"
@ -3582,6 +3675,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View file

@ -1,45 +1,27 @@
[package]
name = "interim"
version = "0.0.1"
edition = "2021"
[workspace]
resolver = "2"
resolver = "3"
members = ["interim-*"]
[workspace.package]
version = "0.0.1"
edition = "2024"
[workspace.dependencies]
anyhow = { version = "1.0.91", features = ["backtrace"] }
chrono = { version = "0.4.41", features = ["serde"] }
derive_builder = "0.20.2"
futures = "0.3.31"
interim-models = { path = "./interim-models" }
interim-pgtypes = { path = "./interim-pgtypes" }
interim-server = { path = "./interim-server" }
rand = "0.8.5"
regex = "1.11.1"
reqwest = { version = "0.12.8", features = ["json"] }
serde = { version = "1.0.213", features = ["derive"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] }
uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] }
tokio = { version = "1.42.0", features = ["full"] }
[dependencies]
anyhow = { workspace = true }
askama = { version = "0.14.0", features = ["urlencode"] }
async-session = "3.0.0"
axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] }
chrono = { workspace = true }
clap = { version = "4.5.31", features = ["derive"] }
config = "0.14.1"
derive_builder = "0.20.2"
dotenvy = "0.15.7"
futures = "0.3.31"
nom = "8.0.0"
oauth2 = "4.4.2"
percent-encoding = "2.3.1"
rand = "0.8.5"
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = "1.0.132"
sqlx = { workspace = true }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] }
thiserror = "2.0.12"
tokio = { workspace = true }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] }
tokio = { version = "1.42.0", features = ["full"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
uuid = { workspace = true }
uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] }
validator = { version = "0.20.0", features = ["derive"] }

View file

@ -1,89 +1,24 @@
# This is a configuration file for the bacon tool
#
# Complete help on configuration: https://dystroy.org/bacon/config/
#
# You may check the current default at
# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml
default_job = "check"
env.CARGO_TERM_COLOR = "always"
[jobs.clippy]
command = ["cargo", "clippy"]
need_stdout = false
watch = ["interim-*"]
[jobs.check]
command = ["cargo", "check"]
need_stdout = false
[jobs.check-all]
command = ["cargo", "check", "--all-targets"]
need_stdout = false
# Run clippy on the default target
[jobs.clippy]
command = ["cargo", "clippy"]
need_stdout = false
# Run clippy on all targets
# To disable some lints, you may change the job this way:
# [jobs.clippy-all]
# command = [
# "cargo", "clippy",
# "--all-targets",
# "--",
# "-A", "clippy::bool_to_int_with_if",
# "-A", "clippy::collapsible_if",
# "-A", "clippy::derive_partial_eq_without_eq",
# ]
# need_stdout = false
[jobs.clippy-all]
command = ["cargo", "clippy", "--all-targets"]
need_stdout = false
# This job lets you run
# - all tests: bacon test
# - a specific test: bacon test -- config::test_default_files
# - the tests of a package: bacon test -- -- -p config
[jobs.test]
command = ["cargo", "test"]
need_stdout = true
[jobs.nextest]
command = [
"cargo", "nextest", "run",
"--hide-progress-bar", "--failure-output", "final"
]
need_stdout = true
analyzer = "nextest"
[jobs.doc]
command = ["cargo", "doc", "--no-deps"]
need_stdout = false
# If the doc compiles, then it opens in your browser and bacon switches
# to the previous job
[jobs.doc-open]
command = ["cargo", "doc", "--no-deps", "--open"]
need_stdout = false
on_success = "back" # so that we don't open the browser at each change
# You can run your application and have the result displayed in bacon,
# if it makes sense for this crate.
[jobs.run-worker]
command = [
"cargo", "run", "worker",
# put launch parameters for your program behind a `--` separator
]
need_stdout = true
allow_warnings = true
background = true
default_watch = false
# Run your long-running application (eg server) and have the result displayed in bacon.
# For programs that never stop (eg a server), `background` is set to false
# to have the cargo run output immediately displayed instead of waiting for
# program's end.
# 'on_change_strategy' is set to `kill_then_restart` to have your program restart
# on every change (an alternative would be to use the 'F5' key manually in bacon).
# If you often use this job, it makes sense to override the 'r' key by adding
# a binding `r = job:run-long` at the end of this file .
[jobs.run-server]
command = ["cargo", "run", "serve"]
need_stdout = true
@ -91,21 +26,22 @@ allow_warnings = true
background = false
on_change_strategy = "kill_then_restart"
kill = ["kill", "-s", "INT"]
watch = ["src", "templates"]
watch = ["interim-*", "components/src"]
# This parameterized job runs the example of your choice, as soon
# as the code compiles.
# Call it as
# bacon ex -- my-example
[jobs.ex]
command = ["cargo", "run", "--example"]
[jobs.watch-components]
command = ["deno", "task", "--cwd=components", "build"]
default_watch = false
watch = ["components/src"]
need_stdout = true
allow_warnings = true
background = false
# You may define here keybindings that would be specific to
# a project, for example a shortcut to launch a specific job.
# Shortcuts to internal functions (scrolling, toggling, etc.)
# should go in your personal global prefs.toml file instead.
[keybindings]
b = "job:watch-components"
c = "job:clippy"
r = "job:run-server"
w = "job:run-worker"

6
components/deno.lock generated
View file

@ -1,11 +1,17 @@
{
"version": "5",
"specifiers": {
"jsr:@std/path@*": "1.0.8",
"npm:@lit/context@^1.1.2": "1.1.2",
"npm:lit@^3.2.0": "3.2.1",
"npm:vite@*": "5.4.5",
"npm:vite@^5.2.10": "5.4.5"
},
"jsr": {
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
}
},
"npm": {
"@esbuild/aix-ppc64@0.21.5": {
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",

View 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>
`;
}
}

View 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>
`;
}
}

View file

@ -1,2 +0,0 @@
export { CellText } from "./cell-text.ts";
export { CellUuid } from "./cell-uuid.ts";

View file

@ -0,0 +1,2 @@
export { CellText } from "../cell-text.ts";
export { CellUuid } from "../cell-uuid.ts";

View 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}">
`;
}
}

View 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>
`;
}
}

View file

@ -0,0 +1 @@
export { LensControls } from "../lens-controls.ts";

View file

@ -0,0 +1 @@
export { AddSelectionButton } from "../add-selection-button.tsx";

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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>
`;
}
}

View 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;
};
};

View file

@ -1,12 +1,18 @@
import { defineConfig } from "vite";
import * as path from "jsr:@std/path";
import "lit";
const entrypointsDir = path.join(import.meta.dirname, "src/entrypoints");
const entry = [...Deno.readDirSync(entrypointsDir)].map(({ name }) =>
path.join(entrypointsDir, name)
);
// https://vitejs.dev/config/
export default defineConfig({
build: {
lib: {
entry: ["src/cells.ts"],
entry,
formats: ["es"],
},
outDir: "../js_dist",

12
interim-models/Cargo.toml Normal file
View 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 }

View file

@ -0,0 +1,3 @@
drop table if exists lens_selections;
drop table if exists lenses;
drop type if exists lens_display_type;

View 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
View 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
}
}

View file

@ -0,0 +1,4 @@
pub mod lens;
pub mod selection;
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();

View 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
}
}

View 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 }

View file

@ -0,0 +1 @@
pub mod pg_attribute;

View file

@ -1,5 +1,7 @@
use sqlx::{postgres::types::Oid, query_as, PgExecutor};
use serde::Serialize;
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
#[derive(Clone, Serialize)]
pub struct PgAttribute {
/// The table this column belongs to
pub attrelid: Oid,

36
interim-server/Cargo.toml Normal file
View 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 }

View file

@ -40,7 +40,7 @@ pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
.set_redirect_uri(
RedirectUrl::new(format!(
"{}{}/auth/callback",
settings.frontend_host, settings.base_path
settings.frontend_host, settings.root_path
))
.context("failed to create new redirection URL")?,
))
@ -59,7 +59,7 @@ async fn start_login(
State(state): State<AppState>,
State(Settings {
auth: auth_settings,
base_path,
root_path,
..
}): State<Settings>,
State(session_store): State<PgStore>,
@ -74,7 +74,7 @@ async fn start_login(
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
tracing::debug!("already logged in, redirecting...");
return Ok(Redirect::to(&format!("{}/", base_path)).into_response());
return Ok(Redirect::to(&format!("{}/", root_path)).into_response());
}
assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none());
@ -99,7 +99,7 @@ async fn start_login(
/// HTTP get handler for /logout
async fn logout(
State(Settings {
base_path,
root_path,
auth: auth_settings,
..
}): State<Settings>,
@ -134,7 +134,7 @@ async fn logout(
}
let jar = jar.remove(Cookie::from(auth_settings.cookie_name));
tracing::debug!("Removed session cookie from jar.");
Ok((jar, Redirect::to(&format!("{}/", base_path))))
Ok((jar, Redirect::to(&format!("{}/", root_path))))
}
#[derive(Debug, Deserialize)]
@ -150,7 +150,7 @@ async fn callback(
State(state): State<AppState>,
State(Settings {
auth: auth_settings,
base_path,
root_path,
..
}): State<Settings>,
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
@ -205,7 +205,7 @@ async fn callback(
}
tracing::debug!("successfully authenticated");
Ok(Redirect::to(
&redirect_target.unwrap_or(format!("{}/", base_path)),
&redirect_target.unwrap_or(format!("{}/", root_path)),
))
}

View file

@ -1,3 +1,5 @@
use std::net::SocketAddr;
use anyhow::Result;
use axum::{
extract::Request,
@ -10,8 +12,7 @@ use clap::{Parser, Subcommand};
use tokio::time::sleep;
use tower::ServiceBuilder;
use tower_http::{
compression::CompressionLayer, normalize_path::NormalizePathLayer,
set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
compression::CompressionLayer, set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
};
use crate::{
@ -51,7 +52,6 @@ pub async fn serve_command(state: AppState) -> Result<()> {
CONTENT_SECURITY_POLICY,
HeaderValue::from_static("frame-ancestors 'none'"),
))
.layer(NormalizePathLayer::trim_trailing_slash())
.service(new_router(state.clone()));
let listener =
@ -62,12 +62,15 @@ pub async fn serve_command(state: AppState) -> Result<()> {
"App running at http://{}:{}{}",
state.settings.host,
state.settings.port,
state.settings.base_path
state.settings.root_path
);
axum::serve(listener, ServiceExt::<Request>::into_make_service(router))
.await
.map_err(Into::into)
axum::serve(
listener,
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<()> {

View file

@ -2,14 +2,16 @@ use std::fmt::Display;
use anyhow::Result;
use chrono::{DateTime, Utc};
use derive_builder::Builder;
use interim_models::selection::SelectionDisplayType;
use sqlx::{
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
error::BoxDynError,
postgres::{PgRow, PgTypeInfo, PgValueRef},
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
};
use uuid::Uuid;
const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z";
pub enum Value {
Text(Option<String>),
Integer(Option<i32>),
@ -17,29 +19,11 @@ pub enum Value {
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 {
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)]
pub struct FromSqlError {
message: String,
@ -72,7 +56,7 @@ impl Value {
}
impl ToHtmlString for Value {
fn to_html_string(&self, options: &FieldOptions) -> String {
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String {
macro_rules! cell_html {
($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => {
{
@ -97,7 +81,7 @@ impl ToHtmlString for Value {
Self::Timestamptz(value) => cell_html!(
"cell-timestamptz",
value,
"format" => options.date_format
"format" => DEFAULT_TIMESTAMP_FORMAT
),
Self::Uuid(value) => cell_html!("cell-uuid", value),
}
@ -142,12 +126,3 @@ impl<'a> Decode<'a, Postgres> for Value {
}
}
}
pub struct Lens {
pub fields: Vec<Field>,
}
pub struct Field {
pub options: FieldOptions,
pub name: String,
}

View 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>,
}

View file

@ -1,10 +1,11 @@
use clap::Parser as _;
use dotenvy::dotenv;
use interim_models::MIGRATOR;
use tracing_subscriber::EnvFilter;
use crate::{
app_state::{App, AppState},
cli::{serve_command, worker_command, Cli, Commands},
cli::{Cli, Commands, serve_command, worker_command},
settings::Settings,
};
@ -17,9 +18,9 @@ mod bases;
mod cli;
mod data_layer;
mod db_conns;
mod lenses;
mod middleware;
mod pg_acls;
mod pg_attributes;
mod pg_classes;
mod pg_databases;
mod pg_roles;
@ -44,8 +45,8 @@ async fn main() {
let state: AppState = App::from_settings(settings.clone()).await.unwrap().into();
if settings.run_database_migrations == Some(1) {
sqlx::migrate!().run(&state.app_db).await.unwrap();
if settings.run_database_migrations != 0 {
MIGRATOR.run(&state.app_db).await.unwrap();
}
let cli = Cli::parse();

View file

@ -1,8 +1,13 @@
use std::net::SocketAddr;
use axum::{
extract::{ws::WebSocket, ConnectInfo, WebSocketUpgrade},
http::{header::CACHE_CONTROL, HeaderValue},
routing::{get, post},
response::Response,
routing::{any, get, post},
Router,
};
use axum_extra::routing::RouterExt as _;
use tower::ServiceBuilder;
use tower_http::{
services::{ServeDir, ServeFile},
@ -12,38 +17,67 @@ use tower_http::{
use crate::{app_state::AppState, auth, routes};
pub fn new_router(state: AppState) -> Router<()> {
let base_path = state.settings.base_path.clone();
let base_path = state.settings.root_path.clone();
let app = Router::new()
.route("/databases", get(routes::bases::list_bases_page))
.route("/databases/add", post(routes::bases::add_base_page))
.route(
"/d/{base_id}/config",
.route_with_tsr("/databases/", get(routes::bases::list_bases_page))
.route_with_tsr("/databases/add/", post(routes::bases::add_base_page))
.route_with_tsr(
"/d/{base_id}/config/",
get(routes::bases::base_config_page_get),
)
.route(
"/d/{base_id}/config",
"/d/{base_id}/config/",
post(routes::bases::base_config_page_post),
)
.route(
"/d/{base_id}/relations",
.route_with_tsr(
"/d/{base_id}/relations/",
get(routes::relations::list_relations_page),
)
.route(
"/d/{base_id}/r/{class_oid}/rbac",
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/",
get(routes::relations::rel_index_page),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/rbac/",
get(routes::relations::rel_rbac_page),
)
.route(
"/d/{base_id}/r/{class_oid}/rbac/invite",
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/rbac/invite/",
get(routes::relations::rel_rbac_invite_page_get),
)
.route(
"/d/{base_id}/r/{class_oid}/rbac/invite",
post(routes::relations::rel_rbac_invite_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/viewer",
get(routes::relations::viewer_page),
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/lenses/",
get(routes::lenses::lenses_page),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/lenses/add/",
get(routes::lenses::add_lens_page_get),
)
.route(
"/d/{base_id}/r/{class_oid}/lenses/add",
post(routes::lenses::add_lens_page_post),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
get(routes::lenses::lens_page),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-lens",
post(routes::lenses::update_lens_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
post(routes::lenses::add_selection_page_post),
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/",
get(routes::lenses::viewer_page),
)
.route("/__dev-healthz", any(dev_healthz_handler))
.nest("/auth", auth::new_router())
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
@ -54,7 +88,9 @@ pub fn new_router(state: AppState) -> Router<()> {
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
// FIXME: restore production value
// HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"),
HeaderValue::from_static("no-cache"),
))
.service(
ServeDir::new("js_dist").not_found_service(
@ -94,6 +130,19 @@ pub fn new_router(state: AppState) -> Router<()> {
}
}
async fn dev_healthz_handler(
ws: WebSocketUpgrade,
ConnectInfo(addr): ConnectInfo<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)]
// struct RbacIndexPath {
// oid: u32,

View file

@ -16,14 +16,12 @@ use crate::{
base_user_perms::sync_perms_for_base,
bases::Base,
db_conns::{escape_identifier, init_role},
pg_databases::PgDatabase,
pg_roles::PgRole,
settings::Settings,
users::CurrentUser,
};
pub async fn list_bases_page(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<Response, AppError> {
@ -33,14 +31,14 @@ pub async fn list_bases_page(
#[derive(Template)]
#[template(path = "list_bases.html")]
struct ResponseTemplate {
base_path: String,
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(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<Response, AppError> {
@ -62,7 +60,7 @@ values ($1, $2, $3, 'configure')",
)
.execute(&mut *app_db)
.await?;
Ok(Redirect::to(&format!("{}/d/{}/config", base_path, base.id)).into_response())
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base.id)).into_response())
}
#[derive(Deserialize)]
@ -71,7 +69,7 @@ pub struct BaseConfigPagePath {
}
pub async fn base_config_page_get(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(params): Path<BaseConfigPagePath>,
@ -84,9 +82,9 @@ pub async fn base_config_page_get(
#[template(path = "base_config.html")]
struct ResponseTemplate {
base: Base,
base_path: String,
settings: Settings,
}
Ok(Html(ResponseTemplate { base, base_path }.render()?).into_response())
Ok(Html(ResponseTemplate { base, settings }.render()?).into_response())
}
#[derive(Deserialize)]
@ -96,7 +94,7 @@ pub struct BaseConfigPageForm {
}
pub async fn base_config_page_post(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
@ -139,5 +137,5 @@ pub async fn base_config_page_post(
.await?;
sync_perms_for_base(base.id, &mut app_db, &mut client).await?;
}
Ok(Redirect::to(&format!("{}/d/{}/config", base_path, base_id)).into_response())
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base_id)).into_response())
}

View 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, &current_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");
}

View file

@ -1,2 +1,3 @@
pub mod bases;
pub mod lenses;
pub mod relations;

View file

@ -8,10 +8,7 @@ use axum::{
};
use axum_extra::extract::Form;
use serde::Deserialize;
use sqlx::{
postgres::{types::Oid, PgRow},
query,
};
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{
@ -19,10 +16,8 @@ use crate::{
app_state::AppDbConn,
base_pooler::BasePooler,
bases::Base,
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
db_conns::{escape_identifier, init_role},
db_conns::init_role,
pg_acls::PgPrivilegeType,
pg_attributes::fetch_attributes_for_rel,
pg_classes::{PgClass, PgRelKind},
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
rel_invitations::RelInvitation,
@ -36,7 +31,7 @@ pub struct ListRelationsPagePath {
}
pub async fn list_relations_page(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
@ -81,16 +76,16 @@ pub async fn list_relations_page(
#[derive(Template)]
#[template(path = "list_rels.html")]
struct ResponseTemplate {
base_path: String,
base: Base,
rels: Vec<PgClass>,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
base,
base_path,
rels: accessible_rels,
settings,
}
.render()?,
)
@ -98,91 +93,26 @@ pub async fn list_relations_page(
}
#[derive(Deserialize)]
pub struct ViewerPagePath {
pub struct RelPagePath {
base_id: Uuid,
class_oid: u32,
}
pub async fn viewer_page(
State(Settings { base_path, .. }): State<Settings>,
State(mut base_pooler): State<BasePooler>,
pub async fn rel_index_page(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(params): Path<ViewerPagePath>,
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
) -> Result<Response, AppError> {
let base = Base::fetch_by_id(params.base_id, &mut *app_db)
.await?
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
let mut client = base_pooler.acquire_for(params.base_id).await?;
init_role(
&format!("{}{}", &base.user_role_prefix, &current_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,
todo!();
}
pub async fn rel_rbac_page(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
) -> Result<Response, AppError> {
// FIXME: auth
let base = Base::fetch_by_id(base_id, &mut *app_db)
@ -215,29 +145,27 @@ pub async fn rel_rbac_page(
let all_invites = RelInvitation::fetch_by_class_oid(Oid(class_oid), &mut *app_db).await?;
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
for invite in all_invites {
let entry = invites_by_email
.entry(invite.email.clone())
.or_insert(vec![]);
let entry = invites_by_email.entry(invite.email.clone()).or_default();
entry.push(invite);
}
#[derive(Template)]
#[template(path = "rel_rbac.html")]
struct ResponseTemplate {
base_path: String,
base: Base,
interim_users: HashMap<String, User>,
invites_by_email: HashMap<String, Vec<RelInvitation>>,
pg_class: PgClass,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
base,
base_path,
interim_users,
invites_by_email,
pg_class: class,
settings,
}
.render()?,
)
@ -245,14 +173,14 @@ pub async fn rel_rbac_page(
}
pub async fn rel_rbac_invite_page_get(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
) -> Result<Response, AppError> {
#[derive(Template)]
#[template(path = "rbac_invite.html")]
struct ResponseTemplate {
base_path: String,
settings: Settings,
}
Ok(Html(ResponseTemplate { base_path }.render()?).into_response())
Ok(Html(ResponseTemplate { settings }.render()?).into_response())
}
#[derive(Deserialize)]
@ -261,10 +189,10 @@ pub struct RbacInvitePagePostForm {
}
pub async fn rel_rbac_invite_page_post(
State(Settings { base_path, .. }): State<Settings>,
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
Form(form): Form<RbacInvitePagePostForm>,
) -> Result<Response, AppError> {
// FIXME auth
@ -288,5 +216,9 @@ pub async fn rel_rbac_invite_page_post(
.upsert(&mut *app_db)
.await?;
}
Ok(Redirect::to(&format!("{base_path}/d/{base_id}/r/{class_oid}/rbac")).into_response())
Ok(Redirect::to(&format!(
"{0}/d/{base_id}/r/{class_oid}/rbac",
settings.root_path
))
.into_response())
}

View file

@ -12,7 +12,12 @@ pub struct Settings {
/// slash but no trailing slash, for example "/app". For default behavior,
/// leave as empty string.
#[serde(default)]
pub base_path: String,
pub root_path: String,
/// When set to 1, dev features such as the frontend reloader will be
/// enabled.
#[serde(default)]
pub dev: u8,
/// postgresql:// URL for Interim's application database.
pub database_url: String,
@ -21,7 +26,8 @@ pub struct Settings {
pub app_db_max_connections: u32,
/// When set to 1, embedded SQLx migrations will be run on startup.
pub run_database_migrations: Option<u8>,
#[serde(default)]
pub run_database_migrations: u8,
/// Address for server to bind to
#[serde(default = "default_host")]

View file

@ -74,7 +74,7 @@ where
SESSION_KEY_AUTH_REDIRECT,
uri.path_and_query()
.map(|value| value.to_string())
.unwrap_or(format!("{}/", app_state.settings.base_path)),
.unwrap_or(format!("{}/", app_state.settings.root_path)),
)?;
if let Some(cookie_value) = app_state.session_store.store_session(session).await? {
tracing::debug!("adding session cookie to jar");
@ -96,7 +96,7 @@ where
};
return Err(Self::Rejection::SetCookiesAndRedirect(
jar,
format!("{}/auth/login", app_state.settings.base_path),
format!("{}/auth/login", app_state.settings.root_path),
));
};
let current_user = if let Some(value) =

View 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 %}

View 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>

View file

@ -1,13 +1,14 @@
{% extends "base.html" %}
{% 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>
<thead>
<tr>
{% for field in fields %}
<th>
<div>{{ field.options.label.clone().unwrap_or(field.name.clone()) }}</div>
<div>{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
</th>
{% endfor %}
</tr>
@ -19,7 +20,7 @@
<td>
{% match Value::get_from_row(row, field.name.as_str()) %}
{% when Ok with (value) %}
{{ value.to_html_string(&field.options) | safe }}
{{ value.to_html_string(&field.display_type) | safe }}
{% when Err with (err) %}
<span class="pg-value-error">{{ err }}</span>
{% endmatch %}
@ -29,4 +30,5 @@
{% endfor %}
</tbody>
</table>
<lens-controls selections="{{ selections_json }}"></lens-controls>
{% endblock %}

View 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 %}

View 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 %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% 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>
</form>
<table>
@ -9,7 +9,7 @@
{% for base in bases %}
<tr>
<td>
<a href="{{ base_path }}/d/{{ base.id.simple() }}/config">
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/config">
{{ base.name }}
</a>
</td>

View file

@ -6,7 +6,7 @@
{% for rel in rels %}
<tr>
<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 }}
</a>
</td>

View file

@ -1,4 +1,4 @@
<meta charset="UTF-8">
<meta name="robots" content="noindex,nofollow">
<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">

View file

@ -0,0 +1,7 @@
<nav>
<ul>
<li>
<a href="{{ settings.root_path }}/auth/login">Login</a>
</li>
</ul>
</nav>

View 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 %}

View file

@ -2,7 +2,7 @@
{% block main %}
<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
</a>
<table>

View file

@ -1,13 +1,17 @@
:root {
--bs-font-sans-serif: Geist, "Noto Sans", Roboto, "Segoe UI", system-ui, -apple-system, "Helvetica Neue", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji";
html {
font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="dark"] {
--bs-body-bg: rgb(27, 28, 30);
--bs-tertiary-bg-rgb: 36, 38, 40;
button, input[type="submit"] {
font-family: inherit;
}
@font-face {
font-family: Geist;
src: url("./geist/geist_variable.ttf");
font-family: "Averia Serif Libre";
src: url("./averia_serif_libre/averia_serif_libre_regular.ttf");
}
@font-face {
font-family: "Funnel Sans";
src: url("./funnel_sans/funnel_sans_variable.ttf");
}

9
static/modern-normalize.min.css vendored Normal file
View 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
View 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;
}
}

View file

@ -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>

View file

@ -1,7 +0,0 @@
<nav>
<ul>
<li>
<a href="{{ base_path }}/auth/login">Login</a>
</li>
</ul>
</nav>