Compare commits

81 Commits

Author SHA1 Message Date
augustin_s 56d9653653 use http.HTTPStatus for readability 2026-05-29 21:48:19 +02:00
augustin_s 40e933de23 re-ordered 2026-05-29 18:10:36 +02:00
augustin_s 9a9360cbdb a bit simpler 2026-05-29 18:08:34 +02:00
augustin_s e3c53087a8 raise if to-be-appended run exists 2026-05-29 17:59:47 +02:00
augustin_s 699940648d added actual timestamp as second column 2026-05-29 11:49:00 +02:00
augustin_s d191b2b8f6 wrap run number into timestamp to be able to use adb update 2026-05-29 11:00:50 +02:00
augustin_s 3cb57110a0 update how to start 2026-05-27 13:53:38 +02:00
augustin_s abd072c7bf print all libs 2026-05-27 13:50:53 +02:00
augustin_s 7488436cc1 ordered arguments 2026-05-24 14:43:58 +02:00
augustin_s 19e4774574 added Beamline annotation so fastapi can insert it automatically 2026-05-24 14:35:54 +02:00
augustin_s c4f5bb50da make one fake pgroup match the localhost beamline 2026-05-24 12:10:28 +02:00
augustin_s ddb916fb26 use starlette.Request to get client IP; map IP to beamline and use respective adb library; store beamline in aggrid context 2026-05-24 12:09:37 +02:00
augustin_s c3eabf30eb added mapping of IP to beamline 2026-05-24 12:05:28 +02:00
augustin_s 6045f19736 moved api into /api/ prefix; allowed /api/ usage without auth 2026-05-24 12:02:22 +02:00
augustin_s 0f0a261516 attach arcticdb.exceptions to ArcticDB 2026-05-23 17:24:24 +02:00
augustin_s 87cdb5962d lib is not part of state 2026-05-23 17:22:36 +02:00
augustin_s d3e199fc86 added small wrapper around arcticdb 2026-05-23 17:10:38 +02:00
augustin_s 17d83c6acc return dict instead of list 2026-05-22 19:32:10 +02:00
augustin_s f7b2fde330 return dict instead of list 2026-05-22 19:29:36 +02:00
augustin_s 6603cf3010 format 2026-05-22 19:27:44 +02:00
augustin_s fb3d406973 better name and message 2026-05-22 19:25:58 +02:00
augustin_s e231621749 added mapping pgroup to beamline 2026-05-22 12:57:38 +02:00
augustin_s 6327099f8d sort imports 2026-05-20 23:55:12 +02:00
augustin_s 24a25d32a8 added __init__.py to utils 2026-05-20 23:50:50 +02:00
augustin_s cf5c4a4a18 moved small utilities into utils sub-folder 2026-05-20 23:47:29 +02:00
augustin_s 40e2d1e665 adjust to new path 2026-05-20 18:42:00 +02:00
augustin_s 0d39e2ccdf added top-level folder 2026-05-20 13:36:05 +02:00
augustin_s dac1a26aef added a run stand task 2026-05-20 00:09:27 +02:00
augustin_s 5fc9ccb46e added some timing 2026-05-19 22:57:12 +02:00
augustin_s ecae51b307 added check ldap tool 2026-05-19 22:38:48 +02:00
augustin_s 5c6c53cac2 relative imports should be relative 2026-05-19 22:37:02 +02:00
augustin_s 19ea53841e added a command line switch for which ldap to use 2026-05-19 22:35:56 +02:00
augustin_s 41e4977032 renamed: singletons.py -> state.py 2026-05-18 11:28:05 +02:00
augustin_s 0999144689 moved PGroup into annotations.py 2026-05-18 11:26:09 +02:00
augustin_s e9b852662f let the LDAP server pool time out 2026-05-17 21:06:47 +02:00
augustin_s e1f24d0043 relative imports should be relative 2026-05-17 17:55:03 +02:00
augustin_s 785d7294df use uvicorn.logging.ColourizedFormatter; added timestamps to uvicorn logs 2026-05-16 13:38:47 +02:00
augustin_s e034203983 log pgroup access denied 2026-05-15 20:32:45 +02:00
augustin_s 0e93435f0f show latest pgroup first; pgroups are lists not sets 2026-05-15 16:11:29 +02:00
augustin_s dcc0f76951 replaced print with logging 2026-05-15 15:02:11 +02:00
augustin_s cf39ef9f4e use fakeldap 2026-05-14 19:09:41 +02:00
augustin_s d78b096ffb use pgroup list instead of set; added username to error message 2026-05-14 19:08:39 +02:00
augustin_s 215d3ad4f7 added ldap3 and gssapi 2026-05-14 18:51:27 +02:00
augustin_s 8006127107 weird space 2026-05-14 18:45:12 +02:00
augustin_s 8bd37ec0e3 first try on GSSAPI-based LDAP access via Kerberos, PSI LDAP specifics and a fake alternative for testing 2026-05-14 18:41:18 +02:00
augustin_s f030247b61 cleanup 2026-05-12 12:08:23 +02:00
augustin_s f79e050928 isort 2026-05-12 12:04:15 +02:00
augustin_s d85191fd56 renamed main -> home 2026-05-12 11:38:19 +02:00
augustin_s bed7153675 further disentangle 2 2026-05-12 11:36:40 +02:00
augustin_s cc8b436db4 simpler focus; the default None is falsey anyway 2026-05-12 11:30:58 +02:00
augustin_s 0ce457249b further disentangle 2026-05-11 12:58:15 +02:00
augustin_s 19c811fdef started to disentangle 2026-05-11 12:35:36 +02:00
augustin_s 00b5d9d096 moved favicon.png and icon.png into assets/ 2026-05-10 14:02:31 +02:00
augustin_s 9280aefc97 formatting 2026-05-10 13:58:31 +02:00
augustin_s ccea09bf09 moved table endpoints into /tables/ 2026-05-10 13:43:02 +02:00
augustin_s e7de8b0ad6 favicon should not be behind authentication 2026-05-09 23:42:14 +02:00
augustin_s 3bea76cf4c renamed main_page -> main; error message wording 2026-05-09 17:22:31 +02:00
augustin_s d9679ed6ff merged authenticated.py into stand.py 2026-05-09 17:14:45 +02:00
augustin_s 6109a76081 ignore .secret file 2026-05-09 14:54:23 +02:00
augustin_s 23afbea6c8 renamed page -> table 2026-05-09 14:53:56 +02:00
augustin_s 8db9585137 store storage secret in file 2026-05-09 14:53:17 +02:00
augustin_s 7825638688 print adb tool 2026-05-08 16:25:16 +02:00
augustin_s 0f019ac4c1 side bar 2026-05-08 16:23:55 +02:00
augustin_s 94e212d66f set row ID to index column, and use it 2026-05-08 15:59:11 +02:00
augustin_s 1634994044 prototype for authentication 2026-05-08 12:16:41 +02:00
augustin_s 01ac806b62 ignore adb as folder; ignore .nicegui folder 2026-05-07 15:49:32 +02:00
augustin_s 4ec9a936e1 added larger icon 2026-05-06 17:35:56 +02:00
augustin_s ed9abec3c4 renamed icon.png -> favicon.png 2026-05-06 17:35:26 +02:00
augustin_s 538e576be5 moved event unpacking out of aggridx 2026-05-04 22:56:52 +02:00
augustin_s d627cfd045 actually more logical grouping 2026-05-04 21:56:38 +02:00
augustin_s 779098510c clean up 2026-05-03 00:36:37 +02:00
augustin_s d5fa8df328 more logical grouping 2026-05-03 00:34:42 +02:00
augustin_s c72d78faf1 one registry per pgroup 2026-05-03 00:19:45 +02:00
augustin_s a8c6adc28c skip over wrong pgroups 2026-05-03 00:04:19 +02:00
augustin_s 5a0774c4e4 added pgroups as table identifier 2026-05-02 23:13:15 +02:00
augustin_s d6ffba8aac made main script exectuable 2026-05-02 18:52:09 +02:00
augustin_s c777de9074 added arcticdb lmdb folder to ignore list 2026-05-02 13:47:19 +02:00
augustin_s 4c4aaa4243 first prototype 2026-05-02 13:46:36 +02:00
augustin_s 0864326f5d added pixi files 2026-05-02 13:46:16 +02:00
augustin_s e26f6eb330 added readme 2026-05-02 11:14:56 +02:00
augustin_s 9780495260 initial commit v2 2026-05-02 10:30:22 +02:00
44 changed files with 3343 additions and 658 deletions
+3
View File
@@ -138,6 +138,9 @@ dmypy.json
cython_debug/
# stand
adb/
.nicegui/
.secret
*.h5
*.xls?
-5
View File
@@ -1,5 +0,0 @@
[browser]
gatherUsageStats = false
+8 -32
View File
@@ -1,54 +1,30 @@
# stand
An editable, filterable and sortable spreadsheet (via [Ag Grid](https://github.com/PablocFonseca/streamlit-aggrid/)) webapp (via [Streamlit](https://streamlit.io/)) with REST API (via [CherryPy](https://cherrypy.dev/)) using [Pandas](https://pandas.pydata.org/) [DataFrames](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html).
<img src="https://gitea.psi.ch/SwissFEL/stand/wiki/raw/uploads%2Fea8ad99871c9f2fd8fcdff38da028211%2Fscreenshot.png" width="50%" />
An editable, filterable and sortable spreadsheet (via [AG Grid](https://www.ag-grid.com/)) webapp (via [NiceGUI](https://nicegui.io/)) with REST API (via [FastAPI](https://fastapi.tiangolo.com/)) using [Pandas](https://pandas.pydata.org/) [DataFrames](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html) backed by [ArcticDB](https://arcticdb.io/).
## Setup the environment
```bash
conda create -n stand
conda activate stand
conda install cherrypy
conda install streamlit=1.9.2 altair=4
conda install streamlit-aggrid=0.3.4.post3
conda install openpyxl # excel writing
conda install pytables # hdf5 writing
pixi install
```
## Start the webapp
```bash
$ ./stand.py
pixi run stand
```
Check the parameters with
or
```bash
$ ./stand.py -h
pixi shell
./stand/main.py
```
## Interact in the browser
- **edit**: double click a cell
- **filter**: hover a column head and click the appearing menu icon
- **filter**: click the filter icon on a column head
- **sort**: click a column head (cycles through ascending, descending, chronological)
- **download**: click the *Downloads* button and choose from the given options
## Use the python client
```python
from client import Client
c = Client()
c.add_row(a=1, b=2.3, c="three")
c.add_row(a=2, b=3.4, c="four", x=123)
c.clear()
```
View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Executable
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python
import argparse
import timeit
from functools import partial
from getpass import getpass
from stand.auth.psildap import get_data
parser = argparse.ArgumentParser()
parser.add_argument("username")
clargs = parser.parse_args()
username = clargs.username
password = getpass()
data = get_data(username, password)
print(data)
times = timeit.repeat(partial(get_data, username, password), number=1, repeat=5)
for i, t in enumerate(times, 1):
print(f"run {i}: {t:.6f}s")
avg = sum(times) / len(times)
print(f"average: {avg:.6f}s")
-42
View File
@@ -1,42 +0,0 @@
import requests
import json
class Client:
def __init__(self, host="127.0.0.1", port=9090):
self.host = host
self.port = port
self.session = requests.Session()
def add_row(self, **kwargs):
addr = self._make_addr()
resp = self.session.patch(addr, json=kwargs)
return ResponseWrapper(resp)
def get(self):
addr = self._make_addr()
resp = self.session.get(addr)
return ResponseWrapper(resp)
def clear(self):
addr = self._make_addr()
resp = self.session.delete(addr)
return ResponseWrapper(resp)
def _make_addr(self):
return f"http://{self.host}:{self.port}/"
class ResponseWrapper:
def __init__(self, resp):
resp.raise_for_status()
self.resp = resp
def __repr__(self):
return self.resp.text
+2492
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[workspace]
authors = ["Sven Augustin <sven.augustin@psi.ch>"]
channels = ["conda-forge"]
name = "stand"
platforms = ["linux-64"]
version = "0.0.2"
[tasks]
stand = "stand/main.py"
[dependencies]
python = ">=3.13,<3.14"
nicegui = ">=3.11.0,<4"
arcticdb = ">=6.13.0,<7"
python-gssapi = ">=1.11.1,<2"
ldap3 = ">=2.9.1,<3"
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python
import arcticdb as adb
uri = "lmdb://adb"
ac = adb.Arctic(uri)
libs = ac.list_libraries()
for n in sorted(libs):
print(n)
print("=" * len(n))
lib = ac.get_library(n)
symbols = lib.list_symbols()
metas = [lib.read_metadata(i) for i in sorted(symbols)] or ["nothing"]
for i in metas:
print("-", i)
print()
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env python
import argparse
import socket
parser = argparse.ArgumentParser(
description="✋ stand",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
hostname = socket.gethostname()
msg = " of the Streamlit app"
parser.add_argument("--app-host", default=hostname, help="host"+msg)
parser.add_argument("--app-port", default=8080, type=int, help="port"+msg)
msg = " of the CherryPy API"
parser.add_argument("--api-host", default=hostname, help="host"+msg)
parser.add_argument("--api-port", default=9090, type=int, help="port"+msg)
clargs = parser.parse_args()
streamlit_params = f"""
--browser.gatherUsageStats false
--server.headless true
--server.address {clargs.app_host}
--server.port {clargs.app_port}
""".split()
cherrypy_params = f"""
--host {clargs.api_host}
--port {clargs.api_port}
""".split()
import sys
# combine the folder that contains this script
base = sys.path[0] or "."
script = f"{base}/stand/webapp.py"
import subprocess
# arguments before "--" will be parsed by streamlit
# arguments after "--" will be passed into the script
# the inner argparse is in restapi.py
cmd = [
# "echo",
"streamlit", "run", *streamlit_params, script,
"--", *cherrypy_params
]
subprocess.run(cmd)
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
RESET="\033[0m"
RED="\033[0;31m"
echo -e "${RED}please switch to the stand.py launcher${RESET}"
base=$(dirname $0)
params=(
--browser.gatherUsageStats false
--server.headless true
)
streamlit run "${params[@]}" $@ $base/stand/webapp.py
+18
View File
@@ -0,0 +1,18 @@
import arcticdb as adb
class ArcticDB(adb.Arctic):
exceptions = adb.exceptions
def get(self, name):
return self.get_library(
name,
create_if_missing=True,
library_options=adb.LibraryOptions(
dynamic_schema=True
)
)
+135
View File
@@ -0,0 +1,135 @@
from nicegui import ui
class aggridx(ui.aggrid):
# #TODO: this should be done in the constructor via a keyword argument switch
# #TODO: this would need the from_pandas/from_polars classmethods to pass through that switch
# def setup_sync_edits(self):
# """
# automatically sync data to the server after edits
# this operation maintains the current view on the data
# """
# self.on("cellValueChanged", self.set_cell_server)
def set_cell_server(self, row_index, col_id, new_val):
with self.props.suspend_updates():
self.options["rowData"][row_index][col_id] = new_val
def set_cell_client(self, row_id, col_id, new_val):
self.run_row_method(row_id, "setDataValue", col_id, new_val)
# def set_row_server_from_event(self, evt):
# index = evt.args["rowIndex"]
# data = evt.args["data"]
# self.set_row_server(index, data)
# def set_row_server(self, index, data):
# """
# overwrite row at index with data
# this operation maintains the current view on the data
# """
# with self.props.suspend_updates():
# self.options["rowData"][index] = data
def append(self, row):
"""
append row to the bottom of the table and
append any missing columns on the right side of the table
this operation maintains the current view on the data
"""
self.ensure_column_defs(row)
self.add_row_data([row])
# def insert(self, index, row):
# """
# insert row at the provided index
# append any missing columns on the right side of the table
# this operation maintains the current view on the data
# """
# self.ensure_column_defs(row)
# self.add_row_data([row], index=index)
# def extend(self, rows):
# """
# append rows to the bottom of the table and
# append any missing columns on the right side of the table
# this operation maintains the current view on the data
# """
# columns = dict.fromkeys(k for d in rows for k in d) # dict.fromkeys acts as ordered set
# self.ensure_column_defs(columns)
# self.add_row_data(rows)
def ensure_column_defs(self, columns):
"""
append any missing columns on the right side of the table
this operation maintains the current view on the data
"""
current_column_defs = self.options["columnDefs"]
current_fields = [i["field"] for i in current_column_defs]
added_fields = [i for i in columns if i not in current_fields]
if not added_fields:
return
added_column_defs = [{"field": n} for n in added_fields]
new_column_defs = current_column_defs + added_column_defs
# update server without re-draw
with self.props.suspend_updates():
self.options["columnDefs"] = new_column_defs
# update client
self.run_grid_method("setGridOption", "columnDefs", new_column_defs)
def add_row_data(self, rows, index=-1):
"""
insert rows at the provided index,
with the default being appending at the bottom of the table
this operation maintains the current view on the data
"""
# update server without re-draw
with self.props.suspend_updates():
self.options["rowData"].extend(rows)
# update client
self.run_grid_method("applyTransaction", {"add": rows, "addIndex": index})
# def scroll_to_row_index(self, index):
# """
# scroll the view on the data such that the row with the given index is visible
# negative indices count from the back
# """
# nrows = len(self.options["rowData"])
# index %= nrows # move index into [0, nrows)
# self.run_grid_method("ensureIndexVisible", index)
# def scroll_to_row_key(self, key):
# """
# scroll the view on the data such that the row with the given key is visible
# consistent with tables generated from dataframes, the column called index is used to match the key
# if the key is not found, the view is not scrolled
# """
# indices = [i.get("index") for i in self.options["rowData"]]
# try:
# index = indices.index(key)
# except ValueError:
# return
# self.scroll_to_row_index(index)
# def scroll_to_column_index(self, index):
# """
# scroll the view on the data such that the column with the given index is visible
# negative indices count from the back
# """
# key = self.options["columnDefs"][index]["field"]
# self.scroll_to_column_key(key)
# def scroll_to_column_key(self, key):
# """
# scroll the view on the data such that the column with the given key is visible
# """
# self.run_grid_method("ensureColumnVisible", key)
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from http import HTTPStatus
from typing import Any
import pandas as pd
from fastapi import APIRouter, HTTPException
from state import grids, adb
from utils.annotations import Beamline, PGroup
router = APIRouter()
@router.post("/tables/{pgroup}/append")
def append(beamline: Beamline, pgroup: PGroup, run: int, row: dict[str, Any]):
lib = adb.get(beamline)
index = pd.Timestamp(run) # adb supports update only for timeseries indexes
index = pd.Index([index], name="run")
run_exists = not lib.read(pgroup, date_range=index, columns=[]).data.index.empty
if run_exists:
raise HTTPException(HTTPStatus.CONFLICT, f"run {run} exists already in {beamline}/{pgroup}")
timestamp = row.pop("timestamp", None)
timestamp = datetime.fromisoformat(timestamp) if timestamp else datetime.now()
row = {"timestamp": timestamp, **row} # setdefault would not force timestamp to be the first column
df = pd.DataFrame(row, index=index)
lib.append(pgroup, df)
row = {"run": run, **row} # setdefault would not force run to be the first column
res = []
for grid in grids[pgroup]:
grid.append(row)
res.append(grid.options)
return res
+28
View File
@@ -0,0 +1,28 @@
PASSWORDS = {
"a": "a",
"b": "b",
"c": "c"
}
PGROUPS = {
"a": {
"p11111": "stand",
"p22222": "SATESE"
},
"b": {
"p33333": "SARESA"
}
}
def get_data(username, password):
if PASSWORDS.get(username) != password:
raise RuntimeError(f'authentication failed for user "{username}"')
return {
"username": username,
"pgroups": PGROUPS.get(username, {})
}
+26
View File
@@ -0,0 +1,26 @@
import gssapi
from ldap3 import GSSAPI, NONE, ROUND_ROBIN, SASL, Connection, Server, ServerPool
def Konnection(hosts, username, password):
server_pool = make_server_pool(hosts)
creds = make_creds(username, password)
return Connection(
server_pool,
authentication=SASL,
sasl_mechanism=GSSAPI,
sasl_credentials=(None, None, creds)
)
def make_server_pool(hosts):
servers = [Server(host, get_info=NONE, connect_timeout=3) for host in hosts]
return ServerPool(servers, ROUND_ROBIN, active=3, exhaust=True)
def make_creds(username, password):
user = gssapi.Name(base=username, name_type=gssapi.NameType.user)
return gssapi.raw.acquire_cred_with_password(user, password.encode()).creds
+57
View File
@@ -0,0 +1,57 @@
import logging
from fastapi.responses import RedirectResponse
from nicegui import APIRouter, app, ui
from state import config
log = logging.getLogger(__name__)
router = APIRouter()
if config.fake:
log.warning("using fake LDAP")
from .fakeldap import get_data
else:
from .psildap import get_data
@router.page("/login")
def login(redirect_to: str = "/") -> RedirectResponse | None:
if app.storage.user.get("authenticated"):
return RedirectResponse("/")
def try_login(): # local function to avoid passing username and password as arguments
if not username.value:
ui.notify("Missing username", color="negative")
username.run_method("focus")
return
if not password.value:
password.run_method("focus")
return
try:
data = get_data(username.value, password.value)
except Exception:
log.exception(f'login failed for user "{username.value}"')
ui.notify("Wrong username or password", color="negative")
return
app.storage.user.update(data, authenticated=True)
ui.navigate.to(redirect_to) # go back to where the user wanted to go
with ui.card().classes("absolute-center items-stretch"):
ui.image("assets/icon.png")
username = ui.input("Username").props("autofocus")
password = ui.input("Password", password=True, password_toggle_button=True)
username.on("keydown.enter", try_login)
password.on("keydown.enter", try_login)
ui.button("log in", icon="login", on_click=try_login)
return None
+9
View File
@@ -0,0 +1,9 @@
from nicegui import app, ui
def logout():
app.storage.user.clear()
ui.navigate.to("/login")
+30
View File
@@ -0,0 +1,30 @@
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import app
from starlette.middleware.base import BaseHTTPMiddleware
unrestricted_page_routes = {"/favicon.ico", "/login"}
class AuthMiddleware(BaseHTTPMiddleware):
"""
This middleware restricts access to all NiceGUI pages.
It redirects the user to the login page if they are not authenticated.
"""
async def dispatch(self, request: Request, call_next):
path = request.url.path
if (
app.storage.user.get("authenticated")
or path in unrestricted_page_routes
or path.startswith("/_nicegui")
or path.startswith("/api/")
):
return await call_next(request)
return RedirectResponse(f"/login?redirect_to={path}")
+96
View File
@@ -0,0 +1,96 @@
import re
from ldap3 import BASE, SUBTREE
from .krbldap import Konnection
USER_ATTRIBUTE_MAP = {
"cn": "username",
"displayName": "display name",
"givenName": "first name",
# "mail": "email",
# "sn": "last name"
}
USER_ATTRIBUTES = [*USER_ATTRIBUTE_MAP, "memberOf"]
COMMON_USER_DN = "OU=users,OU=psi,DC=d,DC=psi,DC=ch"
BASE_PGROUP_DN = "OU=Groups,OU=Experiment,OU=IT,DC=d,DC=psi,DC=ch"
PGROUP_PATTERN = re.compile(r"CN=(p\d{5}),OU=Groups,OU=Experiment,OU=IT,DC=d,DC=psi,DC=ch")
BEAMLINE_PATTERN = re.compile(r"CN=([^,]+),OU=Beamlines,OU=Experiment,OU=IT,DC=d,DC=psi,DC=ch")
SERVERS = [f"dc{i:02}.d.psi.ch" for i in range(3)]
def get_data(username, password):
with Konnection(SERVERS, username, password) as conn:
conn.search(
make_user_search_base(username),
search_filter="(objectClass=*)",
search_scope=BASE,
attributes=USER_ATTRIBUTES
)
entry = ensure_single(conn.entries, "users")
res = repack_user(entry)
res_username = res["username"]
if res_username != username:
raise RuntimeError(f"username mismatch: {res_username} != {username}")
conn.search(
BASE_PGROUP_DN,
search_filter=make_pgroups_search_filter(res["pgroups"]),
search_scope=SUBTREE,
attributes=["cn", "memberOf"]
)
res["pgroups"] = repack_pgroups(conn.entries)
return res
def make_user_search_base(cn):
res = [f"CN={cn}"]
if cn.startswith("ext-"):
res.append("OU=external")
elif cn.startswith("gac-"):
res.append("OU=nonpersons")
res.append(COMMON_USER_DN)
return ",".join(res)
def repack_user(entry):
attrs = entry.entry_attributes_as_dict
res = {USER_ATTRIBUTE_MAP[old]: item[0] for old, item in attrs.items() if old in USER_ATTRIBUTE_MAP if item}
res["pgroups"] = extract_matches(attrs["memberOf"], PGROUP_PATTERN)
return res
def make_pgroups_search_filter(pgroups):
search_filter = "".join(f"(cn={cn})" for cn in pgroups)
return f"(|{search_filter})"
def repack_pgroups(entries):
res = {}
for entry in entries:
attrs = entry.entry_attributes_as_dict
beamlines = extract_matches(attrs["memberOf"], BEAMLINE_PATTERN)
beamline = ensure_single(beamlines, "beamlines")
pgroup = ensure_single(attrs["cn"], "pgroup CNs")
res[pgroup] = beamline
return res
def ensure_single(seq, what):
n = len(seq)
if n == 1:
return seq[0]
raise RuntimeError(f"received ambiguous {what} ({n} entries)")
def extract_matches(seq, pattern):
return sorted(m.group(1) for i in seq if (m := pattern.search(i)))
+23
View File
@@ -0,0 +1,23 @@
import os
import secrets
def get_secret(fn=".secret"):
if os.path.exists(fn):
res = read(fn)
else:
res = secrets.token_hex(32)
write(res, fn)
return res.strip()
def read(fn):
with open(fn) as f:
return f.read()
def write(s, fn):
with open(fn, "w") as f:
f.write(s)
+19
View File
@@ -0,0 +1,19 @@
SUBNET_TO_BEAMLINE = {
"129.129.242": "SARESA",
"129.129.243": "SARESB",
"129.129.244": "SARESC",
"129.129.245": "SATESD",
"129.129.246": "SATESE",
"129.129.247": "SATESF"
}
def get_beamline(ip):
if ip == "127.0.0.1":
return "stand"
subnet = ".".join(ip.split(".")[:3])
return SUBNET_TO_BEAMLINE[subnet]
+11
View File
@@ -0,0 +1,11 @@
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("-f", "--fake", action="store_true")
clargs = parser.parse_args()
-4
View File
@@ -1,4 +0,0 @@
from . import patch_st_cp_stop
-21
View File
@@ -1,21 +0,0 @@
"""
Monkey patch the streamlit server to stop cherrypy upon its own stop
"""
import cherrypy
from streamlit.server.server import Server
old_fn = Server._on_stopped
def new_fn(*args, **kwargs):
print("exit cherrypy")
cherrypy.engine.exit()
print("exit streamlit")
return old_fn(*args, **kwargs)
Server._on_stopped = new_fn
+25
View File
@@ -0,0 +1,25 @@
from nicegui import APIRouter, app, ui
from auth.logout import logout
router = APIRouter()
@router.page("/")
def home():
with ui.column().classes("absolute-center items-center"):
username = app.storage.user.get("username", "unknown user")
ui.label(f"Hello {username}!").classes("text-2xl")
ui.button("log out", icon="logout", on_click=logout)
pgroups = app.storage.user.get("pgroups", {})
ui.select(
label="pgroup",
options=sorted(pgroups, reverse=True),
with_input=True,
on_change=lambda e: ui.navigate.to(f"/tables/{e.value}")
)
+29
View File
@@ -0,0 +1,29 @@
import logging
from uvicorn.config import LOGGING_CONFIG
from uvicorn.logging import ColourizedFormatter
LOGFMT = "%(asctime)s %(levelprefix)s [%(name)s] %(message)s"
def logcfg(level=logging.INFO, fmt=LOGFMT, **kwargs):
formatter = ColourizedFormatter(fmt=fmt, **kwargs)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logging.basicConfig(level=level, handlers=[handler])
# add timestamps to uvicorn logs
formatters = LOGGING_CONFIG["formatters"]
for formatter in formatters.values():
if "fmt" not in formatter:
continue
fmt = formatter["fmt"]
if "%(asctime)s" in fmt:
continue
formatter["fmt"] = "%(asctime)s " + fmt
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python
from nicegui import app, ui
from cli import clargs
from state import config
config.update(vars(clargs))
from logcfg import logcfg
logcfg()
from api import router as api_router
from auth.login import router as login_router
from auth.mw import AuthMiddleware
from auth.secret import get_secret
from home import router as home_router
from table import router as table_router
app.include_router(login_router)
app.add_middleware(AuthMiddleware)
app.include_router(api_router, prefix="/api")
app.include_router(home_router)
app.include_router(table_router)
ui.run(
title="stand",
favicon="assets/favicon.png",
storage_secret=get_secret(),
fastapi_docs=True,
dark=True,
show=False
)
-44
View File
@@ -1,44 +0,0 @@
import argparse
from threading import Thread
import cherrypy as cp
from tblctrl import TableController
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", default=9090, type=int)
clargs = parser.parse_args()
cp.config.update({
"server.socket_host": clargs.host,
"server.socket_port": clargs.port,
})
# creating instances here allows to use restapi etc. as singletons
print(f">>> start restapi on {clargs.host}:{clargs.port}")
restapi = TableController()
conf = {
"/": {
"request.dispatch": cp.dispatch.MethodDispatcher(),
"tools.sessions.on": True,
"tools.response_headers.on": True,
"tools.response_headers.headers": [("Content-Type", "text/plain")],
}
}
args = (restapi, "/", conf)
thread = Thread(target=cp.quickstart, args=args, daemon=True)
thread.start()
+20
View File
@@ -0,0 +1,20 @@
from collections import defaultdict
from adb import ArcticDB
from utils.config import Config
from utils.registry import Registry
adb = ArcticDB("lmdb://adb")
config = Config(
fake=False
)
grids = defaultdict(Registry)
+118
View File
@@ -0,0 +1,118 @@
import logging
import pandas as pd
from nicegui import APIRouter, app, ui
from aggridx import aggridx
from state import grids, adb
from utils.annotations import PGroup
OPTIONS = {
":getRowId": "(params) => params.data.run", # set row ID to index column
"context": {
"beamline": None,
"pgroup": None
},
"defaultColDef": {
"filter": True,
"editable": True,
"sortable": True,
"resizable": True
},
"pagination": True,
"paginationAutoPageSize": True,
"theme": "balham"
}
log = logging.getLogger(__name__)
router = APIRouter()
@router.page("/tables/{pgroup}")
def table(pgroup: PGroup):
pgroups = app.storage.user.get("pgroups", {})
if pgroup in pgroups:
beamline = pgroups[pgroup]
table_show(pgroup, beamline)
else:
table_deny(pgroup)
def table_show(pgroup, beamline):
# with ui.left_drawer(value=False) as ld:
# dark = ui.dark_mode(value=True)
# ui.switch("dark mode").bind_value(dark)
# with ui.page_sticky(position="left", x_offset=-12.5):
# def cb():
# ld.toggle()
# btn.icon = "sym_o_left_panel_close" if ld.value else "sym_o_left_panel_open"
# btn = ui.button(icon="sym_o_left_panel_open", on_click=cb).props("flat dense")
try:
df = adb.get(beamline).read(pgroup).data
except adb.exceptions.NoSuchVersionException:
df = pd.DataFrame()
df.index = df.index.view("int64") # adb supports update only for timeseries indexes
df.index.name = "run"
df = df.reset_index()
options = OPTIONS.copy()
options["context"].update(
beamline=beamline,
pgroup=pgroup
)
grid = aggridx.from_pandas(df, options=options)
grid.classes("h-[calc(100vh-2rem)]") # full height minus padding
grid.on("cellValueChanged", update_adb)
grid.on("cellValueChanged", update_grids)
grids[pgroup].add(grid)
def table_deny(pgroup):
username = app.storage.user.get("username", "unknown user")
log.error(f'access to pgroup {pgroup} denied for user "{username}"')
with ui.column().classes("absolute-center items-center gap-8"):
ui.icon("sym_o_front_hand", size="xl")
ui.label(f"access to {pgroup} denied").classes("text-2xl")
def update_adb(evt):
if evt.args.get("source") != "edit":
# ignore event if it was no direct edit
return
beamline = evt.args["context"]["beamline"]
pgroup = evt.args["context"]["pgroup"]
row_id = evt.args["rowId"]
col_id = evt.args["colId"]
new_val = evt.args["newValue"]
index = pd.Timestamp(int(row_id)) # adb supports update only for timeseries indexes
lib = adb.get(beamline)
df = lib.read(pgroup, date_range=[index]).data
df.at[index, col_id] = new_val
lib.update(pgroup, df)
def update_grids(evt):
sender = evt.sender
pgroup = evt.args["context"]["pgroup"]
row_index = evt.args["rowIndex"]
row_id = evt.args["rowId"]
col_id = evt.args["colId"]
new_val = evt.args["newValue"]
for grid in grids[pgroup]:
grid.set_cell_server(row_index, col_id, new_val)
if grid == sender:
# the sender is already up-to-date
continue
grid.set_cell_client(row_id, col_id, new_val)
-63
View File
@@ -1,63 +0,0 @@
import cherrypy as cp
from utils.dfh import DateFrameHolder
from utils.st_utils import rerunall, get_session_ids
@cp.expose
class TableController:
def __init__(self):
self.dfh = DateFrameHolder("output.h5")
# self.changed = True
self.sids = get_session_ids()
def get(self, sid):
changed = self.get_changed(sid)
data = self.get_data(sid)
return changed, data
def get_changed(self, sid):
return (sid in self.sids)
def get_data(self, sid):
# self.changed = False
try:
self.sids.remove(sid)
except KeyError as e:
if self.sids:
raise RuntimeError(f"{self.sids} is not empty") from e
return self.dfh.df
def set_data(self, df):
if self.dfh.df.equals(df):
print("<<< skipping dump because dataframe did not change")
return
self.dfh.df = df
self.dfh.dump()
self._trigger_changed()
def GET(self):
return str(self.dfh.df)
@cp.tools.json_in()
def PATCH(self, **kwargs):
kwargs = kwargs or cp.request.json
self.dfh.append(kwargs)
self._trigger_changed()
self.dfh.dump()
return str(self.dfh.df)
def DELETE(self):
self.dfh.clear()
self._trigger_changed()
return "cleared"
def _trigger_changed(self):
# self.changed = True
self.sids = get_session_ids()
rerunall()
+17
View File
@@ -0,0 +1,17 @@
from typing import Annotated
from fastapi import Depends, Path, Request
from beamline import get_beamline
def beamline_dependency(request: Request) -> str:
return get_beamline(request.client.host)
Beamline = Annotated[str, Depends(beamline_dependency)]
PGroup = Annotated[str, Path(pattern=r"^p\d{5}$")]
+8
View File
@@ -0,0 +1,8 @@
class Config(dict):
__getattr__ = dict.__getitem__
__setattr__ = dict.__setitem__
__delattr__ = dict.__delitem__
-27
View File
@@ -1,27 +0,0 @@
from io import BytesIO
def to_excel_binary(df, **kwargs):
with BytesIO() as out:
df.to_excel(out, **kwargs)
res = out.getvalue()
return res
from pandas import HDFStore
def to_hdf_binary(df):
with HDFStore(
"wontbewritten.h5",
mode="a",
driver="H5FD_CORE",
driver_core_backing_store=0
) as out:
out["/data"] = df
res = out._handle.get_file_image()
return res
-72
View File
@@ -1,72 +0,0 @@
import pandas as pd
class DateFrameHolder:
def __init__(self, fn):
self.fn = fn
self.df = try_load_df(fn)
def dump(self):
dump_non_empty_df(self.df, self.fn)
def append(self, data):
data = pd.DataFrame.from_records([data])
self.df = pd.concat([self.df, data], ignore_index=True)
def clear(self):
self.df = pd.DataFrame()
def __repr__(self):
head = self.fn + ":"
line = "-" * len(head)
df = str(self.df)
return "\n".join((head, line, df))
def try_load_df(fn):
try:
df = pd.read_hdf(fn)
print(f">>> loaded dataframe from {fn}")
except FileNotFoundError:
df = pd.DataFrame()
print(">>> created empty dataframe")
return df
def dump_non_empty_df(df, fn, key="data"):
if df.empty:
print("<<< skip dumping empty dataframe")
return
print(f"<<< dump dataframe to {fn}")
df.to_hdf(fn, key=key)
backup(df, fn, key) #TODO: test then remove
# the following is just a safety precaution
# if everything works, it can be removed
from datetime import datetime
from pathlib import Path
FMT = "%Y-%m-%d_%H-%M-%S-%f"
BAKDIR = ".backup"
def backup(df, fn, key):
Path(BAKDIR).mkdir(parents=True, exist_ok=True)
bfn = backup_filename(fn)
print(f"<<< backup dataframe to {bfn}")
df.to_hdf(bfn, key=key)
def backup_filename(fn):
p = Path(fn)
fn = p.stem
ext = p.suffix
ts = datetime.now().strftime(FMT)
return f"{BAKDIR}/{fn}_{ts}{ext}"
+18
View File
@@ -0,0 +1,18 @@
from nicegui import ui
class Registry:
def __init__(self):
self.data = {}
def __iter__(self):
return iter(self.data.values())
def add(self, widget):
client = ui.context.client
self.data[client.id] = widget
client.on_disconnect(lambda: self.data.pop(client.id, None))
-64
View File
@@ -1,64 +0,0 @@
import streamlit as st
get_server = st.server.server.Server.get_current
def rerunall():
sibi = get_session_info_by_id()
for si in sibi.values():
print("rerun:", si.session.id)
client_state = None
si.session.request_rerun(client_state)
def get_session_ids():
sibi = get_session_info_by_id()
sids = sibi.keys()
return set(sids)
def rerun(session_id=None):
if session_id is None:
session_id = get_session_id()
server = get_server()
session = server.get_session_by_id(session_id)
client_state = None
session.request_rerun(client_state)
def get_session_id():
ctx = st.scriptrunner.script_run_context.get_script_run_ctx()
return ctx.session_id
def get_session_info_by_id():
server = get_server()
return server._session_info_by_id
def hide_UI_elements(menu=True, header=True, footer=True):
HIDDEN = " {visibility: hidden;}"
res = []
if menu:
res.append("#MainMenu" + HIDDEN)
if header:
res.append("header" + HIDDEN)
if footer:
res.append("footer" + HIDDEN)
if not res:
return
res = ["<style>"] + res + ["</style>"]
res = "\n".join(res)
return st.markdown(res, unsafe_allow_html=True)
-84
View File
@@ -1,84 +0,0 @@
from pathlib import Path
import streamlit as st
import hacks
from widgets.aggrid import aggrid
from widgets.download import download
from restapi import restapi
from utils.st_utils import get_session_id, hide_UI_elements
print(">>> start of streamlit run")
icon = Path(__file__).parent.parent / "icon.png"
st.set_page_config(
page_title="stand",
page_icon=str(icon),
layout="wide",
initial_sidebar_state="collapsed"
)
hide_UI_elements()
sid = get_session_id() # rest api needs current session IDs to trigger reruns
changed, df = restapi.get(sid)
download(df)
auto_height = st.sidebar.checkbox(
"Auto Height",
value=False,
key="auto_height"
)
pagination = st.sidebar.checkbox(
"Pagination",
value=True,
disabled=auto_height,
key="pagination"
)
height = st.sidebar.slider(
"Grid Height",
min_value=100, max_value=1500, value=500, step=10,
disabled=auto_height,
key="grid_height"
)
if auto_height:
pagination = False # would only show "1 of 1"
height = "auto"
# if df in restapi changed, we need to reload it into aggrid.
# if df in restapi did not change, we do not reload to ensure changes in the browser persist.
response = aggrid(
df,
reload_data=changed,
pagination=pagination,
height=height,
enable_enterprise_modules=False,
key="stand"
)
# if we reloaded, aggrid returns the df from before (why?), thus we do not update the restapi.
# if we did not reload, aggrid may return an updated df from an edit in the browser,
# and thus we need to update the restapi.
new_df = response["data"]
if not new_df.equals(df) and not changed:
restapi.set_data(new_df)
print(">>> end of streamlit run")
-59
View File
@@ -1,59 +0,0 @@
from st_aggrid import AgGrid, GridOptionsBuilder
def aggrid(df, reload_data=False, height="auto", pagination=True, key=None, **kwargs):
df = df[::-1] # display in reversed chronological order
gob = GridOptionsBuilder.from_dataframe(
df,
editable=True,
filterable=True,
groupable=True,
resizable=True,
sortable=True
)
auto_height = (height == "auto")
gob.configure_auto_height(auto_height)
gob.configure_pagination(pagination)
go = gob.build()
response = AgGrid(
df,
go,
height=height,
theme="streamlit",
reload_data=reload_data,
key=make_key(key, df, auto_height, pagination),
**kwargs
)
df = response.get("data", df)
df = df[::-1] # undo reversed chronological order
#TODO: streamlit-aggrid changed return type
if isinstance(response, dict):
response["data"] = df # <= 0.2.3.post2
else:
response.data = df # >= 0.3.0
return response
def make_key(prefix, df, auto_height, pagination):
"""
encode the dataframe's column names into a key,
as well as the state of the auto height and pagination settings,
this triggers a hard reload (like F5) of the AgGrid if the columns/settings change
"""
if prefix is None:
return None
items = list(df.columns)
items.append(auto_height)
items.append(pagination)
return str(prefix) + ":" + "+".join(str(i) for i in items)
-31
View File
@@ -1,31 +0,0 @@
import streamlit as st
from utils.df2bin import to_excel_binary, to_hdf_binary
state = st.session_state
def download(df):
showing_downloads = state.get("showing_downloads", False)
col0, col1, col2, col3 = st.columns(4)
if col0.button("Downloads") and not showing_downloads:
state.showing_downloads = True
h5 = to_hdf_binary(df)
col1.download_button("📥 hdf5", h5, file_name="output.h5")
xlsx = to_excel_binary(df)
col2.download_button("📥 xlsx", xlsx, file_name="output.xlsx")
csv = df.to_csv()
col3.download_button("📥 csv", csv, file_name="output.csv")
# st.stop()
else:
state.showing_downloads = False
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env python
from itertools import count, cycle
from random import random, randint
from string import ascii_letters
from time import sleep
from client import Client
def irandom():
while True:
yield random()
num = count()
ran = irandom()
let = cycle(ascii_letters)
c = Client()
c.clear()
sleep(1)
for i, r, l in zip(num, ran, let):
l = l * randint(1, 5)
kwargs = {} if i < 10 else {"x": 123}
c.add_row(index=i, random=r, string=l, **kwargs)
sleep(1)