Merge branch 'user-portal' into 'main'

User portal MVP also called *Codex

See merge request oceanbox/Poseidon!97
This commit was merged in pull request #200.
This commit is contained in:
2025-11-26 14:36:30 +01:00
91 changed files with 9649 additions and 537 deletions

View File

@@ -41,3 +41,9 @@ include:
- changes:
- 'src/ServerPack/**/*'
- 'nix/packages/serverpack.nix'
- local: '/src/Codex/.gitlab-ci.yml'
rules:
- changes:
- 'src/Codex/**/*'
- 'nix/packages/node-modules.nix'
- 'nix/packages/sources.nix'

View File

@@ -24,6 +24,10 @@
<Project Path="src/Atlantis/src/Server/Petimeter/Petimeter.fsproj" />
<Project Path="src/Atlantis/src/Server/Server.fsproj" />
</Folder>
<Folder Name="/Codex/">
<Project Path="src\Codex\src\Client\Codex.Client.fsproj" />
<Project Path="src\Codex\src\Server\Codex.Server.fsproj" />
</Folder>
<Folder Name="/DataAgent/">
<Project Path="src/DataAgent/src/Entity/Entity.csproj" />
</Folder>

View File

@@ -28,25 +28,16 @@ let
packages = import ./nix/packages {
inherit
env
deps
pkgs
version
dotnet-sdk
dotnet-runtime
env
deps
;
inherit netrcConfig;
};
in
rec {
inherit packages;
inherit scripts;
# Expose atlantis as default packages
default = packages.atlantis;
# Docker and Singurlarity images
containers = pkgs.callPackage ./nix/containers.nix {
inherit (packages)
atlantis
@@ -58,7 +49,19 @@ rec {
version
env
;
codex = packages.codex;
};
in
{
inherit packages;
inherit scripts;
# Expose atlantis as default packages
default = packages.atlantis;
# Docker and Singurlarity images
containers = containers;
checks = {
pre-commit = import ./nix/pre-commit.nix;

View File

@@ -6,6 +6,7 @@
atlantis-client,
sorcerer,
archivist,
codex,
}:
let
# Entrypoints
@@ -35,6 +36,7 @@ in
cp -r ${atlantis}/lib/Atlantis/* ./app/
cp -r ${atlantis-client}/public ./app/
'';
config = {
cmd = [ "Server" ];
workingDir = "/app";
@@ -65,6 +67,7 @@ in
workingDir = "/app";
};
};
archivist = pkgs.dockerTools.buildLayeredImage {
name = "archivist";
tag = archivist.version;
@@ -95,4 +98,5 @@ in
};
};
codex = pkgs.callPackage ../src/Codex/container.nix { server = codex; };
}

View File

@@ -76,7 +76,8 @@ let
outputHash = "sha256-T9X1EFeoNV3yKdVUIMOvaYtja6XR0fne6CDkKHD5rhE=";
};
atlantis-client = buildDotnetModule {
in
buildDotnetModule {
inherit dotnet-sdk dotnet-runtime;
inherit src version;
pname = "${pname}-Client";
@@ -140,6 +141,4 @@ let
dontFixup = true;
dontPatchELF = true;
dontStrip = true;
};
in
atlantis-client
}

View File

@@ -20,7 +20,7 @@ buildDotnetModule rec {
pname = "Atlantis";
# NOTE(mrtz): Ensures reproducibility and reduces closure size,
# by filtering out irrelevant files and `.git` which changes between commits.
src = nix-gitignore.gitignoreSource [ ] ../../.;
src = nix-gitignore.gitignoreSource [ ] ../..;
projectFile = "src/Atlantis/src/Server/Server.fsproj";
dotnetRestoreFlags = "--force-evaluate";
nugetDeps = deps {

View File

@@ -9,23 +9,18 @@
}:
let
# NOTE(mrtz): Gitlab Nuget Registry does not support groupwide fetches :/
packageSources = {
"Oceanbox.FvcomKit" = "https://gitlab.com/api/v4/projects/35569541/packages/nuget/download";
"ProjNet.FSharp" = "https://gitlab.com/api/v4/projects/35009572/packages/nuget/download";
"SDSLite.Oceanbox" = "https://gitlab.com/api/v4/projects/34025102/packages/nuget/download";
"Oceanbox.ServerPack" = "https://gitlab.com/api/v4/projects/67427353/packages/nuget/download";
"Oceanbox.DataAgent" = "https://gitlab.com/api/v4/projects/37541600/packages/nuget/download";
"Drifters.Api" = "https://gitlab.com/api/v4/projects/37086336/packages/nuget/download";
"Fable.SignalR.AspNetCore" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR.Saturn" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR.Shared" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR.Elmish" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.Lit" = "https://gitlab.com/api/v4/projects/61744837/packages/nuget/download";
"Fable.Lit.Elmish" = "https://gitlab.com/api/v4/projects/61744837/packages/nuget/download";
"Fable.Lit.React" = "https://gitlab.com/api/v4/projects/61744837/packages/nuget/download";
"Fable.OpenLayers" = "https://gitlab.com/api/v4/projects/36202053/packages/nuget/download";
"Matplotlib.ColorMaps" = "https://gitlab.com/api/v4/projects/36675671/packages/nuget/download";
packageSources = import ./sources.nix;
nodeModules = pkgs.callPackage ./node-modules.nix {};
codex-client = pkgs.callPackage ../../src/Codex/src/Client {
inherit
deps
dotnet-sdk
netrcConfig
nodeModules
packageSources
;
};
in
{
@@ -38,6 +33,7 @@ in
packageSources
;
};
# NOTE(mrtz): It's acutally Oceanbox.DataAgent
archmaester = pkgs.callPackage ./dataagent.nix {
inherit
@@ -48,6 +44,7 @@ in
packageSources
;
};
# NOTE(mrtz): It's acutally Poseidon.Api
interfaces = pkgs.callPackage ./api.nix {
inherit
@@ -59,6 +56,7 @@ in
packageSources
;
};
atlantis = pkgs.callPackage ./atlantis.nix {
inherit
env
@@ -70,6 +68,7 @@ in
packageSources
;
};
sorcerer = pkgs.callPackage ./sorcerer.nix {
inherit
env
@@ -101,4 +100,15 @@ in
packageSources
;
};
codex = pkgs.callPackage ../../src/Codex/src/Server {
inherit
deps
dotnet-sdk
netrcConfig
dotnet-runtime
packageSources
;
client = codex-client;
};
}

View File

@@ -0,0 +1,52 @@
{
bun,
stdenvNoCC,
nix-gitignore,
writableTmpDirAsHomeHook,
}:
stdenvNoCC.mkDerivation {
name = "node-modules";
nativeBuildInputs = [
bun
writableTmpDirAsHomeHook
];
src = nix-gitignore.gitignoreSource [ ] ../../.;
dontConfigure = true;
# Required else we get errors that our fixed-output derivation references store paths
dontFixup = true;
# Only install dependencies, don't build
buildPhase = ''
runHook preBuild
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
# Disable post-install scripts to avoid shebang issues
bun install \
--frozen-lockfile \
--ignore-scripts \
--backend copyfile \
--no-progress \
--force
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r node_modules $out/
runHook postInstall
'';
outputHashMode = "recursive";
outputHashAlgo = "sha256";
# NOTE: Empty this when a new dependency is added
outputHash = "sha256-T9X1EFeoNV3yKdVUIMOvaYtja6XR0fne6CDkKHD5rhE=";
}

19
nix/packages/sources.nix Normal file
View File

@@ -0,0 +1,19 @@
{
"Drifters.Api" = "https://gitlab.com/api/v4/projects/37086336/packages/nuget/download";
"Fable.Lit" = "https://gitlab.com/api/v4/projects/61744837/packages/nuget/download";
"Fable.Lit.Elmish" = "https://gitlab.com/api/v4/projects/61744837/packages/nuget/download";
"Fable.Lit.React" = "https://gitlab.com/api/v4/projects/61744837/packages/nuget/download";
"Fable.OpenLayers" = "https://gitlab.com/api/v4/projects/36202053/packages/nuget/download";
"Fable.SignalR" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR.AspNetCore" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR.Elmish" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR.Saturn" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Fable.SignalR.Shared" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
"Matplotlib.ColorMaps" = "https://gitlab.com/api/v4/projects/36675671/packages/nuget/download";
"Oceanbox.DataAgent" = "https://gitlab.com/api/v4/projects/37541600/packages/nuget/download";
"Oceanbox.FvcomKit" = "https://gitlab.com/api/v4/projects/35569541/packages/nuget/download";
"Oceanbox.ServerPack" = "https://gitlab.com/api/v4/projects/67427353/packages/nuget/download";
"ProjNet.FSharp" = "https://gitlab.com/api/v4/projects/35009572/packages/nuget/download";
"Oceanbox.SDSLite" = "https://gitlab.com/api/v4/projects/34025102/packages/nuget/download";
}

View File

@@ -17,5 +17,25 @@ let private uk : obj =
dateTimeFormat "en-GB" opts
// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
let private ukShort : obj =
let opts = {|
dateStyle = "short"
|}
dateTimeFormat "en-GB" opts
// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
let private ukDateTimeShort : obj =
let opts = {|
dateStyle = "short"
timeStyle = "short"
|}
dateTimeFormat "en-GB" opts
/// Returns date string formatted as e.g.: "Wednesday 11 June 2025 at 06:00"
let format (date: System.DateTime) : string = uk?format date
let shortDate (date: System.DateTime) : string = ukShort?format date
let shortDateTime (date: System.DateTime) : string = ukDateTimeShort?format date

View File

@@ -82,9 +82,10 @@ let fromBase64String (s: string) : string = jsNative
[<Emit("btoa($0)")>]
let toBase64String (s: string) : string = jsNative
let strNull = String.IsNullOrWhiteSpace
let strNotNull = strNull >> not
/// Helper function for testing whether a string is null or only whitespace
let tryStr str =
if String.IsNullOrWhiteSpace str then None else Some str
let tryStr str = if strNotNull str then Some str else None
/// Uses js getElementById on the HTML id. Tests elem for isNullOrUndefined
let tryElem (id: string) : Types.Element option =
@@ -130,6 +131,12 @@ let onEnterOrEscape onEnter onEscape (ev: Types.Event) =
| "Escape" -> onEscape ev
| _ -> ()
let debounce delay (f: 'T -> unit) : 'T -> unit =
let mutable timer = unbox null
fun args ->
do JS.clearTimeout timer
do timer <- JS.setTimeout (fun () -> f args) delay
/// <summary>
/// Calculate the ISO week number from a date. Taken from https://weeknumber.com/how-to/javascript since we cannot
/// use https://learn.microsoft.com/en-us/dotnet/api/system.globalization.calendar.getweekofyear?view=net-8.0

View File

@@ -21,7 +21,7 @@ open Archmaester
[<RequireQualifiedAccess>]
module Handlers =
let notImplemented = fun _ -> async.Return (Error "not implemented")
let notImplemented = fun _ -> failwith "Not implemented"
type private Permission = {
uid: string
@@ -334,10 +334,12 @@ module Handlers =
|> Array.ofSeq
let filter = {
id = None
archiveType = Some aType
owner = Some user
user = Some user
groups = Some groups
searchTerm = None
}
async {
@@ -607,19 +609,32 @@ module Handlers =
return models
}
let getArchives (ctx: HttpContext) (mid: ModelAreaId, filter: ArchiveFilter) =
let getArchives (ctx: HttpContext) (page: int) (rowCount: int) (filter: ArchiveFilter) =
async {
let db = Archives.Archivist (Db.getDataSource ())
match db.getModelAreaArchives (mid, filter) with
| Error err ->
Log.Error $"getArchives: error: {err}"
ctx.SetStatusCode 500
return Error "Could not retrieve model area archives"
match db.getArchives(page, rowCount, filter) with
| Ok archives ->
Log.Debug $"getArchives: %A{archives}"
Log.Debug("[Archmaester] getArchives: length for filter {Filter}: {ArchiveCount}", filter, archives.Length)
ctx.SetStatusCode 200
return Ok archives
| Error ex ->
Log.Error(ex, "[Archmaester] getArchives error with filter {Filter}", filter)
ctx.SetStatusCode 500
return Error "Could not retrieve model area archives"
}
let getArchiveCount (ctx: HttpContext) (filter: ArchiveFilter) =
async {
let db = Archives.Archivist (Db.getDataSource ())
match db.getArchiveCount filter with
| Ok archiveCount ->
Log.Debug("[Archmaester] getArchiveCount {Count} with filter {Filter}: {ArchiveCount}", archiveCount, filter)
ctx.SetStatusCode 200
return Ok archiveCount
| Error ex ->
Log.Error(ex, "[Archmaester] getArchiveCount error with filter {Filter}", filter)
ctx.SetStatusCode 500
return Error "Could not retrieve model area archives"
}
let getArchiveOrModelPolygon (ctx: HttpContext) (aid: ArchiveId) =
@@ -703,9 +718,8 @@ module Handlers =
}
let getAcl (ctx: HttpContext) (aid: ArchiveId) =
Log.Information $"Getting archive acl: {aid}"
async {
Log.Information $"Getting archive acl: {aid}"
let db = Archives.Archivist (Db.getDataSource ())
match db.getArchiveAcl aid with
@@ -719,6 +733,42 @@ module Handlers =
return Error $"Could not retrieve acl: {err}"
}
let getGroups (ctx: HttpContext) =
async {
let db = Archives.Archivist (Db.getDataSource ())
let res = db.getGroups ()
match res with
| Ok groups ->
let dtos = groups |> Array.map _.Name
return dtos
| Error err ->
return [||]
}
let getGroupArchives (ctx: HttpContext) (group: string) =
async {
let db = Archives.Archivist (Db.getDataSource ())
let res = db.getGroupArchives group
match res with
| Ok archives ->
let dtos = archives
return dtos
| Error err ->
return [||]
}
let getGroupUsers (ctx: HttpContext) (group: string) =
async {
let db = Archives.Archivist (Db.getDataSource ())
let res = db.getGroupUsers group
match res with
| Ok users ->
let dtos = users |> Array.map _.Name
return dtos
| Error err ->
return [||]
}
let addToArchiveAcl (ctx: HttpContext) (aclType: AclType) (aid: ArchiveId, names: string[]) =
Log.Information $"Adding acl to archive: {aid}"
@@ -748,13 +798,13 @@ module Handlers =
return Error $"Could not add acl: {err}"
}
let addToAcl (ctx: HttpContext) (aclType: AclType) (names: string[]) =
Log.Information $"Adding acl {aclType}: %A{names}"
let addToAcl (ctx: HttpContext) (aclType: AclType) (request: AddUsersRequest) =
Log.Information $"Adding acl {aclType}: %A{request.users}"
async {
let db = Archives.Archivist (Db.getDataSource ())
match db.tryAddToAcl (aclType, names) with
match db.tryAddToAcl (aclType, request.users) with
| Ok _ ->
ctx.SetStatusCode 201
return Ok ()
@@ -928,14 +978,14 @@ module Handlers =
let user = ctx.User.Identity.Name
{
getModelAreaArchives = getModelAreaArchives ctx
addSubArchive = fun sub -> requireEditor user sub.reference (fun () -> createSubArchive ctx sub)
getArchive = fun aid -> requireViewer user aid (fun () -> getArchiveProps ctx aid)
getRefArchives = fun (aid, _ as args) -> requireViewer user aid (fun () -> getRefArchives ctx args)
getArchivePolygon = fun aid -> requireViewer user aid (fun () -> getArchivePolygon aid)
getModelAreaArchives = getModelAreaArchives ctx
getRefArchives = fun (aid, _ as args) -> requireViewer user aid (fun () -> getRefArchives ctx args)
resizeArchive = fun (aid, _, _ as args) -> requireEditor user aid (fun () -> resizeArchive args)
retireArchive = fun aid -> requireEditor user aid (fun () -> deleteArchive ctx aid)
updateArchive = fun (aid, _ as args) -> requireEditor user aid (fun () -> updateArchive ctx args)
resizeArchive = fun (aid, _, _ as args) -> requireEditor user aid (fun () -> resizeArchive args)
}
let modelAreaHandlers (ctx: HttpContext) : Api.ModelArea = {
@@ -946,28 +996,28 @@ module Handlers =
}
let adminHandlers (ctx: HttpContext) : Api.Admin = {
newArchive = addArchive ctx
getArchiveDto = getArchiveDto
augmentFiles = augmentFiles
renameFiles = notImplemented
getFiles = getFiles
getAllFiles = getAllFiles
queryModelAreaId = queryModelAreaId ctx
addModelArea = addModelArea ctx
updateModelArea = updateModelArea ctx
setModelAreaPolygon = setModelAreaPolygon ctx
setArchivePolygon = setArchivePolygon ctx
updateArchiveAttribs = updateArchiveAttribs ctx
deleteModelArea = Db.rmModelAreaFromDb
removeRetiredAttribs = removeRetiredAttribs
addUsers = addToAcl ctx AclType.User
addGroups = addToAcl ctx AclType.Group
removeUsers = removeFromAcl ctx AclType.User
removeGroups = removeFromAcl ctx AclType.Group
addType = addArchiveType
removeType = removeArchiveType
addAssociation = addAssociatedAttribs ctx
addGroups = notImplemented
addModelArea = addModelArea ctx
addType = addArchiveType
addUsers = addToAcl ctx AclType.User
augmentFiles = augmentFiles
deleteModelArea = Db.rmModelAreaFromDb
getAllFiles = getAllFiles
getArchiveDto = getArchiveDto
getFiles = getFiles
newArchive = addArchive ctx
queryModelAreaId = queryModelAreaId ctx
removeAssociation = removeAssociatedAttribs ctx
removeGroups = removeFromAcl ctx AclType.Group
removeRetiredAttribs = removeRetiredAttribs
removeType = removeArchiveType
removeUsers = removeFromAcl ctx AclType.User
renameFiles = notImplemented
setArchivePolygon = setArchivePolygon ctx
setModelAreaPolygon = setModelAreaPolygon ctx
updateArchiveAttribs = updateArchiveAttribs ctx
updateModelArea = updateModelArea ctx
}
module Endpoints =

View File

@@ -1,9 +1,8 @@
namespace Common
module Utils =
open System
open System.Net.Http
module Utils =
open Azure.Identity
open Azure.Security.KeyVault.Secrets
open Serilog

View File

@@ -89,7 +89,8 @@ let corsPolicy (policy: CorsPolicyBuilder) =
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod()
.WithOrigins appsettings.file.allowedOrigins
.WithOrigins(appsettings.file.allowedOrigins)
.SetIsOriginAllowedToAllowWildcardSubdomains()
|> ignore
type UserIdProvider() =

View File

@@ -61,9 +61,8 @@
"connString": "Username=postgres;Password=secret;Host=localhost;Port=5432;Database=app;Pooling=true;",
"sorcerer" : "https://<x>-sorcerer.ekman.oceanbox.io",
"allowedOrigins": [
"https://atlantis.beta.oceanbox.io",
"https://<x>-atlantis.dev.oceanbox.io",
"https://atlantis.local.oceanbox.io:8080"
"http://*.oceanbox.io",
"https://*.oceanbox.io",
],
"appName": "atlantis",
"appEnv": "<x>",

12
src/Codex/.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,12 @@
# yaml-language-server: $schema=https://gitlab.com/gitlab-org/gitlab/-/raw/master/app/assets/javascripts/editor/schema/ci.json
variables:
SKIP_TESTS: "true"
SKIP_SINGULARITY: "true"
include:
- project: oceanbox/gitlab-ci
ref: v4.2
file: DotnetDeployment.gitlab-ci.yml
inputs:
project-name: codex
project-dir: src/Codex

6
src/Codex/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY dist /app
ENTRYPOINT ["dotnet", "/app/Codex.Server.dll"]

45
src/Codex/Tiltfile Normal file
View File

@@ -0,0 +1,45 @@
name='codex'
cluster='oceanbox'
env=os.getenv('APP_ENV')
namespace=os.getenv('APP_NAMESPACE')
app = '{}-{}'.format(env, name)
repository = 'yolo-registry.dev.oceanbox.io/{}/{}'.format(env, name)
allow_k8s_contexts(cluster)
load('ext://restart_process', 'docker_build_with_restart')
local_resource(
'frontend-watch',
serve_cmd = 'fable -e .jsx -o build --verbose --watch --run bunx --bun vite -c ../../vite.config.js',
serve_dir = 'src/Client',
)
local_resource(
'server',
'dotnet publish -o dist src/Server',
deps = [ 'src/Server' ],
ignore = [
'src/Server/obj',
'src/Server/bin',
],
)
docker_build_with_restart(
repository,
'.',
entrypoint = [ 'dotnet', '/app/Codex.Server.dll' ],
dockerfile = 'Dockerfile',
live_update = [
sync('dist', '/app')
],
ignore = [ 'src/Client' ]
)
k8s_yaml('tilt/k8s.yaml')
k8s_resource(app, port_forwards='8085:8085')
# vim:ft=python

23
src/Codex/container.nix Normal file
View File

@@ -0,0 +1,23 @@
{
server,
busybox,
dockerTools,
}:
dockerTools.buildLayeredImage {
name = "Codex";
tag = "0.0.0-rc1";
created = "now";
contents = [
server
busybox
dockerTools.binSh
];
config = {
cmd = [ "Codex.Server" ];
workingDir = "/lib/Codex";
};
}

38
src/Codex/default.nix Normal file
View File

@@ -0,0 +1,38 @@
{
pkgs,
deps,
dotnet-sdk,
netrcConfig,
nodeModules,
dotnet-runtime,
packageSources,
}:
let
client = pkgs.callPackage ./src/Client {
inherit
deps
dotnet-sdk
netrcConfig
nodeModules
packageSources
;
};
server = pkgs.callPackage ./src/Server {
inherit
deps
client
dotnet-sdk
dotnet-runtime
netrcConfig
packageSources
;
};
in
{
client = client;
server = server;
container = pkgs.callPackage ./container.nix { server = server; };
}

View File

@@ -0,0 +1,747 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Core.JsInterop
open Feliz
open Feliz.Router
module Archive =
let private fetchArchiveAcl (id: System.Guid) : JS.Promise<Result<Archmaester.Dto.ArchiveAcl, string>> =
promise {
try
let! acl = Remoting.aclApi.getAcl id |> Async.StartAsPromise
return acl
with e ->
console.error("Error fetching archive ACL: %o", e)
return Error "Error fetching archive ACL"
}
let private fetchSubArchives (id: System.Guid) : JS.Promise<Result<Archmaester.Dto.ArchiveProps array, string>> =
promise {
let archiveType = Archmaester.Dto.ArchiveType.FromString "*:*:*"
let filter = {
Archmaester.Dto.ArchiveFilter.empty with
id = Some id
archiveType = Some archiveType
}
let! subs = Remoting.adminApi.getArchiveRefs filter |> Async.StartAsPromise
return subs
}
let private deleteArchive (id: System.Guid) =
promise {
try
let! res = Remoting.adminApi.deleteArchive id |> Async.StartAsPromise
return res
with e ->
console.error("Error deleting archive: %o", e)
return Error "Error deleting archive"
}
let private addArchiveGroup (archiveId: System.Guid) (group: string) =
promise {
try
let! res = Remoting.aclApi.addGroups (archiveId, [| group |]) |> Async.StartAsPromise
return res
with e ->
return Error "Error adding group to archive ACL"
}
[<ReactComponent>]
let private GroupSelect (onChange: string option -> unit) =
let groupsReq = Groups.useGroups()
let options =
let groupOptions =
groupsReq.Groups
|> Array.sort
|> Array.map (fun group ->
Html.option [
prop.value group
prop.text group
]
)
groupOptions
|> Array.append [|
Html.option [
prop.value "none"
prop.text "None"
]
|]
Html.select [
prop.onChange (fun (ev: Types.Event) ->
let group : string = ev.target?value
if group = "none" then
onChange None
else
onChange (Some group)
)
prop.children options
]
[<ReactComponent>]
let private GroupFGAList (object: string) (id: string) relation userFilter ctx =
let groups = OpenFGA.useUsers(object, id, relation, userFilter, ctx)
if Array.isEmpty groups.Objects then
Html.p "No objects"
else
match groups.Error with
| Some err ->
Html.p err
| None ->
Html.ul [
prop.children (
groups.Objects
|> Array.map (fun object ->
Html.li [
prop.key object
prop.children [
Html.a [
prop.href (Router.format("groups", object))
prop.text object
]
]
]
)
)
]
[<ReactComponent>]
let private UserFGAList (object: string) (id: string) relation userFilter ctx =
let groups = OpenFGA.useUsers(object, id, relation, userFilter, ctx)
if Array.isEmpty groups.Objects then
Html.p "No objects"
else
match groups.Error with
| Some msg ->
Html.p msg
| None ->
Html.ul [
prop.children (
groups.Objects
|> Array.map (fun object ->
Html.li [
prop.key object
prop.children [
Html.a [
prop.href (Router.format("user", object))
prop.text object
]
]
]
)
)
]
[<ReactComponent>]
let SubArchives (archiveId: System.Guid) =
let loading, setLoading = React.useState true
let archives, setArchives = React.useState<Archmaester.Dto.ArchiveProps array> [||]
let drifter = Archmaester.Dto.ArchiveType.FromString "drifters:*:*"
let drifterKind, _, _ = drifter.ToDbType ()
let drifters =
archives
|> Array.filter (fun archive ->
let kind, _, _ = archive.archiveType.ToDbType ()
kind = drifterKind
)
let rest =
archives
|> Array.filter (fun archive ->
let kind, _, _ = archive.archiveType.ToDbType ()
kind <> drifterKind
)
React.useEffect (
(fun () ->
fetchSubArchives archiveId
|> Promise.iter (fun res ->
match res with
| Ok archives -> setArchives archives
| Error err ->
console.error("Error fetching archive %s", err)
setLoading false
)
),
[| box archiveId |]
)
React.fragment [
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 512)
style.minWidth (length.px 512)
style.maxWidth (length.px 768)
]
prop.children [
Html.h2 "Sub archives"
if loading then
Html.p "Loading ..."
else
if Array.isEmpty archives then
Html.p "No sub archives"
else
Html.ul [
prop.children [
Html.li [
prop.children [
Html.text "Archives"
Html.ul [
prop.children (
rest
|> Array.map (fun archive ->
Html.li [
prop.key archive.archiveId
prop.children [
Html.a [
prop.href (Router.format ("archives", string archive.archiveId))
prop.text archive.name
]
]
]
)
)
]
]
]
Drifters.List (drifters |> Array.map (fun prop -> { Props = prop; CanView = false; CanExec = false }))
]
]
]
]
]
[<ReactComponent>]
let View (archiveId: System.Guid) =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let editing, setEditing = React.useState false
let deleting, setDeleting = React.useState false
let deleted, setDeleted = React.useState false
let selectedGroup, setSelectedGroup = React.useState<string option> None
let archiveOpt, setArchive = React.useState<Archmaester.Dto.ArchiveProps option> None
let aclOpt, setAcl = React.useState<Archmaester.Dto.ArchiveAcl option> None
let handleAddGroupToArchiveAcl (archiveId: System.Guid) =
match aclOpt with
| Some acl ->
match selectedGroup with
| Some group ->
console.debug("Adding group %s to archive %s", selectedGroup, archiveId)
addArchiveGroup archiveId group
|> Promise.iter (fun res ->
match res with
| Ok () ->
let newAcl = {
acl with
groups = [| group |] |> Array.append acl.groups
}
setAcl (Some newAcl)
| Error err ->
setError (Some err)
)
| None ->
console.warn("No group selected to add to archive %s", archiveId)
| None ->
console.warn("ACL has not been downloaded")
let handleDeleteArchive (id: System.Guid) =
deleteArchive id
|> Promise.iter (fun res ->
match res with
| Ok deleted ->
if deleted then
console.info("Archive deleted successfully")
setDeleted true
else
setError (Some "Failed to delete archive")
| Error err ->
console.error("Error deleting archive: %s", err)
setError (Some err)
)
let handleSelectedGroupChange (groupOpt: string option) =
console.debug("Selected group: %s", groupOpt)
setSelectedGroup groupOpt
React.useEffect (
(fun () ->
setLoading true
Archives.Utils.fetchArchive archiveId
|> Promise.iter (fun res ->
match res with
| Ok archive ->
fetchArchiveAcl archive.archiveId
|> Promise.iter (fun res ->
match res with
| Ok acl ->
setArchive (Some archive)
setAcl (Some acl)
| Error err ->
setError (Some err)
setLoading false
)
| Error err ->
console.error("Error fetching archive: %s", err)
setLoading false
setError (Some err)
)
),
[| box archiveId |]
)
React.fragment [
if loading then
Html.h1 "Loading ..."
else
match error with
| Some msg ->
Html.h1 msg
Html.a [
prop.href (Router.format "archives")
prop.text "Return to archives listing"
]
| None ->
match archiveOpt with
| Some archive ->
Html.h1 (sprintf "Archive %s" archive.name)
if deleting then
Html.h2 "Deleting archive ..."
else
Html.none
if deleted then
Html.div [
prop.children [
Html.h2 "Archive successfully deleted"
Html.a [
prop.href (Router.format "archives")
prop.text "Return to archives listing"
]
]
]
else
Html.div [
prop.classes [ "flex-row"; "gap-8" ]
prop.children [
if deleting then
Html.button [
prop.onClick (fun ev ->
setDeleting false
handleDeleteArchive archive.archiveId
)
prop.text "Save"
]
Html.button [
prop.onClick (fun ev ->
setDeleting false
)
prop.text "Cancel"
]
elif editing then
Html.button [
prop.onClick (fun ev ->
setEditing false
)
prop.text "Save"
]
Html.button [
prop.onClick (fun ev ->
setEditing false
)
prop.text "Cancel"
]
else
Html.button [
prop.onClick (fun ev ->
setEditing true
)
prop.text "Edit"
]
Html.button [
prop.onClick (fun ev ->
setDeleting true
)
prop.text "Delete"
]
]
]
if editing then
Html.form [
prop.children [
Html.div [
prop.children [
Html.label [
prop.htmlFor "published-checkbox"
prop.text "Published: "
]
Html.input [
prop.id "published-checkbox"
prop.type' "checkbox"
prop.custom ("checked", archive.isPublished)
]
]
]
]
]
else
Html.none
Archives.InfoSection archive
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.children [
match aclOpt with
| Some acl ->
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 512)
style.minWidth (length.px 512)
style.maxWidth (length.px 768)
]
prop.children [
Html.h2 "Groups"
Html.div [
prop.classes [ "flex-row"; "gap-8" ]
prop.children [
Html.button [
prop.disabled selectedGroup.IsNone
prop.onClick (fun _ ->
handleAddGroupToArchiveAcl archive.archiveId
)
prop.text "Add"
]
GroupSelect handleSelectedGroupChange
]
]
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 256)
style.minWidth (length.px 256)
style.maxWidth (length.px 256)
]
prop.children [
Html.h3 "Archmaester"
if Array.isEmpty acl.groups then
Html.p "No groups in ACL"
else
Html.ul [
prop.children (
acl.groups
|> Array.sort
|> Array.map (fun group ->
Html.li [
prop.key group
prop.children [
Html.a [
prop.href (Router.format("groups", group))
prop.text group
]
]
]
)
)
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 256)
style.minWidth (length.px 256)
style.maxWidth (length.px 512)
]
prop.children [
Html.h3 "OpenFGA"
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.children [
Html.h4 "Groups who's members can view"
GroupFGAList
"archive"
(string archive.archiveId)
"view"
{ Type = "group"; Relation = Some "member" }
{| time = System.DateTime.Now |}
]
]
Html.div [
prop.classes [ "grow" ]
prop.children [
Html.h4 "Groups who's members can exec"
Html.p "With task '*', usage '-1' and current time"
GroupFGAList
"archive"
(string archive.archiveId)
"exec"
{ Type = "group"; Relation = Some "member" }
{|
task = "*"
usage = "-1"
time = System.DateTime.Now
|}
]
]
]
]
]
]
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 512)
style.minWidth (length.px 512)
style.maxWidth (length.px 768)
]
prop.children [
Html.h2 "Owners"
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-8" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.children [
Html.h3 "Archmaester"
if Array.isEmpty acl.owners then
Html.p "No owners in ACL"
else
Html.ul [
prop.children (
acl.owners
|> Array.sort
|> Array.map (fun owner ->
Html.li [
prop.key owner
prop.children [
Html.a [
prop.href (Router.format("users", owner))
prop.text owner
]
]
]
)
)
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.children [
Html.h3 "OpenFGA"
OpenFGA.ArchiveOwnerList.View archiveId
]
]
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 512)
style.minWidth (length.px 512)
style.maxWidth (length.px 768)
]
prop.children [
Html.h2 "Users"
Html.div [
prop.children [
Html.h3 "Archmaester"
if Array.isEmpty acl.users then
Html.p "No users in Archmaester ACL"
else
Html.ul [
prop.children (
acl.users
|> Array.sort
|> Array.map (fun user ->
Html.li [
prop.key user
prop.children [
Html.a [
prop.href (Router.format("users", user))
prop.text user
]
]
]
)
)
]
]
]
Html.div [
prop.children [
Html.h3 "OpenFGA"
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 256)
style.minWidth (length.px 256)
style.maxWidth (length.px 512)
]
prop.children [
Html.h4 "Users with view"
Html.div [
prop.style [
style.maxHeight (length.px 512)
style.overflowY.auto
]
prop.children [
UserFGAList
"archive"
(string archive.archiveId)
"view"
{ Type = "user"; Relation = None }
{| time = System.DateTime.Now |}
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 256)
style.minWidth (length.px 256)
style.maxWidth (length.px 512)
]
prop.children [
Html.h4 "Users with exec"
Html.div [
prop.style [
style.maxHeight (length.px 512)
style.overflowY.auto
]
prop.children [
UserFGAList
"archive"
(string archive.archiveId)
"exec"
{ Type = "user"; Relation = None }
{|
task = "*"
usage = "-1"
time = System.DateTime.Now
|}
]
]
]
]
]
]
]
]
]
]
if Array.isEmpty acl.shares then
Html.none
else
Html.div [
prop.style [ style.flexBasis (length.px 256) ]
prop.children [
Html.h2 "Shares"
Html.ul [
prop.children (
acl.shares
|> Array.sort
|> Array.map (fun share ->
Html.li [
prop.key share
prop.children [
Html.a [
prop.text (string share)
]
]
]
)
)
]
]
]
| None ->
Html.h2 "No ACL found"
SubArchives archiveId
match Utils.tryStr archive.json with
| Some jsonStr ->
let json = JS.JSON.parse jsonStr
Html.div [
prop.style [ style.flexBasis (length.px 512) ]
prop.children [
Html.h2 "Drifters input"
Html.div [
prop.style [
style.maxHeight (length.px 512)
style.overflowY.scroll
]
prop.children [
Html.pre (JS.JSON.stringify(json, space = 4))
]
]
]
]
| None ->
Html.none
]
]
| None ->
Html.h1 "Archive not found"
]

View File

@@ -0,0 +1,252 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Core.JsInterop
open Feliz
open Feliz.Router
module Archives =
[<ReactComponent>]
let private FVCOMList () =
let variant, setVariant = React.useState "*"
let archiveType = Archmaester.Dto.ArchiveType.FromString(sprintf "fvcom:%s:*" variant)
Html.div [
prop.classes [ "archives-list" ]
prop.children [
Html.h2 "FVCOM"
Archives.List(archiveType)
]
]
[<ReactComponent>]
let private DriftersList () =
let searchTerm, setSearchTerm = React.useState ""
let driftersVariant, setDriftersVariant = React.useState "*"
let archiveType = Archmaester.Dto.ArchiveType.FromString(sprintf "drifters:%s:*" driftersVariant)
let handleDriftersVariantChange (ev: Types.Event) =
let newVariant = ev.target?value
setDriftersVariant newVariant
let handleSearchChange (ev: Types.Event) =
let str : string = ev.target?value
console.debug("[DriftersList] Search changed: %s", str)
setSearchTerm str
let dSearch = Utils.debounce 500 handleSearchChange
Html.div [
prop.classes [ "archives-list drifters" ]
prop.children [
Html.h2 "Drifters"
Html.div [
prop.classes [ "flex-row"; "gap-16" ]
prop.style [
style.alignItems.center
]
prop.children [
Html.div [
prop.children [
Html.label [
prop.htmlFor "drifters-search-input"
prop.style [
style.display.block
]
prop.text "Search:"
]
Html.input [
prop.id "drifters-search-input"
prop.type'.text
prop.onChange dSearch
]
]
]
Html.div [
prop.children [
Html.label [
prop.htmlFor "drifter-variant-select"
prop.style [
style.display.block
]
prop.text "Variant:"
]
// accumulation
// accumulation_v2
// downwelling
// lice
// sedimentation
// transport
// virus
// watercontact
Html.select [
prop.id "drifter-variant-select"
prop.name "Drifter variants"
prop.value driftersVariant
prop.onChange handleDriftersVariantChange
prop.children [
Html.option [
prop.value "*"
prop.text "Any"
]
Html.option [
prop.value "transport"
prop.text "Transport"
]
Html.option [
prop.value "virus"
prop.text "Virus"
]
Html.option [
prop.value "lice"
prop.text "Lice"
]
Html.option [
prop.value "sedimentation"
prop.text "Sedimentation"
]
Html.option [
prop.value "watercontact"
prop.text "Watercontact"
]
Html.option [
prop.value "downwelling"
prop.text "Downwelling"
]
Html.option [
prop.value "accumulation"
prop.text "Accumulation"
]
Html.option [
prop.value "accumulation_v2"
prop.text "Accumulation v2"
]
]
]
]
]
]
]
Archives.List(archiveType, searchTerm)
]
]
[<ReactComponent>]
let private StatsList () =
let variant, setVariant = React.useState "*"
let archiveType = Archmaester.Dto.ArchiveType.FromString(sprintf "fvstats:%s:*" variant)
let handleVariantChange (ev: Types.Event) =
let newVariant = ev.target?value
setVariant newVariant
Html.div [
prop.classes [ "archives-list" ]
prop.children [
Html.h2 "Stats"
Html.div [
prop.classes [ "flex-row"; "gap-16" ]
prop.style [
style.alignItems.center
]
prop.children [
Html.div [
prop.children [
Html.label [
prop.htmlFor "stats-search-input"
prop.style [
style.display.block
]
prop.text "Search:"
]
Html.input [
prop.id "stats-search-input"
prop.type'.text
]
]
]
Html.div [
prop.children [
Html.label [
prop.htmlFor "stats-variant-select"
prop.style [
style.display.block
]
prop.text "Variant:"
]
Html.select [
prop.id "stats-variant-select"
prop.name "Stats variants"
prop.value variant
prop.onChange handleVariantChange
prop.children [
Html.option [
prop.value "*"
prop.text "Any"
]
Html.option [
prop.value "salt"
prop.text "Salt"
]
Html.option [
prop.value "temp"
prop.text "Temperature"
]
Html.option [
prop.value "uv"
prop.text "UV"
]
]
]
]
]
]
]
Archives.List(archiveType)
]
]
[<ReactComponent>]
let View () =
React.fragment [
Html.h1 "Archives"
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.style [
style.alignItems.flexStart
]
prop.children [
FVCOMList ()
DriftersList ()
StatsList ()
]
]
]

View File

@@ -0,0 +1,72 @@
namespace Oceanbox.Codex
open Feliz
type Archives =
[<ReactComponent>]
static member InfoSection(archive: Archmaester.Dto.ArchiveProps) =
let archiveLength : System.TimeSpan = archive.endTime - archive.startTime
let focalPoint : float array =
let x, y = archive.focalPoint
[| float x; float y |]
Html.section [
prop.classes [ "flex-row"; "flex-wrap"; "gap-16" ]
prop.children [
Html.ul [
prop.children [
Html.li [
prop.text (sprintf "Description: %s" archive.description)
]
Html.li [
prop.text (sprintf "Archive type: %s" (string archive.archiveType))
]
Html.li [
prop.text (sprintf "Projection: %s" archive.projection)
]
Html.li [
prop.text (sprintf "Frequency: %d" archive.freq)
]
Html.li [
prop.text (sprintf "Frames: %d" archive.frames)
]
Html.li [
prop.text (sprintf "Created: %s" (archive.created.ToLongDateString()))
]
Html.li [
prop.text (sprintf "Start time: %s" (archive.startTime.ToLongDateString()))
]
Html.li [
prop.text (sprintf "End time: %s" (archive.endTime.ToLongDateString()))
]
Html.li [
prop.text (sprintf "Length: %d days %d hours" archiveLength.Days archiveLength.Hours)
]
Html.li [
prop.text (sprintf "Owner: %s" archive.owner)
]
Html.li [
prop.text (sprintf "Expires: %s" (archive.expires |> Option.map string |> Option.defaultValue ""))
]
Html.li [
prop.text (sprintf "Publised: %b" archive.isPublished)
]
Html.li [
prop.text (sprintf "Public: %b" archive.isPublic)
]
Html.li [
prop.text (sprintf "Location: %s" "tos")
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 256)
style.minHeight (length.px 256)
]
prop.children (Map.View focalPoint [||])
]
]
]

View File

@@ -0,0 +1,199 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Core.JsInterop
open Feliz
open Feliz.Router
module private Utils =
let fetchAllArchives
(page: int)
(rowsPerPage: int)
(searchTerm: string)
(archiveType: Archmaester.Dto.ArchiveType)
: JS.Promise<Result<Archmaester.Dto.ArchiveProps array, string>> =
promise {
let filter: Archmaester.Dto.ArchiveFilter = {
id = None
searchTerm = Utils.tryStr searchTerm
archiveType = Some archiveType
owner = None
user = None
groups = None
}
try
console.debug ("Fetching %s archives with filter %o", string archiveType, filter)
let! archives = Remoting.adminApi.getArchives page rowsPerPage filter |> Async.StartAsPromise
return archives
with ex ->
console.error ("Error fetching archives: %s", ex.Message)
return Error "Error fetching archives"
}
type Archives =
[<ReactComponent>]
static member List
(archiveType: Archmaester.Dto.ArchiveType, ?searchTerm: string, ?onSelect: Archmaester.Dto.ArchiveProps -> unit)
=
let searchTerm = defaultArg searchTerm ""
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let count, setCount = React.useState 0
let page, setPage = React.useState 0
let rowsPerPage, setRowsPerPage = React.useState 25
let archives, setArchives = React.useState<Archmaester.Dto.ArchiveProps array> [||]
let fromItem = page * rowsPerPage
let toItem = page * rowsPerPage + archives.Length
let handlePrevClick (ev: Types.MouseEvent) =
let newPage = if page - 1 <= 0 then 0 else page - 1
setPage newPage
let handleNextClick (ev: Types.MouseEvent) =
setPage (page + 1)
let handleSelectChange (ev: Types.Event) =
console.debug ("[Archives] Select changed %o", ev)
let newRowCount = ev.target?value
setRowsPerPage newRowCount
let handleSelectArchive (onSelect: Archmaester.Dto.ArchiveProps -> unit) archive (ev: Types.Event) =
onSelect archive
React.useEffect (
(fun () ->
setPage 0
),
[| box archiveType |]
)
React.useEffect (
(fun () ->
setError None
Archives.Utils.fetchArchiveCount (Utils.tryStr searchTerm) archiveType
|> Promise.iter (fun res ->
match res with
| Ok count -> setCount count
| Error err ->
console.error ("[Archives] Error fetching archives: %s", err)
setError (Some "Error fetching archive count")
)
),
[| box archiveType; box searchTerm |]
)
React.useEffect (
(fun () ->
setLoading true
Utils.fetchAllArchives page rowsPerPage searchTerm archiveType
|> Promise.iter (fun res ->
match res with
| Ok archives ->
setArchives archives
setError None
| Error err -> setError (Some "Error fetching archives")
setLoading false
)
),
[| box archiveType; box page; box rowsPerPage; box searchTerm |]
)
Html.div [
prop.children [
match error with
| Some msg -> Html.p msg
| None ->
if Array.isEmpty archives then
Html.p "No archives"
else
Html.ul [
prop.children (
archives
|> Array.sortBy _.name
|> Array.map (fun archive ->
Html.li [
prop.key archive.archiveId
prop.title archive.name
prop.children [
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
match onSelect with
| Some onSelect ->
Html.button [
prop.onClick (handleSelectArchive onSelect archive)
prop.text "Select"
]
| None -> ()
Html.a [
prop.href (Router.format ("archives", string archive.archiveId))
prop.children [
Html.span [
prop.classes [ "text-overflow" ]
prop.text archive.name
]
]
]
]
]
]
]
)
)
]
Html.div [
prop.classes [ "flex-row-center"; "gap-16" ]
prop.children [
Html.div [
prop.classes [ "flex-row-center"; "gap-16"; "grow" ]
prop.children [
if loading then
Html.span "Loading ..."
]
]
Html.div [
prop.classes [ "flex-row-center"; "gap-16" ]
prop.children [
Html.button [
prop.disabled ((page = 0))
prop.onClick handlePrevClick
prop.text "prev"
]
Html.div [
prop.children [ Html.span $"Page {page + 1}: {fromItem}-{toItem} of {count}" ]
]
Html.button [
prop.disabled (toItem >= count)
prop.onClick handleNextClick
prop.text "next"
]
Html.select [
prop.name "rows-per-page"
prop.value rowsPerPage
prop.onChange handleSelectChange
prop.children [
Html.option [ prop.value 10; prop.text 10 ]
Html.option [ prop.value 25; prop.text 25 ]
Html.option [ prop.value 50; prop.text 50 ]
]
]
]
]
]
]
]
]

View File

@@ -0,0 +1,192 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Core.JsInterop
open Feliz
module private Utils =
let fetch () : JS.Promise<Archmaester.Dto.ArchiveType array> =
promise {
let! res = Remoting.adminApi.getArchiveTypes () |> Async.StartAsPromise
return res
}
type Archives =
[<ReactComponent>]
static member TypeSelects(selectedType: Archmaester.Dto.ArchiveType, onChange: Archmaester.Dto.ArchiveType -> unit) =
let k, v, f = selectedType.ToDbType()
let kind, setKind = React.useState k
let variant, setVariant = React.useState v
let format, setFormat = React.useState f
let types, setTypes = React.useState<Archmaester.Dto.ArchiveType array> [||]
let kinds = [|
Archmaester.Dto.ArchiveType.Any
Archmaester.Dto.ArchiveType.Fvcom (Archmaester.Dto.FvcomVariant.Any, Archmaester.Dto.FvcomFormat.Any)
Archmaester.Dto.ArchiveType.Drifters (Archmaester.Dto.DriftersVariant.Any, Archmaester.Dto.DriftersFormat.Any)
Archmaester.Dto.ArchiveType.Atmo (Archmaester.Dto.AtmoVariant.Any, Archmaester.Dto.AtmoFormat.Any)
Archmaester.Dto.ArchiveType.FvStats (Archmaester.Dto.FvStatsVariant.Any, Archmaester.Dto.FvStatsFormat.Any)
|]
let variants =
types
|> Array.distinctBy (fun t ->
let k, v, _ = t.ToDbType()
if kind = "*" || k = kind then
v
else
"*"
)
let formats =
types
|> Array.distinctBy (fun t ->
let _, v, f = t.ToDbType()
let currentFormat = v = "*" || v = variant
if currentFormat then
f
else
"*"
)
let handleKindChange (ev: Types.Event) =
let newKind = ev.target?value
let type' = Archmaester.Dto.ArchiveType.FromDbType(newKind, variant, format)
setKind newKind
setVariant "*"
setFormat "*"
onChange type'
let handleVariantChange (ev: Types.Event) =
let newVariant = ev.target?value
let type' = Archmaester.Dto.ArchiveType.FromDbType(kind, newVariant, format)
setVariant newVariant
setFormat "*"
onChange type'
let handleFormatChange (ev: Types.Event) =
let newFormat = ev.target?value
let type' = Archmaester.Dto.ArchiveType.FromDbType(kind, variant, newFormat)
setFormat newFormat
onChange type'
React.useEffect (
(fun () ->
console.debug("[Archives] Fetching archive types")
Utils.fetch ()
|> Promise.iter (fun res ->
console.debug("[Archives] Fetched types: %o", res)
setTypes res
)
),
[||]
)
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.div [
prop.style [
style.flexBasis (length.ch 12)
]
prop.children [
Html.label [
prop.htmlFor "archives-kind-select"
prop.style [
style.display.block
]
prop.text "Kind:"
]
Html.select [
prop.id "archives-kind-select"
prop.style [
style.minWidth (length.ch 12)
]
prop.value kind
prop.onChange handleKindChange
prop.children (
kinds
|> Array.map (fun type' ->
let k, _, _ = type'.ToDbType()
Html.option [
prop.value k
prop.text type'.KindLabel
]
)
)
]
]
]
Html.div [
prop.style [
style.flexBasis (length.ch 12)
]
prop.children [
Html.label [
prop.htmlFor "archives-variant-select"
prop.style [
style.display.block
]
prop.text "Variant:"
]
Html.select [
prop.id "archives-variant-select"
prop.disabled ((kind = "*"))
prop.style [
style.minWidth (length.ch 12)
]
prop.value variant
prop.onChange handleVariantChange
prop.children (
variants
|> Array.map (fun type' ->
let _, v, _ = type'.ToDbType()
Html.option [
prop.value v
prop.text type'.VariantLabel
]
)
)
]
]
]
Html.div [
prop.style [
style.flexBasis (length.ch 12)
]
prop.children [
Html.label [
prop.htmlFor "archives-format-select"
prop.style [
style.display.block
]
prop.text "Format:"
]
Html.select [
prop.id "archives-format-select"
prop.disabled ((variant = "*"))
prop.style [
style.minWidth (length.ch 12)
]
prop.value format
prop.onChange handleFormatChange
prop.children (
formats
|> Array.map (fun type' ->
let _, _, f = type'.ToDbType()
Html.option [
prop.value f
prop.text type'.FormatLabel
]
)
)
]
]
]
]
]

View File

@@ -0,0 +1,73 @@
namespace Oceanbox.Codex.Archives
module Utils =
open Browser
open Fable.Core
open Fable.Core.JsInterop
open Fable.Remoting.Client
open Oceanbox.Codex
open Oceanbox.Codex.Types
let archiveName (archive: Archive) : string =
let name = ResizeArray<string> [||]
do name.Add archive.Props.name
let permissions =
[|
archive.CanView, "v"
archive.CanExec, "e"
|]
|> Array.choose (fun (hasPerm, code) ->
if hasPerm then
Some code
else
None
)
if permissions.Length > 0 then
name.Add (sprintf " [%s]" (String.concat "," permissions))
name?join("")
let extractFgaArchiveId (str: string) =
let split = str.Split ':'
match split with
| [| "archive"; str |] -> Some (System.Guid str)
| _ -> None
let fetchArchive (id: System.Guid) : JS.Promise<Result<Archmaester.Dto.ArchiveProps, string>> =
promise {
try
let! archive = Remoting.adminApi.getArchive id |> Async.StartAsPromise
return archive
with
| :? ProxyRequestException as e ->
let proxyError : Types.ProxyError = JS.JSON.parse e.ResponseText |> unbox
return Error (sprintf "Remoting.adminApi.getArchive: %s" proxyError.error.errorMsg)
| e ->
console.error("Error fetching archive: %o", e)
return Error "Error fetching archive"
}
let fetchArchiveCount searchTerm (archiveType: Archmaester.Dto.ArchiveType) : JS.Promise<Result<int, string>> =
promise {
let filter : Archmaester.Dto.ArchiveFilter = {
id = None
searchTerm = searchTerm
archiveType = Some archiveType
owner = None
user = None
groups = None
}
try
console.debug("Fetching %s archive count with filter %o", string archiveType, filter)
let! archives = Remoting.adminApi.getArchiveCount filter |> Async.StartAsPromise
return archives
with ex ->
console.error("Error fetching archive count: %s", ex.Message)
return Error "Error fetching archive count"
}

View File

@@ -0,0 +1,70 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
open Feliz.Router
module ArchivesList =
let private fetchArchives (modelAreaId: System.Guid) (typeStr: string) : JS.Promise<Result<Archmaester.Dto.ArchiveProps array, string>> =
promise {
let archiveType = Archmaester.Dto.ArchiveType.FromString typeStr
try
console.debug("Fetching model area %s archives", string modelAreaId)
let! res = Remoting.inventoryApi.getModelAreaArchives(modelAreaId, archiveType) |> Async.StartAsPromise
return res
with ex ->
console.error("Error fetching archives: %s", ex.Message)
return Error "Error fetching archives"
}
[<ReactComponent>]
let List (modelAreaId: System.Guid) (typeStr: string) =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let archives, setArchives = React.useState<Archmaester.Dto.ArchiveProps array> [||]
React.useEffect (
(fun () ->
setLoading true
fetchArchives modelAreaId typeStr
|> Promise.iter (fun res ->
match res with
| Ok archives -> setArchives archives
| Error err -> setError (Some "Error fetching archives")
setLoading false
)
),
[| box modelAreaId |]
)
if loading then
Html.p "Loading ..."
else
match error with
| Some msg ->
Html.p msg
| None ->
if Array.isEmpty archives then
Html.p "No archives"
else
Html.ul [
prop.children (
archives
|> Array.sort
|> Array.map (fun archive ->
Html.li [
prop.key archive.archiveId
prop.children [
Html.a [
prop.href (Router.format("archives", string archive.archiveId))
prop.text archive.name
]
]
]
)
)
]

View File

@@ -0,0 +1,62 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Oceanbox</RootNamespace>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="../Shared/Remoting.fs" />
<Compile Include="Types.fs" />
<Compile Include="Extensions.fs" />
<Compile Include="Remoting.fs" />
<Compile Include="Map.fs" />
<Compile Include="OpenFGA/Types.fs" />
<Compile Include="OpenFGA/useObjects.fs" />
<Compile Include="OpenFGA/useUsers.fs" />
<Compile Include="OpenFGA/useReadTuples.fs" />
<Compile Include="OpenFGA/Checkbox.fs" />
<Compile Include="OpenFGA/ArchiveOwnerList.fs" />
<Compile Include="Groups/Utils.fs" />
<Compile Include="Groups/useGroups.fs" />
<Compile Include="Groups/List.fs" />
<Compile Include="Groups/ViewForm.fs" />
<Compile Include="Groups/ExecForm.fs" />
<Compile Include="Archives/Utils.fs" />
<Compile Include="Archives/TypeSelect.fs" />
<Compile Include="Archives/List.fs" />
<Compile Include="Archives/InfoSection.fs" />
<Compile Include="Drifters.fs" />
<Compile Include="Organizations.fs" />
<Compile Include="Organization.fs" />
<Compile Include="User.fs" />
<Compile Include="Groups.fs" />
<Compile Include="GroupArchiveAddForm.fs" />
<Compile Include="GroupArchive.fs" />
<Compile Include="Group.fs" />
<Compile Include="ArchivesList.fs" />
<Compile Include="Archives.fs" />
<Compile Include="Archive.fs" />
<Compile Include="ModelAreas.fs" />
<Compile Include="ModelArea.fs" />
<Compile Include="Index.fs" />
<Compile Include="Components.fs" />
<Compile Include="Main.fs" />
</ItemGroup>
<ItemGroup>
<None Include="index.html" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.OpenLayers" Version="2.20.0" />
<PackageReference Include="Fable.Promise" Version="3.2.0" />
<PackageReference Include="Fable.Remoting.Client" Version="7.35.0" />
<PackageReference Include="Feliz" Version="2.9.0" />
<PackageReference Include="Feliz.Router" Version="4.0.0" />
<PackageReference Include="Feliz.UseElmish" Version="2.5.0" />
<PackageReference Include="FsToolkit.ErrorHandling" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Interfaces\Archmaester\Archmaester.Api.fsproj" />
<ProjectReference Include="..\..\..\Interfaces\Atlantis\Atlantis.Api.fsproj" />
<ProjectReference Include="..\..\..\Atlantis\src\Client\Lib\Lib.fsproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,57 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
open Feliz.Router
type Components =
/// <summary>
/// A React component that uses Feliz.Router to determine what to show based on the current URL
/// </summary>
[<ReactComponent>]
static member Router() =
let authed, setAuthed = React.useState false
let loading, setLoading = React.useState true
let currentUrl, updateUrl = React.useState(Router.currentUrl())
React.useEffect (
(fun () ->
Remoting.authApi.IsAuthenticated
|> Async.StartAsPromise
|> Promise.iter (fun isAuth ->
setLoading false
if not isAuth then
window.location.href <- "/signin"
else
setAuthed true
)
),
[||]
)
React.router [
router.onUrlChanged updateUrl
router.children [
if loading then
Html.h1 "Loading..."
elif not authed then
Html.h1 "Redirecting to sign-in..."
else
match currentUrl with
| [ ] -> Index.View()
| [ "archives" ] -> Archives.View ()
| [ "archives"; archive ] -> Archive.View (System.Guid archive)
| [ "model-areas" ] -> ModelAreas.List ()
| [ "model-areas"; id ] -> ModelArea.View (System.Guid id)
| [ "groups" ] -> Groups.View ()
| [ "groups"; group ] -> Group.View group
| [ "groups"; group; "archives"; id ] -> GroupArchive.View group (System.Guid id)
| [ "groups"; group; "users"; user ] -> User.View user
| [ "users"; user ] -> User.View user
| [ "organizations" ] -> Organizations.List ()
| [ "organizations"; org ] -> Organization.View org
| otherwise -> Html.h1 "Not found"
]
]

View File

@@ -0,0 +1,66 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
open Feliz.Router
open Oceanbox.Codex.Types
[<Erase>]
type Drifters =
[<ReactComponent>]
static member List (archives: Archive array) =
let inner (drifterKind: Archmaester.Dto.DriftersVariant) (innerArchives: Archive array) =
let label = Archmaester.Dto.DriftersVariant.Label drifterKind
let filtered =
innerArchives
|> Array.filter (fun archive ->
match archive.Props.archiveType with
| Archmaester.Dto.Drifters(variant, _format) -> variant = drifterKind
| _ -> false
)
Html.li [
prop.children [
Html.text (sprintf "%s (%d)" label filtered.Length)
Html.ul [
prop.children (
filtered
|> Array.sortBy _.Props.name
|> Array.map (fun archive ->
let text = Archives.Utils.archiveName archive
Html.li [
prop.key archive.Props.archiveId
prop.children [
Html.a [
prop.href (Router.format ("archives", string archive.Props.archiveId))
prop.text text
]
]
]
)
)
]
]
]
Html.li [
prop.children [
Html.text (sprintf "Drifters (%d)" archives.Length)
Html.ul [
prop.children [
inner Archmaester.Dto.Accumulation archives
inner Archmaester.Dto.AccumulationV2 archives
inner Archmaester.Dto.Downwelling archives
// inner Archmaester.Dto.DriftersVariant.Any archives
inner Archmaester.Dto.Lice archives
inner Archmaester.Dto.Sedimentation archives
inner Archmaester.Dto.Transport archives
inner Archmaester.Dto.Virus archives
]
]
]
]

View File

@@ -0,0 +1,9 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
open Feliz.Router
module Drifters

View File

@@ -0,0 +1,24 @@
[<AutoOpen>]
module Extensions
open System
open Fable.Core
open Fable.Core.JsInterop
[<RequireQualifiedAccess>]
module StaticFile =
/// Function that imports a static file by it's relative path.
let inline import (path: string) : string = importDefault<string> path
/// Stylesheet API
/// let private stylesheet = Stylesheet.load "./fancy.module.css"
/// stylesheet.["fancy-class-name"] which returns a string
module Stylesheet =
type IStylesheet =
[<Emit "$0[$1]">]
abstract Item : className:string -> string
/// Loads a CSS module and makes the classes within available
let inline load (path: string) = importDefault<IStylesheet> path

View File

@@ -0,0 +1,473 @@
namespace Oceanbox.Codex
module Group =
open Browser
open Fable.Core
open FsToolkit.ErrorHandling
open Oceanbox.Codex.Types
let private fetchUsers (group: string) : Async<Result<string array, string>> =
async {
try
let! users = Remoting.adminApi.getGroupUsers group
return Ok users
with e ->
console.error("Error fetching Archmaester users: %s", e.Message)
return Error (sprintf "Error fetching users for group %s" group)
}
let private fetchArchmaesterArchives (group: string) : JS.Promise<Result<Archmaester.Dto.ArchiveProps array, string>> =
promise {
let filter : Archmaester.Dto.ArchiveFilter = {
id = None
searchTerm = None
archiveType = None
owner = None
user = None
groups = Some [| group |]
}
let! result = Remoting.adminApi.getArchives 0 -1 filter |> Async.StartAsPromise
return result
}
module private Elmish =
open Elmish
let fetchArchiveProps (group: string) : JS.Promise<Types.Archive array> =
promise {
let fgaUser = sprintf "group:%s#member" (Groups.Utils.canonicalizeName group)
let! archivesRes = fetchArchmaesterArchives group
let! viewsRes =
OpenFGA.fetchObjects(fgaUser, "view", "archive", context = {| time = System.DateTime.Now |})
let! execsRes =
OpenFGA.fetchObjects(fgaUser, "exec", "archive", context = {| task = "*"; usage = -1; time = System.DateTime.Now |})
let res =
result {
// TODO: Create specific exception
let! props = archivesRes |> Result.mapError System.Exception
let! views = viewsRes |> Result.mapError System.Exception
let! execs = execsRes |> Result.mapError System.Exception
let viewArchiveIds = views |> Array.choose Archives.Utils.extractFgaArchiveId
let execArchiveIds = execs |> Array.choose Archives.Utils.extractFgaArchiveId
let archives =
props
|> Array.map (fun prop -> {
Props = prop
CanView = viewArchiveIds |> Array.contains prop.archiveId
CanExec = execArchiveIds |> Array.contains prop.archiveId
})
return archives
}
match res with
| Ok archives ->
return archives
| Error ex ->
raise ex
return [||]
}
type Msg =
| FetchArchives of string
| SetArchiveAdding of bool
| SetArchives of Archive array
| SetError of string option
| HandleExn of exn
type Model = {
Adding: bool
Archives: Archive array
Error: string option
Loading: bool
}
let init (group: string) : Model * Cmd<Msg> =
let model = {
Adding = false
Archives = [||]
Error = None
Loading = true
}
model, Cmd.ofMsg (FetchArchives group)
let update msg model =
match msg with
| FetchArchives group ->
model, Cmd.OfPromise.either fetchArchiveProps group SetArchives HandleExn
| HandleExn ex ->
let msg =
match ex with
// | :? System.Exception as e -> Some "Something went wrong. Please try again later."
| _ -> Some "Something went wrong. Please try again later."
{ model with Model.Error = msg }, Cmd.none
| SetArchives archives -> { model with Archives = archives; Loading = false }, Cmd.none
| SetError msg -> { model with Error = msg; Loading = false }, Cmd.none
| SetArchiveAdding adding -> { model with Adding = adding }, Cmd.none
open Feliz
open Feliz.Router
open Feliz.UseElmish
[<ReactComponent>]
let ArchmaesterUserList key (group: string) =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let users, setUsers = React.useState<string array> [||]
React.useEffect (
(fun () ->
console.debug("Mounting Archivist user list %s", group)
setLoading true
fetchUsers group
|> Async.StartAsPromise
|> Promise.iter (fun res ->
match res with
| Ok users ->
console.info("[Archmaester] Got users from group %s: %o", group, users)
setUsers users
| Error msg ->
setError (Some msg)
setLoading false
)
),
[| box group |]
)
Html.div [
prop.children [
Html.h4 "Archmaester"
if loading then
Html.p "Loading archivist users ..."
else
match error with
| Some msg ->
Html.p msg
| None ->
if Array.isEmpty users then
Html.p "No users"
else
Html.ul [
prop.children (
users
|> Array.sort
|> Array.map (fun user ->
Html.li [
prop.children [
Html.a [
prop.href (Router.format("groups", group, "users", user))
prop.text user
]
]
]
)
)
]
]
]
[<ReactComponent>]
let private OpenFGAUserList key (group: string) =
let groupName = Groups.Utils.canonicalizeName group
let users = OpenFGA.useUsers("group", groupName, "member", { Type = "user"; Relation = None })
Html.div [
prop.children [
Html.h4 "OpenFGA"
if users.Loading then
Html.p "Loading ..."
else
match users.Error with
| Some msg -> Html.p msg
| None ->
if Array.isEmpty users.Objects then
Html.p "No users"
else
Html.ul [
prop.children (
users.Objects
|> Array.sort
|> Array.map (fun user ->
Html.li [
prop.children [
Html.a [
prop.href (Router.format("groups", group, "users", user))
prop.text user
]
]
]
)
)
]
]
]
[<ReactComponent>]
let private DriftersList (group: string) (archives: Archive array) =
let inner (drifterKind: Archmaester.Dto.DriftersVariant) (innerArchives: Archive array) =
let label = Archmaester.Dto.DriftersVariant.Label drifterKind
let filtered =
innerArchives
|> Array.filter (fun archive ->
match archive.Props.archiveType with
| Archmaester.Dto.Drifters(variant, _format) -> variant = drifterKind
| _ -> false
)
Html.li [
prop.children [
Html.text (sprintf "%s (%d)" label filtered.Length)
Html.ul [
prop.children (
filtered
|> Array.sortBy _.Props.name
|> Array.map (fun archive ->
let href = Router.format ("groups", group, "archives", string archive.Props.archiveId)
let text = Archives.Utils.archiveName archive
Html.li [
prop.key archive.Props.archiveId
prop.children [
Html.a [
prop.href href
prop.text text
]
]
]
)
)
]
]
]
// test
Html.li [
prop.children [
Html.text (sprintf "Drifters (%d)" archives.Length)
Html.ul [
prop.children [
inner Archmaester.Dto.Accumulation archives
inner Archmaester.Dto.AccumulationV2 archives
inner Archmaester.Dto.Downwelling archives
// inner Archmaester.Dto.DriftersVariant.Any archives
inner Archmaester.Dto.Lice archives
inner Archmaester.Dto.Sedimentation archives
inner Archmaester.Dto.Transport archives
inner Archmaester.Dto.Virus archives
]
]
]
]
[<ReactComponent>]
let ArchiveList (group: string) (archives: Archive array) =
let drifters, rest =
archives
|> Array.partition (fun archive ->
match archive.Props.archiveType with
| Archmaester.Dto.Drifters _ -> true
| _ -> false
)
let stats, rest =
rest
|> Array.partition (fun archive ->
match archive.Props.archiveType with
| Archmaester.Dto.ArchiveType.FvStats _ -> true
| _ -> false
)
Html.ul [
prop.children [
// TODO(simkir): Make the tree collapsable, so use FluentUI
Html.li [
prop.children [
Html.text "Archives"
Html.ul [
prop.children (
rest
|> Array.sortBy _.Props.name
|> Array.map (fun archive ->
let text = Archives.Utils.archiveName archive
Html.li [
prop.children [
Html.a [
prop.href (Router.format("groups", group, "archives", string archive.Props.archiveId))
prop.text text
]
]
]
)
)
]
]
]
DriftersList group drifters
Html.li [
prop.children [
Html.text "Stats"
Html.ul [
prop.children (
stats
|> Array.sortBy _.Props.name
|> Array.map (fun archive ->
let text = Archives.Utils.archiveName archive
Html.li [
prop.children [
Html.a [
prop.href (Router.format("groups", group, "archives", string archive.Props.archiveId))
prop.text text
]
]
]
)
)
]
]
]
]
]
[<ReactComponent>]
let private UserAddForm key (group: string) onAdd =
let adding, setAdding = React.useState false
let inputRef = React.useRef<Types.HTMLInputElement option> None
let handleAddUser () =
match inputRef.current with
| Some input ->
let email = input.value
Remoting.adminApi.addUsers { Group = Groups.Utils.canonicalizeName group; Users = [| email |] }
|> Async.StartAsPromise
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("Added user %s to group %s", email, group)
setAdding false
onAdd ()
| Error msg ->
console.error("Error adding user %s to group %s: %s", email, group, msg)
)
| None ->
console.error("Trying to add user with no input")
Html.div [
prop.classes [ "flex-column"; "gap-8"; ]
prop.children [
Html.div [
prop.classes [ "flex-row"; "gap-8"; ]
prop.children [
if adding then
Html.button [
prop.onClick (fun _ -> handleAddUser ())
prop.text "Save"
]
Html.button [
prop.onClick (fun _ -> setAdding false)
prop.text "Cancel"
]
else
Html.button [
prop.onClick (fun _ -> setAdding true)
prop.text "Add"
]
]
]
if adding then
Html.form [
prop.children [
Html.input [
prop.type'.email
prop.ref inputRef
prop.placeholder "Email"
]
]
]
]
]
[<ReactComponent>]
let View (group: string) =
let model, dispatch = React.useElmish(Elmish.init group, Elmish.update, [||])
// NOTE(simkir): Hack state to make the list re-fetch
let key, setKey = React.useState 0
let handleUserAdd () =
setKey (key + 1)
Html.main [
Html.h1 (sprintf "Group %s" group)
Html.div [
prop.children [
Html.h2 "Organization"
Html.p "TODO"
]
]
Html.div [
prop.classes [ "flex-row"; "flex-wrap" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.children [
Html.h2 "Users"
Html.h3 "Admins"
Html.p "TODO"
Html.h3 "Members"
UserAddForm "group-add-form" group handleUserAdd
ArchmaesterUserList $"archmaester-user-list-{key}" group
OpenFGAUserList $"openfga-user-list-{key}" group
]
]
Html.div [
prop.classes [ "grow-2" ]
prop.children [
Html.h2 "Archives"
GroupArchiveAddForm.View "archive-add-form" ignore group
Html.div [
prop.style [
style.marginTop (length.px 16)
style.marginBottom (length.px 16)
style.borderBottom(length.px 1, borderStyle.solid, color.gainsBoro)
]
]
Html.div [
prop.classes [ "flex-row"; "grow" ]
prop.children [
if model.Loading then
Html.span "Loading ..."
else
ArchiveList group model.Archives
]
]
]
]
]
]
]

View File

@@ -0,0 +1,262 @@
namespace Oceanbox.Codex
module GroupArchive =
open Browser
open Fable.Core
open Feliz
open Feliz.Router
[<ReactComponent>]
let private DeleteRelationButton onDelete (tuple: Remoting.Tuple) =
let handleDelete (ev: Types.Event) =
Remoting.openFgaApi.Delete tuple
|> Async.StartAsPromise
|> Promise.iter (fun res ->
match res with
| Ok deleted ->
if deleted then
console.log ("[Group] Deleted relation tuple %o", tuple)
onDelete tuple
else
// TODO: Should probably just return unit and error if not deleted
console.warn ("[Group] Tuple was not deleted: %o", tuple)
| Error err -> console.error ("[Group] Error deleting tuple: %s\n%o", err, tuple)
)
Html.button [ prop.onClick handleDelete; prop.text "Delete" ]
[<ReactComponent>]
let private ViewTerm group archiveId (onDelete: Remoting.Tuple -> unit) (viewTerm: Remoting.ViewTerm) =
let tuple =
Remoting.Tuple.delete (
user = Groups.Utils.fgaMember group,
relation = "view",
object = sprintf "archive:%O" archiveId
)
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 320) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [ prop.classes [ "grow" ]; prop.children [ Html.b "View Term" ] ]
DeleteRelationButton onDelete tuple
]
]
Html.div [
prop.classes [ "ml-16" ]
prop.children [
Html.div (sprintf "Start time: %s" (Intl.shortDateTime viewTerm.StartTime))
Html.div (sprintf "End time: %s" (Intl.shortDateTime viewTerm.EndTime))
]
]
]
]
[<ReactComponent>]
let private ExecTicket (group: string) (archiveId: System.Guid) onDelete (ticket: Remoting.ExecTicket) =
let tuple =
Remoting.Tuple.delete (
user = Groups.Utils.fgaMember group,
relation = "exec",
object = sprintf "archive:%O" archiveId
)
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 320) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [ prop.classes [ "grow" ]; prop.children [ Html.b "Exec Ticket" ] ]
DeleteRelationButton onDelete tuple
]
]
Html.div [
prop.classes [ "ml-16" ]
prop.children [
Html.div (sprintf "Start time: %s" (Intl.shortDateTime ticket.StartTime))
Html.div (sprintf "End time: %s" (Intl.shortDateTime ticket.EndTime))
Html.div (sprintf "Quota: %.1f" ticket.Quota)
Html.div [
prop.children [
Html.span "Tasks:"
Html.ul [
prop.children (ticket.Tasks |> Array.map (fun task -> Html.li task))
]
]
]
]
]
]
]
[<ReactComponent>]
let private PermissionForm (permissions: OpenFGA.Types.ArchiveRelation array) (group: string) =
let adding, setAdding = React.useState false
let handleAddClick (ev: Types.Event) = setAdding true
let handleCancelClick (ev: Types.Event) = setAdding false
let hasViewTerm = permissions |> Array.exists _.IsViewTerm
let hasExecTicket = permissions |> Array.exists _.IsExecTicket
React.fragment [
Html.div [
prop.classes [
"flex-row-center"
"gap-8"
]
prop.children [
if adding then
Html.button [ prop.onClick handleAddClick; prop.text "Save" ]
Html.button [
prop.onClick handleCancelClick
prop.text "Cancel"
]
else
Html.button [ prop.onClick handleAddClick; prop.text "Add" ]
]
]
if adding then
Html.div [
prop.id "group-archive-exec-form"
prop.classes [
"flex-row"
"gap-32"
]
prop.children [
if not hasViewTerm then
Html.div [
prop.children [
Html.b "View"
Groups.ViewForm (Remoting.ViewTerm.empty, ignore)
]
]
if not hasExecTicket then
Html.div [
prop.classes [
"flex-column"
"gap-8"
"shadow"
"brad-8"
"m-8"
"p-16"
]
prop.style [
style.flexBasis (length.px 512)
]
prop.children [
Html.b "Exec"
Groups.ExecForm (Remoting.ExecTicket.empty, ignore)
]
]
]
]
]
[<ReactComponent>]
let View (group: string) (archiveId: System.Guid) =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let archiveOpt, setArchive =
React.useState<Archmaester.Dto.ArchiveProps option> None
let fgaUser = Groups.Utils.fgaMember group
let tuples =
OpenFGA.useReadTuples (fgaUser, object = sprintf "archive:%O" archiveId)
let relations: OpenFGA.Types.ArchiveRelation array =
tuples.Tuples
|> Array.choose (fun tuple -> tuple.Condition |> Option.bind OpenFGA.Types.ArchiveRelation.tryOfCondition)
let handlePermissionDelete (tuple: Remoting.Tuple) =
tuples.Tuples
|> Array.filter (fun existing -> existing.Relation <> tuple.Relation)
|> tuples.SetTuples
React.useEffect (
(fun () ->
setLoading true
Archives.Utils.fetchArchive archiveId
|> Promise.iter (fun res ->
match res with
| Ok archive -> setArchive (Some archive)
| Error err ->
console.error ("Error fetching archive: %s", err)
setError (Some err)
setLoading false
)
),
[| box archiveId |]
)
Html.main [
if loading then
Html.h1 "Loading ..."
else
match archiveOpt with
| Some archive ->
Html.h1 [
prop.children [
Html.text "Group "
Html.a [ prop.href (Router.format ("groups", group)); prop.text group ]
Html.text " / "
Html.text "Archive "
Html.a [
prop.href (Router.format ("archives", string archive.archiveId))
prop.text archive.name
]
]
]
Html.div [ prop.children [ Html.button [ prop.text "Remove" ] ] ]
Archives.InfoSection archive
Html.section [
prop.children [
Html.h2 "Permissions"
if not tuples.Loading then
Html.div [
prop.children [ PermissionForm relations group ]
]
Html.div [
prop.classes [ "mtb-16" ]
prop.style [
style.borderBottom (length.px 1, borderStyle.solid, color.gainsBoro)
]
]
if Array.isEmpty relations then
Html.p "No permissions"
else
Html.div [
prop.classes [ "flex-row"; "gap-32" ]
prop.children (
relations
|> Array.map (
function
| OpenFGA.Types.ArchiveRelation.ViewTerm term ->
ViewTerm group archive.archiveId handlePermissionDelete term
| OpenFGA.Types.ArchiveRelation.ExecTicket ticket ->
ExecTicket group archive.archiveId handlePermissionDelete ticket
)
)
]
]
]
| None -> Html.h1 (sprintf "Group %s / Archive %O not found" group archiveId)
]

View File

@@ -0,0 +1,234 @@
namespace Oceanbox.Codex
module GroupArchiveAddForm =
open Browser
open Fable.Core
open Fable.Core.JsInterop
open Feliz
let private postArchiveGroups (req: Remoting.AddArchiveGroupsRequest) =
promise {
try
let! result = Remoting.adminApi.addArchiveGroups req |> Async.StartAsPromise
return result
with e ->
return Error (sprintf "Error adding archive to groups: %s" e.Message)
}
// TODO: Promote to elmish
[<ReactComponent>]
let View key (onAdd: Archmaester.Dto.ArchiveProps -> unit) (group: string) =
let adding, setAdding = React.useState false
let selecting, setSelecting = React.useState false
let searchTerm, setSearchTerm = React.useState ""
let archiveType, setArchiveType = React.useState Archmaester.Dto.ArchiveType.Any
let selectedArchive, setSelectedArchive = React.useState<Archmaester.Dto.ArchiveProps option> None
let view, setView = React.useState<Remoting.ViewTerm option> None
let exec, setExec = React.useState<Remoting.ExecTicket option> None
let handleArchiveCancelAdd (ev: Types.Event) =
setAdding false
setSelecting false
setSelectedArchive None
let handleArchiveStartAdd (ev: Types.Event) =
setSelecting true
setAdding true
setSelectedArchive None
let handleArchiveBackToSelect (ev: Types.Event) =
setSelecting true
setSelectedArchive None
let handleArchiveAdd (ev: Types.Event) =
match selectedArchive with
| Some archive ->
console.debug("[Group] Adding archive: %o", archive)
postArchiveGroups {
Id = archive.archiveId;
Groups = [| Groups.Utils.canonicalizeName group |]
View = view
Exec = exec
}
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.debug("[Group] Added archive: %o", archive)
onAdd archive
| Error msg ->
console.error("[Group] Error adding archive to group: %s", msg)
)
| None ->
console.error("[Group] Trying to add archive but none selected")
let handleSearchChange (ev: Types.Event) =
let str = ev.target?value
setSearchTerm str
let debounceSearch = Utils.debounce 500 handleSearchChange
let handleTypeChange (newType: Archmaester.Dto.ArchiveType) =
setArchiveType newType
let handleArchiveSelect (archive: Archmaester.Dto.ArchiveProps) =
console.debug("[Group] Selected archive to add: %o", archive)
setSelecting false
setSelectedArchive (Some archive)
let handleViewToggle (ev: Types.Event) =
let isChecked : bool = ev.target?``checked``
console.debug("[Group] View checkbox changed: %o", ev)
if isChecked then
Some Remoting.ViewTerm.empty |> setView
else
setView None
let handleViewChange (term: Remoting.ViewTerm) =
setView (Some term)
let handleExecToggle (ev: Types.Event) =
let isChecked : bool = ev.target?``checked``
console.debug("[Group] Exec checkbox changed: %o", ev)
if isChecked then
Some Remoting.ExecTicket.empty
|> setExec
else
setExec None
let handleExecChange (ticket: Remoting.ExecTicket) =
setExec (Some ticket)
Html.div [
prop.children [
Html.div [
prop.classes [ "flex-column"; "gap-8" ]
prop.children [
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
if adding && not selecting then
Html.button [
prop.onClick handleArchiveAdd
prop.text "Save"
]
Html.button [
prop.onClick handleArchiveBackToSelect
prop.text "Back"
]
Html.button [
prop.onClick handleArchiveCancelAdd
prop.text "Cancel"
]
elif selecting then
Html.button [
prop.onClick handleArchiveCancelAdd
prop.text "Cancel"
]
Html.p "Select an archive to add"
else
Html.button [
prop.onClick handleArchiveStartAdd
prop.text "Add"
]
]
]
if selecting then
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.div [
prop.children [
Html.label [
prop.htmlFor "archives-search-input"
prop.style [
style.display.block
]
prop.text "Search:"
]
Html.input [
prop.id "archives-search-input"
prop.type'.text
prop.onChange debounceSearch
]
]
]
Archives.TypeSelects(archiveType, handleTypeChange)
]
]
]
]
if selecting then
Archives.List(archiveType, searchTerm = searchTerm, onSelect = handleArchiveSelect)
match selectedArchive with
| Some archive ->
Html.div [
prop.children [
Html.h4 archive.name
Html.p "Configure permissions"
Html.div [
prop.classes [ "flex-row"; "gap-16" ]
prop.children [
Html.div [
prop.style [
style.flexBasis (length.px 256)
]
prop.children [
Html.div [
prop.children [
Html.input [
prop.id "archive-view-checkbox"
prop.type'.checkbox
prop.onChange handleViewToggle
]
Html.label [
prop.htmlFor "archive-view-checkbox"
prop.text "View"
]
]
]
match view with
| Some view ->
Groups.ViewForm(view, handleViewChange)
| None -> Html.none
]
]
Html.div [
prop.style [
style.flexBasis (length.px 256)
]
prop.children [
Html.div [
prop.children [
Html.input [
prop.id "archive-exec-checkbox"
prop.type'.checkbox
prop.onChange handleExecToggle
prop.value exec.IsSome
]
Html.label [
prop.htmlFor "archive-exec-checkbox"
prop.text "Exec"
]
]
]
match exec with
| Some exec -> Groups.ExecForm(exec, handleExecChange)
| None -> Html.none
]
]
]
]
]
]
| None -> ()
]
]

View File

@@ -0,0 +1,14 @@
namespace Oceanbox.Codex
open Feliz
module Groups =
[<ReactComponent>]
let View () =
Html.div [
prop.children [
Html.h1 "Group List"
Groups.List ()
]
]

View File

@@ -0,0 +1,189 @@
namespace Oceanbox.Codex
open Browser
open Feliz
open Fable.Core.JsInterop
type Groups =
[<ReactComponent>]
static member ExecForm (exec: Remoting.ExecTicket, onChange: Remoting.ExecTicket -> unit) =
let taskInputRef = React.useRef<Types.HTMLInputElement option> None
let handleAddTask () =
match taskInputRef.current with
| Some elem ->
match Utils.tryStr elem.value with
| Some newTask ->
let newTasks =
newTask
|> Array.singleton
|> Array.append exec.Tasks
onChange { exec with Tasks = newTasks }
| None -> ()
| None ->
console.error "[Group] Trying to add exec task but input ref is None"
let handleQuotaChange (ev: Types.Event) =
console.debug("[Group] Exec quota changed: %o", ev)
let quota : float = ev.target?value
onChange { exec with Quota = quota }
let handleStartChange (ev: Types.Event) =
let str : string = ev.target?value
let start = System.DateTime.Parse str
console.debug("[Group] Exec start changed: %s", string start)
onChange { exec with StartTime = start }
let handleEndChange (ev: Types.Event) =
let str : string = ev.target?value
let endTime = System.DateTime.Parse str
console.debug("[Group] Exec end changed: %s", string endTime)
onChange { exec with EndTime = endTime }
Html.div [
prop.children [
Html.div [
prop.classes [
"flex-row"
"flex-wrap"
"gap-16"
]
prop.children [
Html.div [
prop.classes [
"flex-column"
"grow"
"gap-8"
]
prop.children [
Html.div [
prop.classes [
"flex-row-center"
"gap-8"
]
prop.style [
]
prop.children [
Html.label [
prop.htmlFor "archive-exec-start-date"
prop.classes [ "grow" ]
prop.text "Start date:"
]
Html.input [
prop.id "archive-exec-start-date"
prop.type'.date
prop.style [
style.flexBasis (length.px 128)
]
prop.onChange handleStartChange
prop.value exec.StartTime
]
]
]
Html.div [
prop.classes [
"flex-row-center"
"gap-8"
]
prop.style [
]
prop.children [
Html.label [
prop.htmlFor "archive-exec-end-date"
prop.classes [ "grow" ]
prop.text "End date:"
]
Html.input [
prop.id "archive-exec-end-date"
prop.type'.date
prop.style [
style.flexBasis (length.px 128)
]
prop.onChange handleEndChange
prop.value exec.EndTime
]
]
]
Html.div [
prop.classes [
"flex-row-center"
"gap-8"
]
prop.style [
]
prop.children [
Html.label [
prop.htmlFor "archive-exec-quota"
prop.classes [ "grow" ]
prop.text "Quota:"
]
Html.input [
prop.id "archive-exec-quota"
prop.type'.number
prop.style [
style.maxWidth (length.px 128)
style.flexBasis (length.px 128)
]
prop.onChange handleQuotaChange
prop.value exec.Quota
]
]
]
]
]
Html.div [
prop.classes [
"flex-column"
"grow"
]
prop.style [
]
prop.children [
Html.span [
prop.text "Tasks:"
]
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.button [
prop.text "Add"
prop.onClick (fun _ -> handleAddTask ())
]
Html.input [
prop.type'.text
prop.ref taskInputRef
]
]
]
Html.ul [
prop.children (
exec.Tasks
|> Array.map (fun task ->
Html.li [
prop.text task
]
)
)
]
]
]
]
]
Html.p "NB: If the start date is the same or after the end date, there is no time restriction."
]
]

View File

@@ -0,0 +1,41 @@
namespace Oceanbox.Codex
open Fable.Core
open Feliz
open Feliz.Router
[<Erase>]
type Groups =
[<ReactComponent>]
static member List () =
let groups = Groups.useGroups ()
if groups.Loading then
Html.p "Loading ..."
else
match groups.Error with
| Some msg ->
Html.p msg
| None ->
if Array.isEmpty groups.Groups then
Html.p "No groups"
else
Html.ul [
prop.children (
groups.Groups
|> Array.sort
|> Array.map (fun group ->
Html.li [
prop.key group
prop.children [
Html.a [
prop.href (Router.format("groups", group))
prop.text group
]
]
]
)
)
]

View File

@@ -0,0 +1,14 @@
namespace Oceanbox.Codex.Groups
module Utils =
/// Ensures that the name starts with a "/"
let canonicalizeName (name: string) : string =
if Utils.strNull name then
""
elif name.StartsWith "/" then
name
else
"/" + name
/// Formats a group to "group:/oceanbox#member", ensuring the group starts with '/'
let fgaMember = canonicalizeName >> sprintf "group:%s#member"

View File

@@ -0,0 +1,55 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core.JsInterop
open Feliz
type Groups =
[<ReactComponent>]
static member ViewForm (view: Remoting.ViewTerm, onChange: Remoting.ViewTerm -> unit) =
let handleStartChange (ev: Types.Event) =
let str : string = ev.target?value
let start = System.DateTime.Parse str
console.debug("[Group] View start changed: %s", string start)
onChange { view with StartTime = start }
let handleEndChange (ev: Types.Event) =
let str : string = ev.target?value
let endTime = System.DateTime.Parse str
console.debug("[Group] View end changed: %s", string endTime)
onChange { view with EndTime = endTime }
Html.div [
prop.children [
Html.div [
prop.children [
Html.label [
prop.text "Start date"
]
Html.input [
prop.type'.date
prop.onChange handleStartChange
prop.value view.StartTime
]
]
]
Html.div [
prop.children [
Html.label [
prop.text "End date"
]
Html.input [
prop.type'.date
prop.onChange handleEndChange
prop.value view.EndTime
]
]
]
Html.p "NB: If the start date is the same or after the end date, there is no time restriction."
]
]

View File

@@ -0,0 +1,51 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
type GroupsRequest = {
Loading: bool
Error: string option
Groups: string array
} with
static member empty = { Loading = true; Error = None; Groups = [||] }
[<Erase>]
type Groups =
static member private fetch () : JS.Promise<Result<string array, string>> =
promise {
try
console.debug("Fetching all groups")
let! groups = Remoting.adminApi.getAllGroups |> Async.StartAsPromise
return Ok groups
with
| :? ProxyRequestException as ex ->
let proxyError : Types.ProxyError = JS.JSON.parse ex.ResponseText |> unbox
console.error("Error fetching groups: %s", proxyError.error.errorMsg)
return Error "Something went wrong fetching groups"
| _ ->
return Error "Error fetching groups"
}
[<Hook>]
static member useGroups() : GroupsRequest =
let groups, setGroups = React.useState GroupsRequest.empty
React.useEffect (
(fun () ->
Groups.fetch ()
|> Promise.iter (fun res ->
match res with
| Ok gs ->
setGroups { groups with Loading = false; Groups = gs }
| Error msg ->
console.error("Error fetching groups: %s", msg)
setGroups { groups with Error = Some msg; Loading = false }
)
),
[||]
)
groups

View File

@@ -0,0 +1,45 @@
namespace Oceanbox.Codex
open Feliz
open Feliz.Router
module Index =
[<ReactComponent>]
let View () =
Html.main [
Html.h1 "Codex"
Html.h2 "Index"
Html.div [
prop.children [
Html.ul [
Html.li [
Html.a [
prop.href (Router.format "archives")
prop.text "archives"
]
]
Html.li [
Html.a [
prop.href (Router.format "model-areas")
prop.text "model areas"
]
]
Html.li [
Html.a [
prop.href (Router.format "groups")
prop.text "groups"
]
]
Html.li [
Html.a [
prop.href (Router.format "organizations")
prop.text "organizations"
]
]
]
]
]
]

View File

@@ -0,0 +1,8 @@
namespace Oceanbox.Codex
module Main =
open Browser
open Feliz
let root = ReactDOM.createRoot(document.getElementById "feliz-app")
root.render(Components.Router())

View File

@@ -0,0 +1,62 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core.JsInterop
open Fable.OpenLayers
open Feliz
module Map =
importSideEffects "ol/ol.css"
let private createVectorLayer (features: Feature array) : Layer =
let featureCollection : Collection.Collection<Feature> =
Collection.collection [
collection.array (ResizeArray features)
]
let vectorSource =
Source.vectorSource [
source.features featureCollection
]
Layer.vectorLayer [
layer.source vectorSource
]
[<ReactComponent>]
let View (center: Coordinate) (features: Feature array) =
let mapRef = React.useRef None
React.useEffect (
(fun () ->
console.debug("[Map] Center is %o", center)
let view =
View.view [
view.center center
view.zoom 3.5
]
let baseLayer : Layer =
Layer.tileLayer [
layer.source (Source.osm [])
]
let map =
OlMap.map [
map.target "map"
map.view view
map.layers [|
baseLayer
if (Array.isEmpty >> not) features then
createVectorLayer features
|]
]
mapRef.current <- Some map
),
[||]
)
Html.div [
prop.id "map"
prop.style [
style.height (length.perc 100)
]
]

View File

@@ -0,0 +1,122 @@
namespace Oceanbox.Codex
open Fable.Core
open Fable.OpenLayers
open Feliz
module ModelArea =
let private fetchModelArea (id: System.Guid) : JS.Promise<Archmaester.Dto.ModelArea option> =
promise {
try
let! opt = Remoting.modelAreaApi.getModelArea id |> Async.StartAsPromise
return opt
with ex ->
return None
}
[<ReactComponent>]
let View (modelAreaId: System.Guid) =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let modelAreaOpt, setModelArea = React.useState<Archmaester.Dto.ModelArea option> None
React.useEffect (
(fun () ->
fetchModelArea modelAreaId
|> Promise.iter (fun modelArea ->
setModelArea modelArea
setLoading false
)
),
[| box modelAreaId |]
)
Html.main [
if loading then
Html.h1 "Loading ..."
else
match modelAreaOpt with
| Some modelArea ->
let center = Utils.toEpsg3857 modelArea.focalPoint
let polygonFeature : Feature =
let coords =
modelArea.polygon
|> Array.map (fun point ->
Utils.toEpsg3857 point
)
let first = Array.head coords
let linearRing = Array.append coords [| first |]
let polygon =
Geometry.polygon [
geometry.coordinates [| linearRing |]
]
Feature.feature [
feature.geometryOrProperties polygon
]
Html.h1 (sprintf "Model area %s" modelArea.name)
match Utils.tryStr modelArea.description with
| Some desc ->
Html.section [
prop.children [
Html.p desc
]
]
| None ->
Html.none
Html.section [
prop.style [
style.height (length.px 256)
]
prop.children [
Map.View center [| polygonFeature |]
]
]
match error with
| Some msg ->
Html.p msg
| None ->
Html.section [
prop.children [
Html.h2 "Archives"
Html.div [
prop.classes [ "flex-row"; "gap-32" ]
prop.children [
Html.div [
prop.style [ style.flexBasis (length.px 256) ]
prop.children [
Html.h3 "FVCOM"
ArchivesList.List modelAreaId "fvcom:*:*"
]
]
Html.div [
prop.style [ style.flexBasis (length.px 256) ]
prop.children [
Html.h3 "Stats"
ArchivesList.List modelAreaId "fvstats:*:*"
]
]
Html.div [
prop.style [ style.flexBasis (length.px 256) ]
prop.children [
Html.h3 "Atmo"
ArchivesList.List modelAreaId "atmo:*:*"
]
]
]
]
]
]
| None ->
Html.h1 "404"
]

View File

@@ -0,0 +1,95 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
open Feliz.Router
module ModelAreas =
let private fetchModelAreas () : JS.Promise<Result<Archmaester.Dto.ModelArea array, string>> =
promise {
try
let! opt = Remoting.modelAreaApi.getModelArea System.Guid.Empty |> Async.StartAsPromise
match opt with
| Some world ->
let! modelAreas = Remoting.modelAreaApi.getSubModelAreas world.modelAreaId |> Async.StartAsPromise
return Ok modelAreas
| None ->
return Error "Could not find root model area"
with
| :? ProxyRequestException as e ->
console.error("Error fetching model areas", e.Response)
return Error "Something went wrong"
| e ->
console.error("Error fetching model areas", e)
return Error "Something went wrong"
}
[<ReactComponent>]
let List () =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let modelAreas, setModelAreas = React.useState<Archmaester.Dto.ModelArea array> [||]
React.useEffect (
(fun () ->
setLoading true
fetchModelAreas ()
|> Promise.iter (fun res ->
match res with
| Ok modelAreas ->
setModelAreas modelAreas
| Error msg ->
setError (Some msg)
setLoading false
)
),
[||]
)
React.fragment [
Html.h1 "Model areas"
if loading then
Html.p "Loading ..."
else
match error with
| Some msg ->
Html.p msg
| None ->
if Array.isEmpty modelAreas then
Html.p "No model areas"
else
Html.ul [
prop.children (
modelAreas
|> Array.sortBy _.name
|> Array.map (fun modelArea ->
Html.li [
prop.key modelArea.modelAreaId
prop.children [
Html.a [
prop.href (Router.format ("model-areas", string modelArea.modelAreaId))
prop.text modelArea.name
]
Html.ul [
prop.children [
Html.li [
prop.text (sprintf "Archives: %d" modelArea.archives)
]
Html.li [
prop.text (sprintf "Description: %s" modelArea.description)
]
]
]
]
]
)
)
]
]

View File

@@ -0,0 +1,38 @@
namespace Oceanbox.Codex.OpenFGA
open Feliz
open Feliz.Router
module ArchiveOwnerList =
[<ReactComponent>]
let View (id: System.Guid) =
let owners =
Oceanbox.Codex.OpenFGA.useUsers(
"archive",
string id,
"owner",
{ Type = "user"; Relation = None }
)
if owners.Loading then
Html.p "Loading ..."
else
if Array.isEmpty owners.Objects then
Html.p "No owners"
else
Html.ul [
prop.children (
owners.Objects
|> Array.map (fun owner ->
Html.li [
prop.key owner
prop.children [
Html.a [
prop.href (Router.format("users", owner))
prop.text owner
]
]
]
)
)
]

View File

@@ -0,0 +1,54 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
[<Erase>]
type OpenFGA =
[<ReactComponent>]
static member Checkbox(key, label: string, user: string, relation: string, object: string) =
let isChecked, setChecked = React.useState false
let handleChange (ev: Types.Event) =
console.debug("[OpenFGA.Checkbox] Checkbox %s changed to %o", key, not isChecked)
// TODO: Write to OpenFGA
setChecked(not isChecked)
React.useEffect (
(fun () ->
Remoting.openFgaApi.Check {
User = user
Relation = relation
Object = object
}
|> Async.StartAsPromise
|> Promise.iter (fun res ->
match res with
| Ok hasRelation ->
console.debug("[OpenFGA.Checkbox] User %s has relation %s to %s = %o", user, relation, object, hasRelation)
setChecked hasRelation
| Error err ->
console.error("[OpenFGA.Checkbox] Error checking user %s has relation %s to %s", user, relation, object)
)
),
[| |]
)
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.input [
prop.id (sprintf "openfga-checkbox-%s" key)
prop.type'.checkbox
prop.onChange handleChange
prop.custom("checked", isChecked)
]
Html.label [
prop.htmlFor (sprintf "openfga-checkbox-%s" key)
prop.text label
]
]
]

View File

@@ -0,0 +1,66 @@
namespace Oceanbox.Codex.OpenFGA
open Oceanbox.Codex
module Types =
type Objects = {
Loading: bool
Error: string option
Objects: string array
} with
static member Empty = { Loading = true; Error = None; Objects = [||] }
type Tuples = {
Loading: bool
Error: string option
SetTuples: Remoting.Tuple array -> unit
Tuples: Remoting.Tuple array
} with
static member Empty = {
Loading = true
Error = None
SetTuples = ignore
Tuples = [||]
}
module ViewTerm =
open Thoth.Json
let decoder: Decoder<Remoting.ViewTerm> =
Decode.Auto.generateDecoder (caseStrategy = SnakeCase)
let decode = Decode.fromString decoder
module ExecTicket =
open Thoth.Json
let decoder: Decoder<Remoting.ExecTicket> =
let floatOrString: Decoder<float> =
fun path value ->
if Decode.Helpers.isString value then
let value: string = unbox value
Ok (float value)
elif Decode.Helpers.isNumber value then
let value: float = unbox value
Ok value
else
Error (path, BadPrimitive ("a float or string", value))
Decode.object (fun get -> {
Tasks = get.Required.Field "tasks" (Decode.array Decode.string)
Quota = get.Required.Field "quota" floatOrString
StartTime = get.Required.Field "start_time" Decode.datetimeLocal
EndTime = get.Required.Field "end_time" Decode.datetimeLocal
})
let decode = Decode.fromString decoder
[<RequireQualifiedAccess>]
type ArchiveRelation =
| ViewTerm of Remoting.ViewTerm
| ExecTicket of Remoting.ExecTicket
static member tryOfCondition(cond: Remoting.Condition) =
match cond.Name with
| "term" -> cond.Context |> ViewTerm.decode |> Result.toOption |> Option.map ViewTerm
| "ticket" -> cond.Context |> ExecTicket.decode |> Result.toOption |> Option.map ExecTicket
| _ -> None

View File

@@ -0,0 +1,51 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
open Oceanbox.Codex.OpenFGA.Types
[<Erase>]
type OpenFGA =
static member fetchObjects(user: string, relation: string, objectType: string, ?context: obj) =
promise {
let contextJson : string option =
context
|> Option.map JS.JSON.stringify
let request : Remoting.ListObjectsRequest = {
User = user
Relation = relation
Type = objectType
Context = contextJson
}
try
let! res = Remoting.openFgaApi.ListObjects request |> Async.StartAsPromise
return res
with e ->
return Error $"Error fetching objects for {user} with relation {relation} on type {objectType}: {e.Message}"
}
[<Hook>]
static member useObjects(user: string, relation: string, objectType: string, ?context: obj) : Objects =
let objects, setObjects = React.useState<Objects> Objects.Empty
React.useEffect (
(fun () ->
setObjects { objects with Loading = true; Error = None }
OpenFGA.fetchObjects(user, relation, objectType, context)
|> Promise.iter (fun res ->
match res with
| Ok newObjects ->
setObjects { objects with Loading = false; Objects = newObjects }
| Error err ->
console.error("[OpenFGA] Error loading user objects %s", err)
setObjects { objects with Loading = false; Error = Some err }
)
),
[| box user |]
)
objects

View File

@@ -0,0 +1,56 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
open Oceanbox.Codex.OpenFGA.Types
[<Erase>]
type OpenFGA =
static member fetchTuples(?user: string, ?relation: string, ?object: string) =
promise {
let request : Remoting.ReadRequest = {
User = user
Relation = relation
Object = object
}
try
let! res = Remoting.openFgaApi.Read request |> Async.StartAsPromise
return res
with e ->
return Error $"Error fetching tuples with request {request}: {e.Message}"
}
[<Hook>]
static member useReadTuples(?user: string, ?relation: string, ?object: string) : Tuples =
let tuples, setTuples = React.useState<Tuples> Tuples.Empty
let handleSetTuples (newTuples: Remoting.Tuple array) =
setTuples { tuples with Tuples = newTuples }
React.useEffect (
(fun () ->
setTuples { tuples with Loading = true; Error = None }
OpenFGA.fetchTuples(?user = user, ?relation = relation, ?object = object)
|> Promise.iter (fun res ->
match res with
| Ok resp ->
let newTuples = resp.Tuples |> Array.map _.Key
setTuples {
tuples with
Loading = false
SetTuples = handleSetTuples
Tuples = newTuples
}
| Error err ->
console.error("[OpenFGA] Error loading user objects %s", err)
setTuples { tuples with Loading = false; Error = Some err }
)
),
[| box user |]
)
tuples

View File

@@ -0,0 +1,51 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
open Oceanbox.Codex
[<Erase>]
type OpenFGA =
static member private fetch (object: string) (id: string) (relation: string) (userFilter: Remoting.UserFilter) (context: obj) =
promise {
let contextJson : string option =
context
|> Option.ofObj
|> Option.map JS.JSON.stringify
let request : Remoting.ListUsersRequest = {
Object = object
Id = id
Relation = relation
UserFilter = userFilter
Context = contextJson
}
let! res = Remoting.openFgaApi.ListUsers request |> Async.StartAsPromise
return res
}
[<Hook>]
static member useUsers(object: string, id: string, relation: string, userFilter: Remoting.UserFilter, ?context: obj) : OpenFGA.Types.Objects =
let users, setUsers = React.useState<OpenFGA.Types.Objects> OpenFGA.Types.Objects.Empty
React.useEffect (
(fun () ->
setUsers { users with Loading = true; Error = None }
OpenFGA.fetch object id relation userFilter (defaultArg context null)
|> Promise.iter (fun res ->
match res with
| Ok newUsers ->
setUsers { users with Loading = false; Objects = newUsers }
| Error err ->
console.error("Error fetching OpenFGA users of %s with relation of type %o", object, relation, userFilter)
setUsers { users with Loading = false; Error = Some err }
)
),
[| box id |]
)
users

View File

@@ -0,0 +1,102 @@
namespace Oceanbox.Codex
open Fable.Core
open Feliz
open Feliz.Router
module Organization =
[<ReactComponent>]
let View (org: string) =
let groups = OpenFGA.useObjects(sprintf "organization:%s" org, "parent", "group")
let domains = OpenFGA.useUsers("organization", org, "realm", { Type = "domain"; Relation = None })
let admins = OpenFGA.useUsers("organization", org, "admin", { Type = "user"; Relation = None })
Html.main [
Html.h1 $"Organization {org}"
Html.div [
prop.children [
Html.h2 "Groups"
if groups.Loading then
Html.p "Loading ..."
else
Html.ul [
prop.children (
groups.Objects
|> Array.sort
|> Array.map (fun object ->
let split = object.Split ':'
match split with
| [| objectType; id |] ->
Html.li [
prop.key object
prop.children [
Html.a [
prop.href (Router.format("groups", id))
prop.text id
]
]
]
| _ ->
Html.li [
prop.text "Invalid object format"
]
)
)
]
]
]
Html.div [
prop.children [
Html.h2 "Domains"
if domains.Loading then
Html.p "Loading ..."
else
Html.ul [
prop.children (
domains.Objects
|> Array.sort
|> Array.map (fun object ->
Html.li [
prop.key object
prop.children [
Html.a [
prop.text object
]
]
]
)
)
]
]
]
Html.div [
prop.children [
Html.h2 "Admins"
if admins.Loading then
Html.p "Loading ..."
else
Html.ul [
prop.children (
admins.Objects
|> Array.sort
|> Array.map (fun user ->
Html.li [
prop.key user
prop.children [
Html.a [
prop.href (Router.format("users", user))
prop.text user
]
]
]
)
)
]
]
]
]

View File

@@ -0,0 +1,44 @@
namespace Oceanbox.Codex
open Feliz
open Feliz.Router
module Organizations =
[<ReactComponent>]
let List () =
let objects = OpenFGA.useObjects("system:atlantis", "parent", "organization")
Html.main [
prop.children [
Html.h1 "Organizations"
Html.p "This is the organizations page."
if objects.Loading then
Html.p "Loading ..."
else
Html.ul [
prop.children (
objects.Objects
|> Array.sort
|> Array.map (fun object ->
let split = object.Split ':'
match split with
| [| objectType; id |] ->
Html.li [
prop.key object
prop.children [
Html.a [
prop.href (Router.format("organizations", id))
prop.text id
]
]
]
| _ ->
Html.li [
prop.text "Invalid object format"
]
)
)
]
]
]

View File

@@ -0,0 +1,43 @@
# Feliz Template
This template gets you up and running with a simple web app using [Fable](http://fable.io/) and [Feliz](https://github.com/Zaid-Ajaj/Feliz).
## Requirements
* [dotnet SDK](https://www.microsoft.com/net/download/core) v7.0 or higher
* [node.js](https://nodejs.org) v18+ LTS
## Editor
To write and edit your code, you can use either VS Code + [Ionide](http://ionide.io/), Emacs with [fsharp-mode](https://github.com/fsharp/emacs-fsharp-mode), [Rider](https://www.jetbrains.com/rider/) or Visual Studio.
## Development
Before doing anything, start with installing npm dependencies using `npm install`.
Then to start development mode with hot module reloading, run:
```bash
npm start
```
This will start the development server after compiling the project, once it is finished, navigate to http://localhost:8080 to view the application .
To build the application and make ready for production:
```
npm run build
```
This command builds the application and puts the generated files into the `deploy` directory (can be overwritten in webpack.config.js).
### Tests
The template includes a test project that ready to go which you can either run in the browser in watch mode or run in the console using node.js and mocha. To run the tests in watch mode:
```
npm run test:live
```
This command starts a development server for the test application and makes it available at http://localhost:8085.
To run the tests using the command line and of course in your CI server, you have to use the mocha test runner which doesn't use the browser but instead runs the code using node.js:
```
npm test
```

View File

@@ -0,0 +1,42 @@
namespace Oceanbox.Codex
module Remoting =
open Fable.Remoting.Client
// User Portal APIs
let authApi : Remoting.Api.Auth =
Remoting.createApi()
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.buildProxy<Remoting.Api.Auth>
let openFgaApi : Remoting.Api.OpenFGA =
Remoting.createApi()
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.buildProxy<Remoting.Api.OpenFGA>
let adminApi : Remoting.Api.Admin =
Remoting.createApi()
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.buildProxy<Remoting.Api.Admin>
// Archmaester APIs
let aclApi : Archmaester.Api.Acl =
Remoting.createApi()
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.buildProxy<Archmaester.Api.Acl>
let inventoryApi : Archmaester.Api.Inventory =
Remoting.createApi()
|> Remoting.withCredentials true
|> Remoting.withBaseUrl "https://simkir-atlantis.dev.oceanbox.io"
|> Remoting.withRouteBuilder Archmaester.Api.internalRouteBuilder
|> Remoting.buildProxy<Archmaester.Api.Inventory>
let modelAreaApi : Archmaester.Api.ModelArea =
Remoting.createApi()
|> Remoting.withCredentials true
|> Remoting.withBaseUrl "https://simkir-atlantis.dev.oceanbox.io"
|> Remoting.withRouteBuilder Archmaester.Api.internalRouteBuilder
|> Remoting.buildProxy<Archmaester.Api.ModelArea>

View File

@@ -0,0 +1,13 @@
namespace Oceanbox.Codex.Types
type Archive = {
Props: Archmaester.Dto.ArchiveProps
CanView: bool
CanExec: bool
}
type ProxyError = {
error: Oceanbox.Codex.Remoting.CustomError
ignored: bool
handled: bool
}

View File

@@ -0,0 +1,226 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
open Feliz.Router
[<Erase>]
type User =
[<ReactComponent>]
static member List(user: string, relation: string, objectType: string, ?context: obj) =
let objects = OpenFGA.useObjects(user, relation, objectType, context)
if objects.Loading then
Html.p "Loading ..."
else
if Array.isEmpty objects.Objects then
Html.p (sprintf "No objects with user %s relation %s of type %s" user relation objectType)
else
Html.ul [
prop.children (
objects.Objects
|> Array.sort
|> Array.map (fun object ->
let split = object.Split ':'
match split with
| [| objectType; id |] ->
Html.li [
prop.key id
prop.children [
Html.a [
prop.href (Router.format("archives", id))
prop.text id
]
]
]
| _ ->
Html.li [
prop.text "Invalid object format"
]
)
)
]
module User =
[<ReactComponent>]
let private DeleteForm (user: string) =
let deleted, setDeleted = React.useState<Result<unit, string> option> None
let deleting, setDeleting = React.useState false
let handleDelete =
React.useCallback (
(fun () ->
setDeleting true
console.info("[User] Deleting user %s", user)
Remoting.adminApi.removeUsers [| user |]
|> Async.StartAsPromise
|> Promise.catch (fun ex ->
match ex with
| :? ProxyRequestException as e ->
let proxyError : Types.ProxyError = JS.JSON.parse e.ResponseText |> unbox
let msg = proxyError.error.errorMsg
Error msg
| ex ->
Error ex.Message
)
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("[User] Successfully deleted user %s", user)
setDeleted (Some (Ok ()))
| Error err ->
console.error("[User] Error deleting user %s: %s", user, err)
setDeleted (Some (Error err))
)
),
[| box user |]
)
React.fragment [
match deleted with
| Some (Ok ()) ->
Html.p "User successfully deleted."
Html.a [
prop.onClick (fun ev ->
ev.preventDefault ()
Router.navigateBack ()
)
prop.href (Router.format "")
prop.text "Back"
]
| Some (Error err) ->
Html.p (sprintf "Error deleting user: %s" err)
| None ->
if deleting then
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.button [
prop.onClick (fun _ -> handleDelete ())
prop.text "Are you sure?"
]
Html.button [
prop.onClick (fun _ -> setDeleting false)
prop.text "Cancel"
]
]
]
Html.p "This will delete the user from the databases. Not disable the user."
else
Html.button [
prop.onClick (fun _ -> setDeleting true)
prop.text "Delete"
]
]
[<ReactComponent>]
let View (user: string) =
let fgaUser = sprintf "user:%s" user
Html.main [
Html.h1 user
Html.section [
prop.children [
DeleteForm user
]
]
Html.section [
prop.children [
Html.h2 "Archmaester"
Html.p "TODO"
]
]
Html.section [
prop.children [
Html.h2 "OpenFGA"
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-8" ]
prop.children [
OpenFGA.Checkbox("active-checkbox", "Active", fgaUser, "active", fgaUser)
OpenFGA.Checkbox("registered-checkbox", "Registered", fgaUser, "registered", fgaUser)
OpenFGA.Checkbox("disabled-checkbox", "Disabled", fgaUser, "disabled", fgaUser)
]
]
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with Owner"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "owner", "archive")
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with View"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "view", "archive", {| time = System.DateTime.Now |})
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with exec"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "exec", "archive", {| time = System.DateTime.Now |})
]
]
]
]
]
]
]
]
]

6
src/Codex/src/Client/build.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
echo Building Codex frontend
fable -e .jsx -o build --verbose --run \
bunx --bun vite build -d -c ../../vite.config.js --mode development --minify false --outDir /home/simkir/oceanbox/poseidon/src/Codex/dist/WebRoot

View File

@@ -0,0 +1,75 @@
{
bun,
lib,
deps,
pkgs,
fable,
nodejs,
dotnet-sdk,
netrcConfig,
nodeModules,
nix-gitignore,
packageSources,
buildDotnetModule,
}:
let
name = "Codex.Client";
in
buildDotnetModule {
name = name;
inherit dotnet-sdk;
src = nix-gitignore.gitignoreSource [ ] ../../../..;
projectFile = "src/Codex/src/Client/Codex.Client.fsproj";
nugetDeps = deps {
inherit
pkgs
netrcConfig
packageSources
;
name = name;
lockfiles = [
./packages.lock.json
];
};
# Skip the default dotnet build since we're using Fable
dontDotnetBuild = true;
dontFixup = true;
dontPatchELF = true;
dontStrip = true;
buildInput = [
nodejs
fable
bun
];
buildPhase = ''
runHook preBuild
cp -r ${nodeModules}/node_modules ./.
pushd src/Codex/src/Client
${lib.getExe fable} --verbose -e .jsx -o build
${lib.getExe bun} ../../../../node_modules/.bin/vite build -d
popd
mv src/Codex/src/Client/dist .
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
mv dist $out/WebRoot
runHook postInstall
'';
}

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<title>Feliz App</title>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" type="image/png" href="/img/favicon-32x32.png" sizes="32x32" />
<link rel="shortcut icon" type="image/png" href="/img/favicon-16x16.png" sizes="16x16" />
<link rel="stylesheet" href="/main.css" />
</head>
<body>
<div id="feliz-app"></div>
<script type="module" src="/build/Main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,656 @@
{
"version": 1,
"dependencies": {
"net9.0": {
"Fable.OpenLayers": {
"type": "Direct",
"requested": "[2.20.0, )",
"resolved": "2.20.0",
"contentHash": "FB3JH7UTSgFwyA9l8/B7QkN1G3uHb4g9h3Nyh//wFBFvDlGFqd+Pet4Fp3XLrwJaHl3hUWz7BKZOQLdcw1EKBA==",
"dependencies": {
"FSharp.Core": "9.0.303",
"Fable.Browser.Dom": "2.18.0",
"Fable.Browser.WebGL": "1.3.0",
"Fable.Core": "4.3.0"
}
},
"Fable.Promise": {
"type": "Direct",
"requested": "[3.2.0, )",
"resolved": "3.2.0",
"contentHash": "4A+Iiembrny2h3AE2BIbchfuLmWHNhpkOTvbTtFXHtGzHVMqEVFRXrAfdy83wX2wK5Og3fqRo1y8t/Bqkd7j6g==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.7.1"
}
},
"Fable.Remoting.Client": {
"type": "Direct",
"requested": "[7.35.0, )",
"resolved": "7.35.0",
"contentHash": "57StsvefN9NZorEbOsjngDXjn0JDxDG36S8ikDQdAC/WTi5n7kZChZ1v+0CMiZ2IU9aarprOLvr1ie5NKN9IZA==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.XMLHttpRequest": "1.0.0",
"Fable.Core": "3.1.5",
"Fable.Remoting.MsgPack": "1.25.0",
"Fable.SimpleJson": "3.24.0"
}
},
"Feliz": {
"type": "Direct",
"requested": "[2.9.0, )",
"resolved": "2.9.0",
"contentHash": "8nyGREGA60RysdSBamVWmr68MG+3lLy76W17fBiGaKi7uMFbtRcYBLyNtp2NyGZFfnuWCEyDAmAXM5YFnDhbhg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.ReactDom.Types": "18.2.0",
"Feliz.CompilerPlugins": "2.2.0"
}
},
"Feliz.Router": {
"type": "Direct",
"requested": "[4.0.0, )",
"resolved": "4.0.0",
"contentHash": "al1c1BJgmobZ1U5bWRVrPe/TXRJAV70uslBGRT3yKgpf7dl7E/XWZhAZWpKhu4pPuvpH0rjHhPnj15r80QCSMQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Elmish": "4.0.0",
"Feliz": "2.3.0"
}
},
"Feliz.UseElmish": {
"type": "Direct",
"requested": "[2.5.0, )",
"resolved": "2.5.0",
"contentHash": "kY0otnlyCeoTTHFvZ0LbfgWU570evLSh2rj8ekKpsN1MO0d980igZWdLDgeBkiIKazYdn1+9VgqgQi4zXqbtCw==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Elmish": "4.0.0"
}
},
"FSharp.Core": {
"type": "Direct",
"requested": "[9.0.303, )",
"resolved": "9.0.303",
"contentHash": "6JlV8aD8qQvcmfoe/PMOxCHXc0uX4lR23u0fAyQtnVQxYULLoTZgwgZHSnRcuUHOvS3wULFWcwdnP1iwslH60g=="
},
"FsToolkit.ErrorHandling": {
"type": "Direct",
"requested": "[5.1.0, )",
"resolved": "5.1.0",
"contentHash": "l1bblQhBLLjoHVVxnxDghT8DBjeDQoN1UEEwryvfAoer599C/hnOo0BPcNVP1SpltaWXTjdUZXZyXj0jF6Onbw==",
"dependencies": {
"FSharp.Core": "9.0.300"
}
},
"Dapr.Actors": {
"type": "Transitive",
"resolved": "1.16.0",
"contentHash": "s9v6VofXXYoRqZJQlQbvNYYSlGhkL+Z+bpqrx1TRo06kLhANeDmXA9yeVaD+1KwJIO1chUFj5O4iKuTxIkg1sA==",
"dependencies": {
"Dapr.Client": "1.16.0",
"Dapr.Common": "1.16.0",
"Google.Api.CommonProtos": "2.17.0",
"Google.Protobuf": "3.32.0",
"Grpc.Net.Client": "2.71.0",
"Microsoft.Extensions.Configuration": "9.0.8",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.DependencyInjection": "9.0.8",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Http": "9.0.8",
"Microsoft.Extensions.Logging": "9.0.8",
"Microsoft.Extensions.Logging.Abstractions": "9.0.8",
"Microsoft.Extensions.Options": "9.0.8"
}
},
"Dapr.Client": {
"type": "Transitive",
"resolved": "1.16.0",
"contentHash": "dFDKol+mtQrk1lIKlEyCx3k6W0Pf+0wC6xcsaDqa0Bg+XCWDc4juROuDcSb0/L1Y+Ev6LSLDMC/FgzNWMw9YtQ==",
"dependencies": {
"Dapr.Common": "1.16.0",
"Dapr.Protos": "1.16.0",
"Google.Api.CommonProtos": "2.17.0",
"Google.Protobuf": "3.32.0",
"Grpc.Net.Client": "2.71.0",
"Microsoft.Extensions.Configuration": "9.0.8",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.DependencyInjection": "9.0.8",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Http": "9.0.8",
"Microsoft.Extensions.Logging": "9.0.8",
"Microsoft.Extensions.Logging.Abstractions": "9.0.8",
"Microsoft.Extensions.Options": "9.0.8"
}
},
"Dapr.Common": {
"type": "Transitive",
"resolved": "1.16.0",
"contentHash": "6JyPI8LxNXSjSpO9vTdbfJh78zOiiC0sgeaXuY8O6SJQh2epaRdEPw0UpamNnld3CkDjp69/VCphox7pU/lh1Q==",
"dependencies": {
"Dapr.Protos": "1.16.0",
"Google.Api.CommonProtos": "2.17.0",
"Google.Protobuf": "3.32.0",
"Grpc.Net.Client": "2.71.0",
"Microsoft.Extensions.Configuration": "9.0.8",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.DependencyInjection": "9.0.8",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Http": "9.0.8",
"Microsoft.Extensions.Logging": "9.0.8",
"Microsoft.Extensions.Logging.Abstractions": "9.0.8",
"Microsoft.Extensions.Options": "9.0.8"
}
},
"Dapr.Protos": {
"type": "Transitive",
"resolved": "1.16.0",
"contentHash": "4k4iKjyRCsFwX7KY5tDcBWDe6JPkhnvN1nqd8zRhDw3YcajF/Br3SU072YdEQKUQ/MJNvqafvzCNPbqSbK3nqg==",
"dependencies": {
"Google.Api.CommonProtos": "2.17.0",
"Google.Protobuf": "3.32.0",
"Grpc.Net.Client": "2.71.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Logging.Abstractions": "9.0.8"
}
},
"Drifters.Api": {
"type": "Transitive",
"resolved": "6.22.0",
"contentHash": "EQguKE22Tfd3ayO/jdWiWMBK5R1uzcYo+8agG3ZzAJ1ltl72mIXHqr68BKqO4uhOLtiFs8ErZa4cZ9NVueYHWA==",
"dependencies": {
"FSharp.Core": "9.0.201"
}
},
"Fable.AST": {
"type": "Transitive",
"resolved": "4.2.1",
"contentHash": "/4V6U7Qw/WIRRxm9NJ7b+YTXTRCTk6/YKeJnbKYaVbtT45MstA3jkFvRfV0FqVFtkG9AL4uccetreygTjK7nbQ=="
},
"Fable.Browser.Blob": {
"type": "Transitive",
"resolved": "1.4.0",
"contentHash": "UlaxrIXUfMmABjP+8a4XJp/Af+eCRKa8KJ57Olq4sqphmPLn/gNtp3sk5hRNBZ385lwUszbO5yd3Q/rrl9BdOQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.Dom": {
"type": "Transitive",
"resolved": "2.18.0",
"contentHash": "usu19HS3yRIPvzQ//Yj+Dp6SkJ1fkVUVOREaeDR4iLXGTKl0UqR1nPT1tEBX2GGMefj7dVrmG0dbONirOlVFBw==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Blob": "1.4.0",
"Fable.Browser.Event": "1.7.0",
"Fable.Browser.WebStorage": "1.3.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.Event": {
"type": "Transitive",
"resolved": "1.7.0",
"contentHash": "x+wqXQK0l4VlCnELDp68GC/mZAx6NbicDxYPliyAoNq8RPNDeR3R782icNwI5YmA+ufq11XvG6w1JjsL/ldy7w==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Gamepad": "1.3.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.Gamepad": {
"type": "Transitive",
"resolved": "1.3.0",
"contentHash": "C4HZDzCgff+U094QjpQlJh425W5j5/vojvOi2FV5UFS34l7TJ6YBgBPpKoro02QhAi/UF3AeocR+V2yiYxHb0A==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.IndexedDB": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "6RU8aqUeb4qpAekPEjnpaWP+RRTyYMB4ICE06eZoMoTXPq0oGWxsEkHPgDJIPVTmyDuAGJ4YMcDCt2D8850xMw==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Dom": "2.14.0",
"Fable.Browser.Event": "1.5.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.WebGL": {
"type": "Transitive",
"resolved": "1.3.0",
"contentHash": "iQognakmr62KccqZg++oenn1J0eSdCexAFUII0fSWAz1tTfdaPxrFKIjagHd/3HWw5NettpyNJREVRDghklYTQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Dom": "2.16.0",
"Fable.Browser.Event": "1.6.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.WebStorage": {
"type": "Transitive",
"resolved": "1.3.0",
"contentHash": "x8JL9oEtPiK0JY4GrRTqhomiLxT6Jaiv5uu8VXiNeA78bFvUogZWxQeejsK83iNFGErK5wpdiPd0tsREZTRLeg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Event": "1.6.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.XMLHttpRequest": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "27p/F8781NrnV9vQ23RhX10ww9MDkX+Yi3yTiV9s8U8Bufi/VCCjS4swX0LXvgKQANN3k87CwaNeiO75r2U7gw==",
"dependencies": {
"FSharp.Core": "4.6.2",
"Fable.Browser.Blob": "1.1.0",
"Fable.Browser.Event": "1.0.0",
"Fable.Core": "3.0.0"
}
},
"Fable.Core": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "zVQdiC8RqCOBb3KACTp9ASU9Q46esXXWosZQT/Vu/RhCpkfVwXPmBxVayy3iyqaRWc7XSu4Af7pbOqlcL/RtdA=="
},
"Fable.Elmish": {
"type": "Transitive",
"resolved": "4.2.0",
"contentHash": "A8lDcHbz2AKcwFa6IlnK8I/21nbsxBcP5Vxq6Gp+jT8dU7Vjpnk8Pbry5+zQrlqjwt1XHU/S5Oo0KZqaGemPUA==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.7.1"
}
},
"Fable.Fetch": {
"type": "Transitive",
"resolved": "2.7.0",
"contentHash": "2ndGZZTqpX9Hyso51tnIxWAskN2zrHX+7LeAwfG4zew+DtMMGa/3IyJGl2BOYUwweq2MhfuVqs1K3avgBFDq+Q==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Blob": "1.2.0",
"Fable.Browser.Event": "1.5.0",
"Fable.Core": "3.7.1",
"Fable.Promise": "2.2.2"
}
},
"Fable.Lit": {
"type": "Transitive",
"resolved": "1.6.2-oceanbox",
"contentHash": "ylo6UgB6FiGyINpDvryYt3GPl8MH6fvB5tiNizCmsNNerGxw/THFMGGJHmukQDh64NHru4ARhPTPgkBvsyOTVA==",
"dependencies": {
"FSharp.Core": "8.0.300",
"Fable.Browser.Dom": "2.17.0",
"Fable.Core": "4.3.0",
"Fable.Promise": "3.2.0"
}
},
"Fable.Lit.Elmish": {
"type": "Transitive",
"resolved": "1.6.2-oceanbox",
"contentHash": "K0fBlpHWZs07s3OYcpBZXVo+xoot62f+USdw8Pi9yxd8a6rmfsbbPlDQvGe2k6VpkEelXNL0AMwEt9GxEY1DeQ==",
"dependencies": {
"Fable.Elmish": "4.2.0",
"Fable.Lit": "1.6.2-oceanbox"
}
},
"Fable.Lit.React": {
"type": "Transitive",
"resolved": "1.6.2-oceanbox",
"contentHash": "+k0/F4mWZe91GuPDJxBTawUFKegQyNU48OjwAQTzzp3RlibCo1wvFtZjISJz83OvHcjuCSmO02i7uxWm9j6gFw==",
"dependencies": {
"Fable.Lit": "1.6.2-oceanbox",
"Feliz": "2.8.0"
}
},
"Fable.Parsimmon": {
"type": "Transitive",
"resolved": "4.0.0",
"contentHash": "AaHqEcwjjv8q5S2gCNu6XsVcpChYM8D6aEb3sjjsAiLspwLrNLqm6vOEKdJKGnh0gSLHg6UWzLGA/Q4jrk+t/w==",
"dependencies": {
"FSharp.Core": "4.6.2",
"Fable.Core": "3.0.0"
}
},
"Fable.React": {
"type": "Transitive",
"resolved": "9.4.0",
"contentHash": "c33FD2BumoYvu4/8bz2ToWaLZyfq2GMo7nq0RB/Bdoj7KdNObNBw2s1jWTi9whcf/s3tmikoXS4gZUKpD9MJ8g==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.React.Types": "18.3.0",
"Fable.ReactDom.Types": "18.2.0"
}
},
"Fable.React.Types": {
"type": "Transitive",
"resolved": "18.3.0",
"contentHash": "/b8WZ3Bdfhqy9r60ZK9JGZaGNjIMb0ogsrvWIg3k7KfCEvJs5X6+7hCybVkyjVoxwzn9wLyYGRbh5wmuHQT/Vg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Dom": "2.4.4",
"Fable.Core": "3.2.7"
}
},
"Fable.ReactDom.Types": {
"type": "Transitive",
"resolved": "18.2.0",
"contentHash": "2WoBjsLiSgrvR68OJXko0iVaqeMbkPM5Bx813A1WlxOSCJ50M9fwnlwG/MUEZtiOIhQmku/YTJY5a8E8r1+j2Q==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.React.Types": "18.3.0"
}
},
"Fable.Remoting.MsgPack": {
"type": "Transitive",
"resolved": "1.25.0",
"contentHash": "FyqSj8j8J0W7xTR8XJmk2Q6vZa0NlKUUjQtr/rQhCkV2r5uJ3gtT+2KSiMjdzemwV5X+9eUz6C5q1YN0t3ccug==",
"dependencies": {
"FSharp.Core": "4.7.2"
}
},
"Fable.SignalR": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "CTBaS44I4fGG++g9wRbNO/gOxy5gPKBkw1UPP9rA8j/bX0SpfJENtavVI0ZHJLltf5umy01RG9HMWlMpi1Q6Sw==",
"dependencies": {
"FSharp.Core": "9.0.100",
"Fable.Promise": "3.2.0",
"Fable.Remoting.MsgPack": "1.24.0",
"Fable.SimpleJson": "3.24.0"
}
},
"Fable.SignalR.Elmish": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "sPPuEcpKlRGACbX7Hk4kh31+aii8GAM8toTwYpmrtU+Zl9QocwbWK6nPJaE0YbQ41ZJgohyU6bNhKt7+SPKZhw==",
"dependencies": {
"FSharp.Core": "9.0.100",
"Fable.Elmish": "4.2.0",
"Fable.Promise": "3.2.0",
"Fable.SignalR": "2.1.0"
}
},
"Fable.SimpleHttp": {
"type": "Transitive",
"resolved": "3.6.0",
"contentHash": "RHXu3OQVxoxObErhUWl7J9JWXqDxLaQrpIXyo2MECF1a9ekNZ5bBnDGVB1RCEKRpVFB6SOun/pk+DB5wJDYmmg==",
"dependencies": {
"FSharp.Core": "4.6.2",
"Fable.Browser.Dom": "1.0.0",
"Fable.Browser.XMLHttpRequest": "1.1.0",
"Fable.Core": "3.0.0"
}
},
"Fable.SimpleJson": {
"type": "Transitive",
"resolved": "3.24.0",
"contentHash": "mNk5s+8arkrrupT52/840xybT/DmaPUsJ926fTHk2uHOaWLnyNbUPY63Yg8zJZFCxSCzWrFpmB8rS9fcLVLJSg==",
"dependencies": {
"FSharp.Core": "4.7.0",
"Fable.Core": "3.1.5",
"Fable.Parsimmon": "4.0.0"
}
},
"Feliz.CompilerPlugins": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "ACkO++Hp4lUrEx/axeehIL5/3R8jMnak+CYpzd0/kLpejp9BETtrgjHK7oj6Lh3V9fB7WoAKsCxyPSrm4ADN2w==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.AST": "4.2.1"
}
},
"Google.Api.CommonProtos": {
"type": "Transitive",
"resolved": "2.17.0",
"contentHash": "elfQPknFr495hm7vdy6ZlgyQh6yzZq9TU7sS35L/Fj/fqjM/mUGau9gVJLhvQEtUlPjtR80hpn/m9HvBMyCXIw==",
"dependencies": {
"Google.Protobuf": "[3.31.1, 4.0.0]"
}
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.32.0",
"contentHash": "fsKxV5bhcvXmZi+cUo5+IxzRMBHwHeFO8G5utNa9f+Mu37kmfy8JcUVvWPt4cX7EuQWAjjHUjZqVl7nGSTRHRg=="
},
"Grpc.Core.Api": {
"type": "Transitive",
"resolved": "2.71.0",
"contentHash": "QquqUC37yxsDzd1QaDRsH2+uuznWPTS8CVE2Yzwl3CvU4geTNkolQXoVN812M2IwT6zpv3jsZRc9ExJFNFslTg=="
},
"Grpc.Net.Client": {
"type": "Transitive",
"resolved": "2.71.0",
"contentHash": "U1vr20r5ngoT9nlb7wejF28EKN+taMhJsV9XtK9MkiepTZwnKxxiarriiMfCHuDAfPUm9XUjFMn/RIuJ4YY61w==",
"dependencies": {
"Grpc.Net.Common": "2.71.0",
"Microsoft.Extensions.Logging.Abstractions": "6.0.0"
}
},
"Grpc.Net.Common": {
"type": "Transitive",
"resolved": "2.71.0",
"contentHash": "v0c8R97TwRYwNXlC8GyRXwYTCNufpDfUtj9la+wUrZFzVWkFJuNAltU+c0yI3zu0jl54k7en6u2WKgZgd57r2Q==",
"dependencies": {
"Grpc.Core.Api": "2.71.0"
}
},
"Matplotlib.ColorMaps": {
"type": "Transitive",
"resolved": "3.0.1",
"contentHash": "Amw/NumOXIOB4Z/YbBErDd7gcZrtNhG10aeF9MydXUVNmmf7BJKeHDroSnzMRbsUOf3oQCXhzyjng6mhmRA0LA==",
"dependencies": {
"FSharp.Core": "6.0.4"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "6m+8Xgmf8UWL0p/oGqBM+0KbHE5/ePXbV1hKXgC59zEv0aa0DW5oiiyxDbK5kH5j4gIvyD5uWL0+HadKBJngvQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.Primitives": "9.0.8"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "yNou2KM35RvzOh4vUFtl2l33rWPvOCoba+nzEDJ+BgD8aOL/jew4WPCibQvntRfOJ2pJU8ARygSMD+pdjvDHuA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.8"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "0vK9DnYrYChdiH3yRZWkkp4x4LbrfkWEdBc5HOsQ8t/0CLOWKXKkkhOE8A1shlex0hGydbGrhObeypxz/QTm+w==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "JJjI2Fa+QtZcUyuNjbKn04OjIUX5IgFGFu/Xc+qvzh1rXdZHLcnqqVXhR4093bGirTwacRlHiVg1XYI9xum6QQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "xY3lTjj4+ZYmiKIkyWitddrp1uL5uYiweQjqo4BKBw01ZC4HhcfgLghDpPZcUlppgWAFqFy9SgkiYWOMx365pw=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "BKkLCFXzJvNmdngeYBf72VXoZqTJSb1orvjdzDLaGobicoGFBPW8ug2ru1nnEewMEwJzMgnsjHQY8EaKWmVhKg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "9.0.8",
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.8",
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.8"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "UDY7blv4DCyIJ/8CkNrQKLaAZFypXQavRZ2DWf/2zi1mxYYKKw2t8AOCBWxNntyPZHPGhtEmL3snFM98ADZqTw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Options": "9.0.8"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "jDj+4aDByk47oESlDDTtk6LWzlXlmoCsjCn6ihd+i9OntN885aPLszUII5+w0B/7wYSZcS3KdjqLAIhKLSiBXQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Diagnostics": "9.0.8",
"Microsoft.Extensions.Logging": "9.0.8",
"Microsoft.Extensions.Logging.Abstractions": "9.0.8",
"Microsoft.Extensions.Options": "9.0.8"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "Z/7ze+0iheT7FJeZPqJKARYvyC2bmwu3whbm/48BJjdlGVvgDguoCqJIkI/67NkroTYobd5geai1WheNQvWrgA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.8",
"Microsoft.Extensions.Logging.Abstractions": "9.0.8",
"Microsoft.Extensions.Options": "9.0.8"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "pYnAffJL7ARD/HCnnPvnFKSIHnTSmWz84WIlT9tPeQ4lHNiu0Az7N/8itihWvcF8sT+VVD5lq8V+ckMzu4SbOw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "OmTaQ0v4gxGQkehpwWIqPoEiwsPuG/u4HUsbOFoWGx4DKET2AXzopnFe/fE608FIhzc/kcg2p8JdyMRCCUzitQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Primitives": "9.0.8"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "eW2s6n06x0w6w4nsX+SvpgsFYkl+Y0CttYAt6DKUXeqprX+hzNqjSfOh637fwNJBg7wRBrOIRHe49gKiTgJxzQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.Configuration.Binder": "9.0.8",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.8",
"Microsoft.Extensions.Options": "9.0.8",
"Microsoft.Extensions.Primitives": "9.0.8"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "9.0.8",
"contentHash": "tizSIOEsIgSNSSh+hKeUVPK7xmTIjR8s+mJWOu1KXV3htvNQiPMFRMO17OdI1y/4ZApdBVk49u/08QGC9yvLug=="
},
"Thoth.Fetch": {
"type": "Transitive",
"resolved": "3.0.1",
"contentHash": "5i8KQwTFzDEoIjE/fAwCw0GFICCsFzVkVq2w4uU1fRlOqbSfLlUNcCEq6JkeAvQ+Jj7syMKNPSH994T8NswcpA==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.2.8",
"Fable.Fetch": "2.1.0",
"Fable.Promise": "2.0.0",
"Thoth.Json": "6.0.0"
}
},
"Thoth.Json": {
"type": "Transitive",
"resolved": "10.4.1",
"contentHash": "hs76/uO+gHhvnlaxQDqbpUX2Y0L97ilEZ1Nx+LA4D6N7fuAYJmNwQWZB/KQLBE7wIeWK5oXMFHCuKdImSrF1Bg==",
"dependencies": {
"FSharp.Core": "5.0.2",
"Fable.Core": "4.1.0"
}
},
"atlantis.api": {
"type": "Project",
"dependencies": {
"FSharp.Core": "[9.0.201, )",
"Hipster.Api": "[1.0.1, )",
"Petimeter.Api": "[1.0.0, )"
}
},
"hipster.api": {
"type": "Project",
"dependencies": {
"Dapr.Actors": "[1.16.0, )",
"Drifters.Api": "[6.22.0, )",
"FSharp.Core": "[9.0.201, )"
}
},
"lib": {
"type": "Project",
"dependencies": {
"Atlantis.Api": "[1.0.1, )",
"FSharp.Core": "[9.0.303, )",
"Fable.Browser.IndexedDB": "[2.2.0, )",
"Fable.Browser.WebGL": "[1.3.0, )",
"Fable.Core": "[4.4.0, )",
"Fable.Elmish": "[4.2.0, )",
"Fable.Fetch": "[2.7.0, )",
"Fable.Lit": "[1.6.2-oceanbox, )",
"Fable.Lit.Elmish": "[1.6.2-oceanbox, )",
"Fable.Lit.React": "[1.6.2-oceanbox, )",
"Fable.OpenLayers": "[2.19.0, )",
"Fable.Promise": "[3.2.0, )",
"Fable.React": "[9.4.0, )",
"Fable.Remoting.Client": "[7.32.0, )",
"Fable.Remoting.MsgPack": "[1.24.0, )",
"Fable.SignalR.Elmish": "[2.1.0, )",
"Fable.SimpleHttp": "[3.6.0, )",
"Feliz": "[2.9.0, )",
"Feliz.CompilerPlugins": "[2.2.0, )",
"FsToolkit.ErrorHandling": "[5.0.1, )",
"Hipster.Api": "[1.0.1, )",
"Matplotlib.ColorMaps": "[3.0.1, )",
"Oceanbox.DataAgent.Api": "[7.2.1, )",
"Petimeter.Api": "[1.0.0, )",
"Sorcerer.Api": "[4.19.0, )",
"Thoth.Fetch": "[3.0.1, )",
"Thoth.Json": "[10.4.1, )"
}
},
"Oceanbox.DataAgent.Api": {
"type": "Project",
"dependencies": {
"FSharp.Core": "[9.0.201, )"
}
},
"petimeter.api": {
"type": "Project",
"dependencies": {
"Dapr.Actors": "[1.16.0, )",
"FSharp.Core": "[9.0.201, )"
}
},
"sorcerer.api": {
"type": "Project",
"dependencies": {
"Drifters.Api": "[6.22.0, )",
"FSharp.Core": "[9.0.201, )",
"Oceanbox.DataAgent.Api": "[7.2.1, )"
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

View File

@@ -0,0 +1,150 @@
body {
font-family: Arial, Helvetica, sans-serif;
color: #333;
font-size: .9em;
}
h1 {
font-size: 1.5em;
color: #334499;
}
.m-8 {
margin: 8px;
}
.m-16 {
margin: 16px;
}
.ml-16 {
margin-left: 16px;
}
.mtb-16 {
margin: 16px 0px 16px 0px;
}
.p-8 {
padding: 8px;
}
.p-16 {
padding: 16px;
}
.flex-row {
display: flex;
flex-direction: row;
}
.flex-row-start {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.flex-row-center {
display: flex;
flex-direction: row;
align-items: center;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-basis-1 {
flex-basis: 8px;
}
.flex-basis-2 {
flex-basis: 16px;
}
.flex-basis-3 {
flex-basis: 32px;
}
.flex-basis-4 {
flex-basis: 64px;
}
.flex-basis-5 {
flex-basis: 128px;
}
.flex-basis-6 {
flex-basis: 256px;
}
.flex-basis-7 {
flex-basis: 512px;
}
.flex-basis-8 {
flex-basis: 1024px;
}
.grow {
flex-grow: 1;
}
.grow-2 {
flex-grow: 2;
}
.gap-8 {
gap: 8px;
}
.gap-16 {
gap: 16px;
}
.gap-32 {
gap: 32px;
}
.brad-8 {
border-radius: 8px;
}
.text-overflow {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.shadow {
box-shadow: rgba(50, 50, 93, 0.25) 0px 2px 5px -1px, rgba(0, 0, 0, 0.3) 0px 1px 3px -1px;
}
/* special stuff */
.archives-list {
flex: 1 1 384px;
min-width: 384px;
max-width: 512px;
select {
flex-basis: 9ch;
}
span {
display: block;
max-width: inherit;
}
}
.archives-list.drifters {
flex: 2 1 512px;
min-width: 512px;
max-width: 576px;
}

3
src/Codex/src/Client/run Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
exec fable -e .jsx -o build --verbose --watch --run bunx --bun vite -c ../../vite.config.js

View File

@@ -0,0 +1,328 @@
namespace Oceanbox.Codex
module Admin =
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging
open Fable.Remoting.Giraffe
open Fable.Remoting.Server
open FsToolkit.ErrorHandling
open Giraffe
open OpenFga.Sdk.Client
open Npgsql
open Oceanbox
module private Handler =
let addUsers (ctx: HttpContext) (req: Remoting.AddUsersRequest) : Async<Result<unit, string>> =
let archmaesterAdd (db: Entity.ArchiveContext) =
async {
try
let! created = Archmaester.EFCore.addUsers db req.Users
return Ok created
with e ->
return Error (sprintf "Error adding users: %s" e.Message)
}
let fgaAdd () =
async {
let fga = ctx.GetService<OpenFgaClient> ()
let tuples: Remoting.Tuple array =
req.Users
|> Array.collect (fun user ->
let fgaUser = sprintf "user:%s" user
let fgaGroup = sprintf "group:%s" req.Group
[|
{ Object = fgaUser; Relation = "active"; User = fgaUser; Condition = None }
{ Object = fgaUser; Relation = "registered"; User = fgaUser; Condition = None }
{ Object = fgaGroup; Relation = "member"; User = fgaUser; Condition = None }
|]
)
let req = OpenFGA.Queries.write tuples
let! resp = fga.Write req |> Async.AwaitTask
return Ok resp
}
asyncResult {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("addUsers {Request} from {User}", req, user)
try
// NOTE(simkir): Start a transaction so that we can wait for fga to succeed before committing
let db = ctx.GetService<Entity.ArchiveContext> ()
use tr = db.Database.BeginTransaction ()
try
let! created = archmaesterAdd db
do logger.LogInformation ("Created {Count} users", created)
let! fgaResp = fgaAdd ()
do logger.LogInformation ("OpenFGA write responded with: {JSON}", fgaResp.ToJson ())
do! tr.CommitAsync ()
return ()
with e ->
do logger.LogError ("OpenFGA write errored with: {Msg}. Rolling back archmaester.", e.Message)
do! tr.RollbackAsync ()
return! Error (sprintf "Error deleting users from OpenFGA: %s" e.Message)
with e ->
do logger.LogError (e, "Failed connecting to database")
return! Error (sprintf "Error deleting users from OpenFGA: %s" e.Message)
}
let addArchiveGroups (ctx: HttpContext) (req: Remoting.AddArchiveGroupsRequest) =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("Add archive groups from {User}: {Request}", user, req)
try
let db = ctx.GetService<Entity.ArchiveContext> ()
let fga = ctx.GetService<OpenFgaClient> ()
let! created = Archmaester.EFCore.addArchiveGroups db req.Id req.Groups
do logger.LogInformation ("Added {CreatedCount} archive group entries", created)
let req = OpenFGA.Group.addArchive req
let! fgaResp = fga.Write req |> Async.AwaitTask
do logger.LogInformation ("OpenFGA write responded with: {JSON}", fgaResp.ToJson ())
return Ok ()
with e ->
do logger.LogError(e, "Error adding group to archive")
return Error (sprintf "Error adding archive groups: %s" e.Message)
}
let deleteArchive (ctx: HttpContext) (archiveId: System.Guid) : Async<Result<bool, string>> =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("deleteArchive {ArchiveId} from {User}", archiveId, user)
try
let db = ctx.GetService<Entity.ArchiveContext> ()
let! deleted = Archmaester.EFCore.deleteArchive db archiveId
do
logger.LogInformation (
"deleteArchive {ArchiveId} from {User} completed with {DeleteCount} deleted entities",
archiveId,
user,
deleted
)
// NOTE: True that it was deleted if EF Core returns that it actually deleted entities/rows
return Ok (deleted > 0)
with
| :? System.AggregateException as e ->
let exists =
e.InnerExceptions
|> Seq.exists (fun inner -> inner :? System.InvalidOperationException)
if exists then
return Error "This archive does not exist"
else
do logger.LogError (e, "Error in deleteArchive from {User}", user)
return Error "Error deleting archive"
| :? System.InvalidOperationException -> return Error "This archive does not exist"
| ex ->
logger.LogError (
"Error deleting archive {ArchiveId} from {User}: {Error}",
archiveId,
user,
ex.Message
)
return Error (sprintf "Error deleting archive: %s" ex.Message)
}
let getArchive
(ctx: HttpContext)
(archiveId: System.Guid)
: Async<Result<Archmaester.Dto.ArchiveProps, string>> =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
try
do logger.LogInformation ("getArchive from {User}", user)
let db = ctx.GetService<Entity.ArchiveContext> ()
let! archiveOpt = Archmaester.EFCore.queryArchive db archiveId
match archiveOpt with
| Some archive ->
let dto: Archmaester.Dto.ArchiveProps = DataAgent.Archives.archiveToProps archive
return Ok dto
| None -> return Error "This archive does not exist"
with
| :? System.AggregateException as e ->
let exists =
e.InnerExceptions
|> Seq.exists (fun inner -> inner :? System.InvalidOperationException)
if exists then
return Error "This archive does not exist"
else
do logger.LogError (e, "Error in getArchive from {User}", user)
return Error "Error fetching archive"
| :? System.InvalidOperationException -> return Error "This archive does not exist"
| e ->
do logger.LogError (e, "Error in getArchive from {User}", user)
return Error "Error fetching archive"
}
let getArchiveRefs
(ctx: HttpContext)
(filter: Archmaester.Dto.ArchiveFilter)
: Async<Result<Archmaester.Dto.ArchiveProps array, string>> =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
try
do logger.LogInformation ("getArchiveRefs from {User}", user)
if filter.id.IsSome then
let db = ctx.GetService<Entity.ArchiveContext> ()
let! archives = Archmaester.EFCore.queryArchiveRefs db filter
let dtos: Archmaester.Dto.ArchiveProps array =
archives |> Array.map DataAgent.Archives.archiveToProps
return Ok dtos
else
return Error "Filter must include archive id"
with e ->
do logger.LogError (e, "Error in getArchives from {User}", user)
return Error "Error fetching archive count"
}
let getArchives
(ctx: HttpContext)
(page: int)
(rowsPerPage: int)
(filter: Archmaester.Dto.ArchiveFilter)
: Async<Result<Archmaester.Dto.ArchiveProps array, string>> =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
try
do logger.LogInformation ("getArchives from {User}", user)
let db = ctx.GetService<NpgsqlDataSource> ()
let! archives = Archmaester.Dapper.queryArchives db page rowsPerPage filter
let dtos: Archmaester.Dto.ArchiveProps array =
archives
|> Array.map (fun (archive, type') ->
let archiveType = Archmaester.Dto.ArchiveType.FromDbType(type'.kind, type'.variant, type'.format)
{
Archmaester.Dto.ArchiveProps.empty with
archiveId = archive.id
reference = archive.archive_ref_id
name = archive.name
archiveType = archiveType
frames = archive.frames
startTime = archive.start_time
created = archive.created
expires = archive.expires
isPublished = archive.published
isPublic = archive.``public``
json = archive.json |> Option.defaultValue ""
}
)
return Ok dtos
with e ->
do logger.LogError (e, "Error in getArchives from {User}", user)
return Error "Error fetching archives"
}
let getArchiveCount (ctx: HttpContext) filter =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
try
do logger.LogInformation ("getArchiveCount from {User}", user)
let db = ctx.GetService<NpgsqlDataSource> ()
let! count = Archmaester.Dapper.queryArchiveCount db filter
return Ok count
with e ->
do logger.LogError (e, "getArchiveCount from {User}", user)
return Error "Error fetching archive count"
}
let getArchiveTypes (ctx: HttpContext) =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("getArchiveCount from {User}", user)
let db = ctx.GetService<Entity.ArchiveContext> ()
let! entities = Archmaester.EFCore.queryArchiveTypes db
let dtos : Archmaester.Dto.ArchiveType array =
entities
|> Array.map (fun entity ->
Archmaester.Dto.ArchiveType.FromDbType(entity.Kind, entity.Variant, entity.Format)
)
return dtos
}
let getAllGroups (ctx: HttpContext) =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("getAllGroups from {User}", user)
let db = ctx.GetService<Entity.ArchiveContext> ()
let! groups = Archmaester.EFCore.queryGroups db
let names = groups |> Array.map _.Name
return names
}
let getGroupUsers (ctx: HttpContext) (group: string) =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("getGroupUsers from {User}", user)
let db = ctx.GetService<Entity.ArchiveContext> ()
let! users = Archmaester.EFCore.queryGroupUsers db group
let names = users |> Array.map _.Name
return names
}
let removeUsers (ctx: HttpContext) (users: string array) : Async<Result<unit, string>> =
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
let archmaesterDelete () =
async {
try
let db = ctx.GetService<Entity.ArchiveContext> ()
let! deleted = Archmaester.EFCore.deleteUsers db users
return Ok deleted
with e ->
logger.LogError (e, "Error deleting users from Archmaester")
return Error (sprintf "Error deleting users: %s" e.Message)
}
let fgaDelete () =
async {
try
let db = ctx.GetService<OpenFgaDb> ()
use conn = db.CreateConnection ()
let! deleted = OpenFGA.Db.deleteUsers conn logger users
return Ok deleted
with e ->
logger.LogError (e, "Error deleting users from OpenFGA")
return Error (sprintf "Error deleting users from OpenFGA: %s" e.Message)
}
asyncResult {
let user = ctx.User.Identity.Name
do logger.LogInformation ("removeUsers {Users} from user {DeleterUser}", users, user)
let! deleted = archmaesterDelete ()
do logger.LogInformation ("Deleted {Count} users from Archmaester", deleted)
let! fgaDeletedTuples = fgaDelete ()
do logger.LogInformation ("Deleted {Count} OpenFGA tuples", fgaDeletedTuples)
return ()
}
let private impl (ctx: HttpContext) : Remoting.Api.Admin = {
addUsers = Handler.addUsers ctx
addArchiveGroups = Handler.addArchiveGroups ctx
deleteArchive = Handler.deleteArchive ctx
getAllGroups = Handler.getAllGroups ctx
getArchive = Handler.getArchive ctx
getArchiveCount = Handler.getArchiveCount ctx
getArchiveRefs = Handler.getArchiveRefs ctx
getArchiveTypes = fun () -> Handler.getArchiveTypes ctx
getArchives = Handler.getArchives ctx
getGroupUsers = Handler.getGroupUsers ctx
removeUsers = Handler.removeUsers ctx
}
let endpoints: HttpHandler =
Remoting.createApi ()
|> Remoting.withErrorHandler Utils.rpcErrorHandler
|> Remoting.fromContext impl
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.buildHttpHandler

View File

@@ -0,0 +1,423 @@
namespace Oceanbox.Codex
module Archmaester =
open Npgsql
open Archmaester
let getDataSource (connStr: string) : NpgsqlDataSource =
let dataSourceBuilder = NpgsqlDataSourceBuilder connStr
let _mapper = dataSourceBuilder.UseNetTopologySuite ()
let dataSource = dataSourceBuilder.EnableParameterLogging().Build ()
dataSource
module Dapper =
open Dapper
open Dapper.FSharp.PostgreSQL
open Oceanbox.DataAgent.Dapper
let private canonicalizeGroupNames (groups: string array option) : string array =
groups
|> Option.defaultValue [||]
|> Array.choose (fun str ->
str
|> Utils.tryStr
|> Option.map (fun str -> if str[0] = '/' then str else "/" + str)
)
// NOTE: Try reusing the query for more cases. The order by is because we can't use it when counting
let private archivesQuery (select: string) (orderBy: string) : string =
$$"""
WITH groups AS (
SELECT
*
FROM
archive_groups
JOIN
groups on groups.id = archive_groups.group_id
WHERE
groups.name = ANY(@groups)
)
SELECT
{{select}}
FROM
archives
-- If group is not specified there wont be any rows ...
LEFT OUTER JOIN
groups on groups.archive_id = archives.id
JOIN
attribs on attribs.id = archives.attribs_id
JOIN
types on types.id = attribs.type_id
WHERE
-- ... so require that there is a match here when we filter by groups
(@no_groups OR groups.id IS NOT NULL)
AND (@kind::text = '*' OR types.kind = @kind::text)
AND (@variant::text = '*' OR types.variant = @variant::text)
AND (
@search_term::text IS NULL
OR archives.name ILIKE '%' || @search_term::text || '%'
OR archives.id::text ILIKE '%' || @search_term::text || '%'
)
{{orderBy}}
LIMIT
@limit
OFFSET
@offset
;
"""
let queryArchives
(db: NpgsqlDataSource)
(page: int)
(rowsPerPage: int)
(filter: Dto.ArchiveFilter)
: Async<(Table.Archive * Table.ArchiveType) array> =
async {
use conn = db.OpenConnection ()
let limit = if rowsPerPage < 0 then 1_000 else rowsPerPage
let offset = page * limit
let searchTermOpt = filter.searchTerm |> Option.bind Utils.tryStr
let kind, variant, format =
filter.archiveType
|> Option.map _.ToDbType()
|> Option.defaultValue ("*", "*", "*")
let groups = canonicalizeGroupNames filter.groups
let query =
let orderBy =
"""
ORDER BY
archives.name
"""
archivesQuery "archives.*, types.*" orderBy
let param =
dict [
"no_groups", box (Array.isEmpty groups)
"groups", box groups
"kind", box kind
"variant", box variant
"format", box format
"search_term", box searchTermOpt
"limit", box limit
"offset", box offset
]
let! archives =
conn.QueryAsync<Table.Archive, Table.ArchiveType, Table.Archive * Table.ArchiveType> (
query,
map = (fun archive archiveType -> archive, archiveType),
param = param
)
|> Async.AwaitTask
return archives |> Array.ofSeq
}
let queryArchiveCount (db: NpgsqlDataSource) (filter: Dto.ArchiveFilter) : Async<int> =
async {
use conn = db.OpenConnection ()
let searchTermOpt = filter.searchTerm |> Option.bind Utils.tryStr
let kind, variant, format =
filter.archiveType
|> Option.map _.ToDbType()
|> Option.defaultValue ("*", "*", "*")
let groups = canonicalizeGroupNames filter.groups
let query = archivesQuery "COUNT(*)" ""
let param =
dict [
"no_groups", box (Array.isEmpty groups)
"groups", box groups
"kind", box kind
"variant", box variant
"format", box format
"search_term", box searchTermOpt
"limit", box System.Int32.MaxValue
"offset", box 0
]
let! count = conn.ExecuteScalarAsync<int> (query, param = param) |> Async.AwaitTask
return count
}
module EFCore =
open System.Linq
open Microsoft.EntityFrameworkCore
open FSharp.Linq.NullableOperators
let addArchiveGroups (db: Entity.ArchiveContext) (archiveId: System.Guid) (groups: string array) : Async<int> =
async {
let groupEntities =
db.Groups.Where(fun group -> groups.Contains group.Name).ToArray ()
let newArchiveGroups =
groupEntities
|> Array.map (fun group ->
let entity = Entity.ArchiveGroup (ArchiveId = archiveId, GroupId = group.GroupId)
let tracking = db.Add entity
entity
)
let! created = db.SaveChangesAsync () |> Async.AwaitTask
return created
}
let addUsers (db: Entity.ArchiveContext) (users: string array) : Async<int> =
async {
let newUsers =
users
|> Array.map (fun userName ->
let entity = Entity.User (Name = userName)
let tracking = db.Add entity
entity
)
let! created = db.SaveChangesAsync () |> Async.AwaitTask
return created
}
let deleteArchive (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<int> =
async {
let! entity =
db.Archives
.Include(fun archive -> archive.Attribs)
.SingleOrDefaultAsync (fun archive -> archive.ArchiveId = archiveId)
|> Async.AwaitTask
// NOTE(simkir): Remove the archive's attribs to cascade properly. I guess this is because the archive
// is referencing the attribs, and not the other way around
let _entityEntry = db.Remove entity.Attribs
let! deleted = db.SaveChangesAsync () |> Async.AwaitTask
// NOTE: This includes cascaded deletes. However, it seem that it isn't the database doing it itself, but EF
// core sending delete statements to all related entities. Idk..
return deleted
}
let deleteUsers (db: Entity.ArchiveContext) (users: string array) : Async<int> =
async {
let! entities =
db.Users.Where(fun user -> users.Contains user.Name).ToArrayAsync ()
|> Async.AwaitTask
do entities |> Array.iter (fun entity -> db.Remove entity |> ignore)
let! deleted = db.SaveChangesAsync () |> Async.AwaitTask
return deleted
}
/// NOTE: This throws InvalidOperationException if the archive does not exist
let queryArchive (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<Entity.Archive option> =
async {
let! entity =
db.Archives
.AsNoTracking()
.Where(fun archive -> archive.ArchiveId = archiveId)
.Include(fun archive -> archive.Attribs)
.ThenInclude(fun attribs -> attribs.Type)
.SingleAsync ()
|> Async.AwaitTask
return Option.ofObj entity
}
let queryArchiveOwners (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<Entity.ArchiveOwner array> =
async {
let! entities =
db.ArchiveOwners
.AsNoTracking()
.Include(fun archive -> archive.Owner)
.Where(fun a -> a.ArchiveId = archiveId)
.ToArrayAsync ()
|> Async.AwaitTask
return entities
}
let queryArchiveGroups (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<Entity.ArchiveGroup array> =
async {
let! entities =
db.ArchiveGroups
.AsNoTracking()
.Include(fun archive -> archive.Group)
.Where(fun a -> a.ArchiveId = archiveId)
.ToArrayAsync ()
|> Async.AwaitTask
return entities
}
let queryArchiveUsers (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<Entity.ArchiveUser array> =
async {
let! entities =
db.ArchiveUsers
.AsNoTracking()
.Include(fun archive -> archive.User)
.Where(fun a -> a.ArchiveId = archiveId)
.ToArrayAsync ()
|> Async.AwaitTask
return entities
}
let queryArchiveAcl (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<Entity.ArchiveOwner array> =
async {
let! owners =
db.ArchiveOwners
.AsNoTracking()
.Include(fun archive -> archive.Owner)
.Where(fun a -> a.ArchiveId = archiveId)
.ToArrayAsync ()
|> Async.AwaitTask
return owners
}
let queryArchiveRefs (db: Entity.ArchiveContext) (filter: Dto.ArchiveFilter) : Async<Entity.Archive array> =
async {
let archiveId = filter.id |> Option.toNullable
let! entities =
db.Archives
.AsNoTracking()
.Where(fun archive -> archive.RefId ?=? archiveId)
.Include(fun archive -> archive.Attribs)
.ThenInclude(fun attribs -> attribs.Type)
.ToArrayAsync ()
|> Async.AwaitTask
return entities
}
let queryArchives
(db: Entity.ArchiveContext)
(page: int)
(rowsPerPage: int)
(filter: Dto.ArchiveFilter)
: Async<Entity.Archive array> =
async {
let kindOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let kind, _variant, _format = archiveType.ToDbType ()
kind
)
let variantOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let _kind, variant, _format = archiveType.ToDbType ()
variant
)
let groups =
filter.groups
|> Option.defaultValue [||]
|> Array.choose (fun str ->
str
|> Utils.tryStr
|> Option.map (fun str -> if str[0] = '/' then str else "/" + str)
)
let filtered =
db.Archives
.AsNoTracking()
.Include(fun archive -> archive.Attribs)
.ThenInclude(fun attribs -> attribs.Type)
.OrderBy(fun archive -> archive.Name)
.Where (fun archive ->
(kindOpt.IsNone
|| kindOpt.Value = "*"
|| kindOpt.Value = archive.Attribs.Type.Kind)
&& (variantOpt.IsNone
|| variantOpt.Value = "*"
|| variantOpt.Value = archive.Attribs.Type.Variant)
&& (filter.groups.IsNone
|| archive.Groups.Any (fun archiveGroup -> groups.Contains archiveGroup.Group.Name))
&& (filter.searchTerm.IsNone
|| archive.Name.Contains filter.searchTerm.Value
// NOTE: This causes a runtime error
|| archive.ArchiveId.ToString () = filter.searchTerm.Value)
)
let paginated =
if rowsPerPage > 0 then
filtered.Skip(page * rowsPerPage).Take (rowsPerPage)
else
filtered
let! entities = paginated.ToArrayAsync () |> Async.AwaitTask
return entities
}
let queryArchiveCount (db: Entity.ArchiveContext) (filter: Dto.ArchiveFilter) : Async<int> =
async {
let kindOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let kind, _variant, _format = archiveType.ToDbType ()
kind
)
let variantOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let _kind, variant, _format = archiveType.ToDbType ()
variant
)
let! (count: int) =
db.Archives
.Where(fun archive ->
(kindOpt.IsNone
|| kindOpt.Value = "*"
|| kindOpt.Value = archive.Attribs.Type.Kind)
&& (variantOpt.IsNone
|| variantOpt.Value = "*"
|| variantOpt.Value = archive.Attribs.Type.Variant)
&& (filter.searchTerm.IsNone || archive.Name.Contains filter.searchTerm.Value)
)
.CountAsync ()
|> Async.AwaitTask
return count
}
let queryArchiveTypes (db: Entity.ArchiveContext) : Async<Entity.Type array> =
async {
let! types = db.Types.AsNoTracking().ToArrayAsync () |> Async.AwaitTask
return types
}
let queryGroups (db: Entity.ArchiveContext) : Async<Entity.Group array> =
async {
let! groups = db.Groups.AsNoTracking().ToArrayAsync () |> Async.AwaitTask
return groups
}
let queryGroupUsers (db: Entity.ArchiveContext) (group: string) : Async<Entity.User array> =
async {
let! entities =
db.Users.AsNoTracking().Where(fun user -> user.Name.Contains group).ToArrayAsync ()
|> Async.AwaitTask
return entities
}
let queryGroupArchives (db: Entity.ArchiveContext) (groupName: string) : Async<Entity.Archive array> =
async {
let! entities =
db.Archives
.AsNoTracking()
.Include(fun archive -> archive.Attribs)
.Where(fun archive ->
archive.Groups.Any (fun archiveGroup -> archiveGroup.Group.Name.Contains groupName)
)
.ToArrayAsync ()
|> Async.AwaitTask
return entities
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<EnableDefaultContentItems>false</EnableDefaultContentItems>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PackageId>Codex.Server</PackageId>
<RootNamespace>Oceanbox</RootNamespace>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.8.0" />
<PackageReference Include="Fable.Remoting.Giraffe" Version="5.24.0" />
<PackageReference Include="Fargo.CmdLine" Version="1.7.5" />
<PackageReference Include="FsToolkit.ErrorHandling" Version="5.1.0" />
<PackageReference Include="Giraffe" Version="7.0.2" />
</ItemGroup>
<ItemGroup>
<Compile Include="../Shared/Remoting.fs" />
<Compile Include="Utils.fs" />
<Compile Include="Settings.fs" />
<Compile Include="Archmaester.fs" />
<Compile Include="OpenFGA.fs" />
<Compile Include="Admin.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.Development.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="web.config" CopyToOutputDirectory="PreserveNewest" />
<Content Include="WebRoot\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\ServerPack\src\Oceanbox.ServerPack.fsproj" />
<ProjectReference Include="..\..\..\Interfaces\Archmaester\Archmaester.Api.fsproj" />
<ProjectReference Include="..\..\..\DataAgent\src\Entity\Entity.csproj" />
<ProjectReference Include="..\..\..\DataAgent\src\DataAgent\Oceanbox.DataAgent.fsproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,332 @@
namespace Oceanbox.Codex
open Npgsql
// NOTE(simkir): Stub class for ASP.NET Dependency Injection types not clashing
type OpenFgaDb(source: NpgsqlDataSource) =
let source = source
member _.CreateConnection() : NpgsqlConnection = source.CreateConnection ()
module OpenFGA =
open System
open System.Text.Json
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging
open Fable.Remoting.Giraffe
open Fable.Remoting.Server
open Giraffe
open OpenFga.Sdk.Client
open OpenFga.Sdk.Client.Model
open OpenFga.Sdk.Model
module Db =
open Dapper.FSharp.PostgreSQL
// NOTE(simkir): Tuple is used internally by Dapper.FSharp, so the query will fail if the type starts with
// "Tuple"
//
// Ref: https://github.com/Dzoukr/Dapper.FSharp/blob/60d8e3f450e08a9c3440f896c22e60a04cd3d317/src/Dapper.FSharp/PostgreSQL/Builders.fs#L35
type private RecordTuple = {
store: string
object_type: string
object_id: string
relation: string
_user: string
user_type: string
ulid: string
inserted_at: DateTime
condition_name: string option
condition_context: byte array option
}
let private tupleTable = table'<RecordTuple> "tuple"
let getDataSource (connStr: string) : NpgsqlDataSource =
let dataSourceBuilder = NpgsqlDataSourceBuilder connStr
let dataSource = dataSourceBuilder.EnableParameterLogging().Build ()
do OptionTypes.register ()
dataSource
let deleteUsers (db: NpgsqlConnection) (logger: ILogger) (users: string array) : Async<int> =
async {
let fgaUsers = users |> Array.map (sprintf "user:%s") |> Array.toList
do logger.LogInformation ("Deleting OpenFGA users: {Users}", String.Join (", ", fgaUsers))
let tupleQuery =
delete {
for t in tupleTable do
where (isIn t._user fgaUsers)
}
let! deleted = db.DeleteAsync (tupleQuery, logFunction = logger.LogDebug) |> Async.AwaitTask
return deleted
}
module Queries =
let readResponseToDto (resp: ReadResponse) : Remoting.ReadResponse =
let tuples: Remoting.TupleContainer array =
resp.Tuples
|> Seq.toArray
|> Array.map (fun t ->
let condition : Remoting.Condition option =
Option.ofObj t.Key.Condition
|> Option.map (fun cond -> {
Name = cond.Name
Context = JsonSerializer.Serialize cond.Context
})
{
Timestamp = t.Timestamp
Key = {
Object = t.Key.Object
Relation = t.Key.Relation
User = t.Key.User
Condition = condition
}
}
)
{ ContinuationToken = resp.ContinuationToken; Tuples = tuples }
let condition (name: string) (context: 'T) : RelationshipCondition =
let condition = RelationshipCondition ()
do condition.Name <- name
do condition.Context <- context
condition
let check (req: Remoting.CheckRequest) : ClientCheckRequest =
let result = ClientCheckRequest ()
do result.User <- req.User
do result.Relation <- req.Relation
do result.Object <- req.Object
result
let listUsers (req: Remoting.ListUsersRequest) : ClientListUsersRequest =
let result = ClientListUsersRequest ()
let userFilters =
ResizeArray [
UserTypeFilter (req.UserFilter.Type, ?relation = req.UserFilter.Relation)
]
do result.Object <- FgaObject (req.Object, req.Id)
do result.Relation <- req.Relation
do result.UserFilters <- userFilters
do
req.Context
|> Option.iter (fun str ->
let json = Text.Json.JsonSerializer.Deserialize str
result.Context <- json
)
result
let listObjects (req: Remoting.ListObjectsRequest) : ClientListObjectsRequest =
let result = ClientListObjectsRequest ()
do result.User <- req.User
do result.Relation <- req.Relation
do result.Type <- req.Type
do
req.Context
|> Option.iter (fun str ->
let json = Text.Json.JsonSerializer.Deserialize str
result.Context <- json
)
result
let read (req: Remoting.ReadRequest) : ClientReadRequest =
let result = ClientReadRequest ()
do req.User |> Option.iter (fun user -> result.User <- user)
do req.Relation |> Option.iter (fun relation -> result.Relation <- relation)
do req.Object |> Option.iter (fun obj -> result.Object <- obj)
result
let delete (tuples: Remoting.Tuple array) =
let result = ClientWriteRequest ()
let deletes: ClientTupleKeyWithoutCondition array =
tuples
|> Array.map (fun tuple ->
let result = ClientTupleKeyWithoutCondition ()
do result.Object <- tuple.Object
do result.Relation <- tuple.Relation
do result.User <- tuple.User
result
)
do result.Deletes <- ResizeArray deletes
result
let write (tuples: Remoting.Tuple array) =
let result = ClientWriteRequest ()
let writes: ClientTupleKey array =
tuples
|> Array.map (fun tuple ->
let result = ClientTupleKey ()
do result.Object <- tuple.Object
do result.Relation <- tuple.Relation
do result.User <- tuple.User
result
)
do result.Writes <- ResizeArray writes
result
let write' (writes: ClientTupleKey array) =
let result = ClientWriteRequest ()
do result.Writes <- ResizeArray writes
result
module Group =
/// Create group view archive tuple
///
/// E.g. csv: group,/scaleaq#member,view,archive,b9053add-2d2d-4907-8762-5f14eb055e26,term,"{""start_time"": ""2025-09-13T00:00:00Z"", ""end_time"": ""2026-03-15T00:00:00Z""}"
let viewArchive (id: Guid) (term: Remoting.ViewTerm) (group: string) : ClientTupleKey =
let tuple = ClientTupleKey ()
do tuple.Object <- sprintf "archive:%s" (string id)
do tuple.Relation <- "view"
do tuple.User <- sprintf "group:%s#member" group
let condition = Queries.condition "term" term.JsonObj
do tuple.Condition <- condition
tuple
/// E.g. in csv: group,/scaleaq#member,exec,archive,b9053add-2d2d-4907-8762-5f14eb055e26,ticket,"{""tasks"": [ ""*"" ], ""quota"": ""-1.0"", ""start_time"": ""2025-09-13T00:00:00Z"", ""end_time"": ""2026-03-15T00:00:00Z""}"
let execArchive (id: Guid) (ticket: Remoting.ExecTicket) (group: string) : ClientTupleKey =
let tuple = ClientTupleKey ()
do tuple.Object <- sprintf "archive:%s" (string id)
do tuple.Relation <- "exec"
do tuple.User <- sprintf "group:%s#member" group
let condition = Queries.condition "ticket" ticket.JsonObj
do tuple.Condition <- condition
tuple
/// Creates write tuples for adding an archive to a group
let addArchive (req: Remoting.AddArchiveGroupsRequest) : ClientWriteRequest =
req.Groups
|> Array.collect (fun group -> [|
match req.View with
| Some view -> viewArchive req.Id view group
| None -> ()
match req.Exec with
| Some exec -> execArchive req.Id exec group
| None -> ()
|])
|> Queries.write'
module private Handlers =
let check (ctx: HttpContext) (req: Remoting.CheckRequest) =
async {
let logger = ctx.GetLogger<Remoting.Api.OpenFGA> ()
let fga = ctx.GetService<OpenFgaClient> ()
try
let checkRequest = Queries.check req
do logger.LogInformation ("Check req: {Request}", checkRequest.ToJson ())
let! resp = fga.Check checkRequest |> Async.AwaitTask
if resp.Allowed.HasValue then
return Ok resp.Allowed.Value
else
do logger.LogError "OpenFGA's Allowed field was null"
return Error "OpenFGA did not return an allowed value"
with e ->
do logger.LogError (e, "Error checking OpenFGA permission")
return Error "Could not check OpenFGA permission"
}
let delete (ctx: HttpContext) (tuple: Remoting.Tuple) =
async {
let logger = ctx.GetLogger<Remoting.Api.OpenFGA> ()
let fga = ctx.GetService<OpenFgaClient> ()
try
let deleteRequest = Queries.delete [| tuple |]
do logger.LogInformation ("Delete req: {Request}", deleteRequest.ToJson ())
let! resp = fga.Write deleteRequest |> Async.AwaitTask
do logger.LogInformation ("Delete resp: {Response}", resp.ToJson ())
return Ok (resp.Deletes.Count >= 1)
with e ->
do logger.LogError (e, "Error deleting OpenFGA tuples")
return Error "Could not delete OpenFGA tuples"
}
let listObjects (ctx: HttpContext) (req: Remoting.ListObjectsRequest) =
async {
let logger = ctx.GetLogger<Remoting.Api.OpenFGA> ()
let fga = ctx.GetService<OpenFgaClient> ()
try
let req = Queries.listObjects req
do logger.LogInformation ("List Objects req: {Request}", req.ToJson ())
let! resp = fga.ListObjects req |> Async.AwaitTask
do logger.LogInformation ("List Objects resp: {Response}", resp.ToJson ())
let dtos: string array = resp.Objects |> Seq.toArray
return Ok dtos
with e ->
do logger.LogError (e, "Error fetching OpenFGA users")
return Error "Could not fetch OpenFGA user objects"
}
let listUsers (ctx: HttpContext) (req: Remoting.ListUsersRequest) =
async {
let logger = ctx.GetLogger<Remoting.Api.OpenFGA> ()
let fga = ctx.GetService<OpenFgaClient> ()
try
let usersRequest = Queries.listUsers req
do logger.LogInformation ("List Users req: {Request}", usersRequest.ToJson ())
let! resp = fga.ListUsers usersRequest |> Async.AwaitTask
let ids: string array =
match req.UserFilter.Relation with
| Some _relation -> resp.Users |> Seq.toArray |> Array.map _.Userset.Id
| None -> resp.Users |> Seq.toArray |> Array.map _.Object.Id
return Ok ids
with e ->
do logger.LogError (e, "Error fetching OpenFGA users")
return Error "Could not fetch OpenFGA user objects"
}
let read (ctx: HttpContext) (req: Remoting.ReadRequest) =
async {
let logger = ctx.GetLogger<Remoting.Api.OpenFGA> ()
let fga = ctx.GetService<OpenFgaClient> ()
let readRequest = Queries.read req
do logger.LogInformation ("Read req: {Request}", readRequest.ToJson ())
let! resp = fga.Read readRequest |> Async.AwaitTask
do logger.LogInformation ("Read resp: {Response}", resp.ToJson ())
let result: Remoting.ReadResponse = resp |> Queries.readResponseToDto
do logger.LogInformation ("Read result: {Result}", result)
return Ok result
}
let private impl (ctx: HttpContext) : Remoting.Api.OpenFGA = {
Check = Handlers.check ctx
Delete = Handlers.delete ctx
ListObjects = Handlers.listObjects ctx
ListUsers = Handlers.listUsers ctx
Read = Handlers.read ctx
}
let endpoints: HttpHandler =
Remoting.createApi ()
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.fromContext impl
|> Remoting.buildHttpHandler

View File

@@ -0,0 +1,296 @@
module Oceanbox.Codex.Server
open System
open System.IO
open Microsoft.AspNetCore.Authentication.OpenIdConnect
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Cors.Infrastructure
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.Logging
open Azure.Identity
open Azure.Security.KeyVault.Secrets
open Fable.Remoting.Giraffe
open Fable.Remoting.Server
open FsToolkit.ErrorHandling
open Giraffe
open OpenFga.Sdk.Client
open Oceanbox.ServerPack
let private tryGetFromVault (vault: string) (key: string) : Async<string option> =
async {
let client = SecretClient (Uri vault, EnvironmentCredential ())
let! s = client.GetSecretAsync key |> Async.AwaitTask
if s.HasValue then
return Some s.Value.Value
else
eprintfn "[Server] tryGetFromVault key not found: %s" key
return None
}
// ---------------------------------
// RPC Impl.
// ---------------------------------
module Auth =
module private Handlers =
let IsAuthenticated (ctx: HttpContext) : Async<bool> =
async { return ctx.User.Identity.IsAuthenticated }
let private impl (ctx: HttpContext) : Remoting.Api.Auth = { IsAuthenticated = Handlers.IsAuthenticated ctx }
let endpoints: HttpHandler =
Remoting.createApi ()
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.fromContext impl
|> Remoting.buildHttpHandler
module Acl =
module private Handlers =
let getAcl
(ctx: HttpContext)
(aid: Archmaester.Dto.ArchiveId)
: Async<Result<Archmaester.Dto.ArchiveAcl, string>> =
async {
let logger = ctx.GetLogger "Oceanbox.Codex.Server.Acl"
let db = ctx.GetService<Entity.ArchiveContext> ()
let! ownerEntities = Archmaester.EFCore.queryArchiveOwners db aid
let ownerNames = ownerEntities |> Array.map _.Owner.Name
do logger.LogDebug ("getAcl owners {Owners}", ownerNames)
let! groupEntities = Archmaester.EFCore.queryArchiveGroups db aid
let groupNames = groupEntities |> Array.map _.Group.Name
do logger.LogDebug ("getAcl groups {Groups}", groupNames)
let! userEntities = Archmaester.EFCore.queryArchiveUsers db aid
let userNames = userEntities |> Array.map _.User.Name
do logger.LogDebug ("getAcl users {Owners}", userNames)
let dto: Archmaester.Dto.ArchiveAcl = {
owners = ownerNames
groups = groupNames
users = userNames
shares = [||]
}
return Ok dto
}
let addGroups (ctx: HttpContext) =
fun (archiveId: Guid, groups: string array) ->
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger "Oceanbox.Codex.Server.Acl"
try
do
logger.LogInformation (
"addGroups {Groups} to {ArchiveId} from {User}",
groups,
archiveId,
user
)
let db = ctx.GetService<Entity.ArchiveContext> ()
let! created = Archmaester.EFCore.addArchiveGroups db archiveId groups
if created > 0 then
do
logger.LogInformation (
"addGroups {Groups} to {ArchiveId} from {User} completed with {CreateCount} entities created",
groups,
archiveId,
user,
created
)
return Ok ()
else
do
logger.LogError (
"addGroups {Groups} to {ArchiveId} from {User} failed with {CreateCount} entities created",
groups,
archiveId,
user,
created
)
return Error "No groups were added to the archive"
with _ ->
return Error "Error adding groups to archive"
}
let private impl (ctx: HttpContext) : Archmaester.Api.Acl = {
getAcl = Handlers.getAcl ctx
addOwners = Utils.notImplemented
addUsers = Utils.notImplemented
addGroups = Handlers.addGroups ctx
removeOwners = Utils.notImplemented
removeUsers = Utils.notImplemented
removeGroups = Utils.notImplemented
}
let endpoints: HttpHandler =
Remoting.createApi ()
|> Remoting.withRouteBuilder Remoting.routeBuilder
|> Remoting.fromContext impl
|> Remoting.buildHttpHandler
// ---------------------------------
// Web app
// ---------------------------------
let authorize: HttpHandler =
requiresAuthentication (challenge OpenIdConnectDefaults.AuthenticationScheme)
let signin (next: HttpFunc) (ctx: HttpContext) =
let logger = ctx.GetLogger "Oceanbox.Codex.Server"
do logger.LogInformation ("signin: is authenticated {IsAuthed}", ctx.User.Identity.IsAuthenticated)
(authorize >=> redirectTo false "/") next ctx
let webApp: HttpHandler =
choose [
route "/signin" >=> signin
routeStartsWith "/api"
>=> choose [
Auth.endpoints
authorize >=> choose [ Acl.endpoints; Admin.endpoints; OpenFGA.endpoints ]
]
]
// ---------------------------------
// Error handler
// ---------------------------------
let errorHandler (ex: Exception) (logger: ILogger) =
logger.LogError (ex, "An unhandled exception has occurred while executing the request.")
clearResponse >=> setStatusCode 500 >=> text ex.Message
// ---------------------------------
// Config and Main
// ---------------------------------
let configureCors (builder: CorsPolicyBuilder) =
builder
.WithOrigins(
[|
"http://localhost:8085"
"http://*.oceanbox.io"
"https://*.oceanbox.io"
"https://*.oceanbox.io:8080"
|]
)
.AllowAnyMethod()
.AllowAnyHeader()
.SetIsOriginAllowedToAllowWildcardSubdomains ()
|> ignore
let configureApp (app: IApplicationBuilder) =
let env = app.ApplicationServices.GetService<IWebHostEnvironment> ()
if env.IsDevelopment () then
app.UseDeveloperExceptionPage () |> ignore
else
app.UseGiraffeErrorHandler errorHandler |> ignore
app
.UseCors(configureCors)
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseDefaultFiles()
.UseStaticFiles()
.UseGiraffe webApp
let configureServices (settings: Settings) (services: IServiceCollection) =
let authSettings = settings.Auth
let fga: OpenFgaClient = Fga.newFgaClient settings.Fga
let archmaesterDatasource = Archmaester.getDataSource settings.DbConnectionString
do Oceanbox.DataAgent.Dapper.register ()
// NOTE: For direct db queries
let fgaDataSource = OpenFGA.Db.getDataSource settings.OpenFgaDbConnectionString
do MultiAuth.configureMultiAuthServices authSettings services |> ignore
do
services
.AddSingleton<Settings>(settings)
.AddSingleton<OpenFgaClient>(fga)
.AddSingleton<Npgsql.NpgsqlDataSource>(archmaesterDatasource)
.AddSingleton<OpenFgaDb>(OpenFgaDb(fgaDataSource))
.AddTransient<Entity.ArchiveContext>(fun _ -> new Entity.ArchiveContext (archmaesterDatasource, true))
.AddRouting()
.AddCors()
.AddGiraffe ()
|> ignore
()
let configureLogging (builder: ILoggingBuilder) =
builder.AddConsole().AddDebug () |> ignore
let private fetchSecrets (settings: Settings) : Async<unit> =
let inner () : Async<unit option> =
asyncOption {
let app = "atlantis" // settings.appName
let env = settings.Auth.sso.environment
let! keyVault = settings.Auth.sso.keyVault
let! oidcClientSecret = tryGetFromVault keyVault.uri (sprintf "%s-%s-oidc-client-secret" env app)
do Environment.SetEnvironmentVariable ("OIDC_CLIENT_SECRET", oidcClientSecret)
do settings.Auth <- { settings.Auth with oidc.clientSecret = oidcClientSecret }
let! azureStorageToken = tryGetFromVault keyVault.uri (sprintf "%s-azure-storage-token" env)
do Environment.SetEnvironmentVariable ("AZURE_STORAGE_TOKEN", azureStorageToken)
return ()
}
async {
let! _opt = inner ()
return ()
}
open Fargo
let parser = opt "port" "p" "port" "Port to listen on"
[<EntryPoint>]
let main args =
run
"Codex"
parser
args
(fun ct text ->
task {
let port = text |> Option.map int |> Option.defaultValue 8085
let contentRoot = Directory.GetCurrentDirectory ()
let webRoot = Path.Combine (contentRoot, "WebRoot")
// TODO(simkir): Create the logger here
let settings = Settings ()
do! fetchSecrets settings
let host =
WebHostBuilder()
.UseKestrel()
.UseUrls([| sprintf "http://0.0.0.0:%d" port |])
.UseContentRoot(contentRoot)
.UseWebRoot(webRoot)
.Configure(Action<IApplicationBuilder> configureApp)
.ConfigureServices(configureServices settings)
.ConfigureLogging(configureLogging)
.Build ()
do! host.StartAsync ct
// Do stuff
eprintfn "Hello, I've started the web server, but now I'll wait for shutdown"
do! host.WaitForShutdownAsync ct
return 0
}
)

View File

@@ -0,0 +1,84 @@
namespace Oceanbox.Codex
open Oceanbox
type Appsettings = { OIDC: ServerPack.MultiAuth.OpenIdSettings; SSO: ServerPack.MultiAuth.SsoSettings }
type Settings() =
let chooseAppsettings env =
match env with
| "Development"
| "dev" -> "appsettings.Development.json"
| _ -> "appsettings.json"
let tryGetEnv = System.Environment.GetEnvironmentVariable >> Utils.tryStr
let env = tryGetEnv "DOTNET_ENVIRONMENT" |> Option.defaultValue "dev"
let file =
let appsettings = chooseAppsettings env
eprintfn "[Settings] Reading %s from disk" appsettings
let text = System.IO.File.ReadAllText appsettings
eprintfn "[Settings] Reading %s from disk completed" appsettings
try
System.Text.Json.JsonSerializer.Deserialize text
with e ->
failwithf "Exception serializing Appsettings: %s" e.Message
// NOTE(simkir): I'm sorry :(
let mutable auth: ServerPack.MultiAuth.MultiAuthSettings = {
oidc = file.OIDC
sso = file.SSO
plainAuthUsers = [||]
}
// Database settings
let host =
tryGetEnv "DB_HOST"
|> Option.defaultValue "simkir-atlantis-db-rw.simkir-atlantis.svc.cluster.local"
let port = tryGetEnv "DB_PORT" |> Option.map int |> Option.defaultValue 5432
let username = tryGetEnv "DB_USER" |> Option.defaultValue "postgres"
let password = tryGetEnv "DB_PASSWORD"
let database = tryGetEnv "DB_DATABASE" |> Option.defaultValue "app"
let fgaUrl: string = tryGetEnv "FGA_URL" |> Option.defaultValue "http://staging-openfga.staging-openfga.svc.cluster.local:8080"
let fgaStoreId: string = tryGetEnv "FGA_STORE" |> Option.defaultValue "01JKTZXMP7ANN4GG2P5W8Y56M6"
let fgaModelId: string = tryGetEnv "FGA_MODEL" |> Option.defaultValue "01JKTZYMCZZBVSBG66W27XMW0A"
let fgaHost =
tryGetEnv "FGA_DB_HOST"
|> Option.defaultValue "staging-openfga-db-rw.staging-openfga"
let fgaPort = tryGetEnv "FGA_DB_PORT" |> Option.map int |> Option.defaultValue 5432
let fgaUsername = tryGetEnv "FGA_DB_USER" |> Option.defaultValue "postgres"
let fgaPassword = tryGetEnv "FGA_DB_PASSWORD"
let fgaDatabase = tryGetEnv "FGA_DB_DATABASE" |> Option.defaultValue "app"
//
// The actually exposed settings under here
//
member _.Env = env
member _.File: Appsettings = file
member _.Auth
with get () = auth
and set newAuth = auth <- newAuth
/// pgpass: simkir-atlantis-db-rw:5432:*:postgres:dA9KiBRfmP8E36TK8kjEcsPnVLTuIOryI96S9AiaUd6VNGZHaXqGT0bmHSHoRZkJ
member _.DbConnectionString: string =
match password with
| Some pwd -> sprintf "Username=%s;Password=%s;Host=%s;Port=%i;Database=%s;" username pwd host port database
| None -> failwith "DB_PASSWORD not set in environment variables!"
member _.OpenFgaDbConnectionString: string =
match fgaPassword with
| Some pwd ->
sprintf "Username=%s;Password=%s;Host=%s;Port=%i;Database=%s;" fgaUsername pwd fgaHost fgaPort fgaDatabase
| None -> failwith "FGA_DB_PASSWORD not set in environment variables!"
member _.Fga: ServerPack.Fga.FgaConnectionSettings = {
apiUrl = fgaUrl
apiKey = None
storeId = fgaStoreId
modelId = fgaModelId
}

View File

@@ -0,0 +1,32 @@
namespace Oceanbox.Codex
open System
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging
open Fable.Remoting.Server
open Giraffe
exception NotImplemented
module Utils =
let inline notImplemented (_: 'T) : 'U = raise NotImplemented
let rpcErrorHandler (ex: exn) (routeInfo: RouteInfo<HttpContext>) =
let ctx = routeInfo.httpContext
let logger = ctx.GetLogger "Oceanbox.Codex.Server"
do logger.LogError (ex, "Unhandeled error in RPC {Path}", routeInfo.path)
match ex with
| :? IO.IOException as x ->
let customError = Remoting.createError "Something terrible happened"
Propagate customError
| :? NotImplemented ->
let customError = Remoting.createError "This function has not been implemented yet"
Propagate customError
| _ -> Ignore
let strNull = String.IsNullOrWhiteSpace
let strNotNull = strNull >> not
let tryStr str =
if strNotNull str then Some str else None

View File

@@ -0,0 +1 @@
Host: auth.oceanbox.io

View File

@@ -0,0 +1,66 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting": "Error"
}
},
"Debug": {
"LogLevel": {
"Default": "Debug"
}
},
"Console": {
"IncludeScopes": true,
"LogLevel": {
"Default": "Debug"
}
},
"OIDC": {
"issuer": "https://auth.oceanbox.io/realms/oceanbox",
"authorization_endpoint": "https://auth.oceanbox.io/realms/oceanbox/protocol/openid-connect/auth",
"token_endpoint": "https://auth.oceanbox.io/realms/oceanbox/protocol/openid-connect/token",
"jwks_uri": "https://auth.oceanbox.io/realms/oceanbox/protocol/openid-connect/certs",
"userinfo_endpoint": "https://auth.oceanbox.io/realms/oceanbox/protocol/openid-connect/userinfo",
"end_session_endpoint": "https://auth.oceanbox.io/realms/oceanbox/protocol/openid-connect/logout",
"device_authorization_endpoint": "https://auth.oceanbox.io/realms/oceanbox/protocol/openid-connect/auth/device",
"clientId": "atlantis_dev",
"clientSecret": "",
"scopes": [
"openid",
"email",
"offline_access",
"profile"
],
"audiences": [
"atlantis_dev"
]
},
"SSO": {
"cookieDomain": ".oceanbox.io",
"cookieName": ".obx.staging",
"ttl": 12.0,
"signedOutRedirectUri": "https://simkir-atlantis.dev.oceanbox.io/",
"realm": "atlantis",
"environment": "staging",
"keyStore": {
"kind": "azure",
"uri": "https://atlantis.blob.core.windows.net",
"key": "dataprotection-keys"
},
"keyVault": {
"kind": "azure",
"uri": "https://atlantisvault.vault.azure.net",
"key": "dataencryption-keys"
}
},
"plainAuthUsers": [
{
"username": "admin",
"password": "en-to-tre-fire",
"groups": [ "/oceanbox" ],
"roles": [ "admin" ]
}
]
}

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,41 @@
{
deps,
pkgs,
client,
dotnet-sdk,
netrcConfig,
dotnet-runtime,
packageSources,
buildDotnetModule,
}:
let
name = "Codex";
in
buildDotnetModule {
pname = name;
version = "0.0.0-alpha.1";
inherit dotnet-sdk dotnet-runtime;
src = ../../../..;
projectFile = "src/Codex/src/Server/Codex.Server.fsproj";
dotnetRestoreFlags = "--force-evaluate";
nugetDeps = deps {
pkgs = pkgs;
name = name;
netrcConfig = netrcConfig;
packageSources = packageSources;
lockfiles = [
./packages.lock.json
../../../DataAgent/src/Entity/packages.lock.json
];
};
doCheck = false;
postInstall = ''
rm -rf $out/lib/Codex/WebRoot
cp -r ${client}/WebRoot $out/lib/Codex
'';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments="Codex.Server.dll" stdoutLogEnabled="false" stdoutLogFile="logs/stdout" />
</system.webServer>
</configuration>

View File

@@ -0,0 +1,163 @@
namespace Oceanbox.Codex
module Remoting =
type CustomError = { errorMsg: string }
let createError msg : CustomError = { errorMsg = msg }
let routeBuilder (typeName: string) (methodName: string) =
sprintf "/api/v1/%s/%s" typeName methodName
type Group = { Name: string }
type Condition = {
Name: string
/// NOTE: JSON from the OpenFGA database
Context: string
}
type Tuple = {
User: string
Relation: string
Object: string
Condition: Condition option
} with
static member empty = {
User = ""
Relation = ""
Object = ""
Condition = None
}
static member delete(user: string, relation: string, object: string) = {
User = user
Relation = relation
Object = object
Condition = None
}
type CheckRequest = {
User: string
Relation: string
Object: string
}
type ReadRequest = {
/// E.g.: Some "group:/mowi"
User: string option
/// E.g.: Some "view"
Relation: string option
/// E.g.: Some "archive:23feab9e-e9af-49fd-a740-33c0b63ffd0b"
Object: string option
}
type ReadResponse = { ContinuationToken: string; Tuples: TupleContainer array }
and TupleContainer = { Key: Tuple; Timestamp: System.DateTime }
type ListObjectsRequest = {
/// customer@domain.com
User: string
/// exec
Relation: string
/// archive
Type: string
/// E.g.: { "time": "2025-10-12T23:20:50.52Z" }
Context: string option
}
type UserFilter = { Type: string; Relation: string option }
type ListUsersRequest = {
/// E.g.: "archive"
Object: string
/// E.g.: "23feab9e-e9af-49fd-a740-33c0b63ffd0b"
Id: string
/// E.g.: "view"
Relation: string
/// E.g.: "user"
UserFilter: UserFilter
/// E.g.: { "time": "2025-10-12T23:20:50.52Z" }
Context: string option
}
// NOTE(simkir): Could have used [<JsonPropertyName("start_time")>], but the frontend cannot use System.Text.Json(?)
/// OpenFGA Archive view relation term context
[<Struct>]
type ViewTerm = {
StartTime: System.DateTime
EndTime: System.DateTime
} with
// NOTE(simkir): OpenFga uses System.Text.Json directly on the object, so the context keys must match exactly
/// Creates a tmp object with snake case and UTC times to upload as OpenFGA context object
member this.JsonObj = {|
start_time = this.StartTime.ToUniversalTime()
end_time = this.EndTime.ToUniversalTime()
|}
static member empty = {
StartTime = System.DateTime.Now
EndTime = System.DateTime.Now
}
/// OpenFGA Archive exec relation ticket context
[<Struct>]
type ExecTicket = {
Tasks: string array
Quota: float
StartTime: System.DateTime
EndTime: System.DateTime
} with
/// Creates a tmp object with snake case and UTC times to upload as OpenFGA context object
member this.JsonObj = {|
tasks = this.Tasks
quota = this.Quota
start_time = this.StartTime.ToUniversalTime()
end_time = this.EndTime.ToUniversalTime()
|}
static member empty = {
Tasks = [| "*" |]
Quota = -1.0
StartTime = System.DateTime.Now
EndTime = System.DateTime.Now
}
[<Struct>]
type AddArchiveGroupsRequest = {
Id: Archmaester.Dto.ArchiveId
Groups: string array
View: ViewTerm option
Exec: ExecTicket option
}
[<Struct>]
type AddUsersRequest = {
Group: string
Users: string array
}
[<RequireQualifiedAccess>]
module Api =
type Auth = { IsAuthenticated: Async<bool> }
type Admin = {
addUsers: AddUsersRequest -> Async<Result<unit, string>>
addArchiveGroups: AddArchiveGroupsRequest -> Async<Result<unit, string>>
deleteArchive: Archmaester.Dto.ArchiveId -> Async<Result<bool, string>>
getAllGroups: Async<string array>
getArchive: Archmaester.Dto.ArchiveId -> Async<Result<Archmaester.Dto.ArchiveProps, string>>
getArchiveCount: Archmaester.Dto.ArchiveFilter -> Async<Result<int, string>>
getArchiveRefs: Archmaester.Dto.ArchiveFilter -> Async<Result<Archmaester.Dto.ArchiveProps array, string>>
getArchiveTypes: unit -> Async<Archmaester.Dto.ArchiveType array>
getArchives: int -> int -> Archmaester.Dto.ArchiveFilter -> Async<Result<Archmaester.Dto.ArchiveProps array, string>>
getGroupUsers: string -> Async<string array>
removeUsers: string array -> Async<Result<unit, string>>
}
type OpenFGA = {
Check: CheckRequest -> Async<Result<bool, string>>
Delete: Tuple -> Async<Result<bool, string>>
ListObjects: ListObjectsRequest -> Async<Result<string array, string>>
ListUsers: ListUsersRequest -> Async<Result<string array, string>>
Read: ReadRequest -> Async<Result<ReadResponse, string>>
}

View File

@@ -0,0 +1,30 @@
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: simkir-user-portal
name: simkir-user-portal
namespace: simkir-atlantis
spec:
replicas: 1
selector:
matchLabels:
app: simkir-user-portal
template:
metadata:
labels:
app: simkir-user-portal
spec:
containers:
- image: yolo-registry.dev.oceanbox.io/simkir/user-portal
imagePullPolicy: Always
name: user-portal
ports:
- containerPort: 8085
protocol: TCP
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
terminationGracePeriodSeconds: 30

View File

@@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/backend-protocol: HTTP
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,213.239.94.191/32
name: simkir-user-portal
namespace: simkir-atlantis
spec:
ingressClassName: nginx
rules:
- host: simkir-user-portal.dev.oceanbox.io
http:
paths:
- backend:
service:
name: simkir-user-portal
port:
number: 8085
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- simkir-user-portal.dev.oceanbox.io
secretName: simkir-user-portal-tls

138
src/Codex/tilt/k8s.yaml Normal file
View File

@@ -0,0 +1,138 @@
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: simkir-codex
name: simkir-codex
namespace: simkir-atlantis
spec:
replicas: 1
selector:
matchLabels:
app: simkir-codex
template:
metadata:
labels:
app: simkir-codex
spec:
containers:
- image: yolo-registry.dev.oceanbox.io/simkir/codex
imagePullPolicy: Always
name: codex
ports:
- containerPort: 8085
protocol: TCP
name: http
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
env:
- name: DOTNET_ENVIRONMENT
value: Development
- name: ASPNETCORE_ENVIRONMENT
value: Development
- name: DB_HOST
valueFrom:
secretKeyRef:
name: simkir-atlantis-db-app
key: host
- name: DB_PORT
valueFrom:
secretKeyRef:
name: simkir-atlantis-db-app
key: port
- name: DB_DATABASE
valueFrom:
secretKeyRef:
name: simkir-atlantis-db-app
key: dbname
- name: DB_USER
valueFrom:
secretKeyRef:
name: simkir-atlantis-db-app
key: user
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: simkir-atlantis-db-app
key: password
- name: FGA_DB_HOST
valueFrom:
secretKeyRef:
name: staging-openfga-db-app
key: host
- name: FGA_DB_PORT
valueFrom:
secretKeyRef:
name: staging-openfga-db-app
key: port
- name: FGA_DB_DATABASE
valueFrom:
secretKeyRef:
name: staging-openfga-db-app
key: dbname
- name: FGA_DB_USER
valueFrom:
secretKeyRef:
name: staging-openfga-db-app
key: user
- name: FGA_DB_PASSWORD
valueFrom:
secretKeyRef:
name: staging-openfga-db-app
key: password
envFrom:
- secretRef:
name: azure-keyvault
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
terminationGracePeriodSeconds: 30
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/backend-protocol: HTTP
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,213.239.94.191/32
name: simkir-codex
namespace: simkir-atlantis
spec:
ingressClassName: nginx
rules:
- host: simkir-codex.dev.oceanbox.io
http:
paths:
- backend:
service:
name: simkir-codex
port:
name: http
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- simkir-codex.dev.oceanbox.io
secretName: simkir-codex-tls
---
apiVersion: v1
kind: Service
metadata:
labels:
app: simkir-codex
name: simkir-codex
namespace: simkir-atlantis
spec:
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 8085
protocol: TCP
targetPort: http
selector:
app: simkir-codex

43
src/Codex/vite.config.js Normal file
View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import mkcert from "vite-plugin-mkcert"
const certDir = `${process.env.HOME}/.vite-plugin-mkcert`;
const proxy = {
target: "http://localhost:8085",
changeOrigin: false,
secure: false,
};
export default defineConfig({
clearScreen: false,
plugins: [
react(),
mkcert({
hosts: [
"localhost",
"*.local.oceanbox.io"
],
savePath: `${certDir}/certs`,
mkcertPath: `${certDir}/mkcert`
}),
],
server: {
host: "0.0.0.0",
port: 8080,
https: true,
cors: true,
proxy: {
'/api': proxy,
'/signin': proxy,
'/signin-oidc': proxy,
'/signout': proxy,
},
watch: {
ignored: [
"**/*.fs"
],
},
},
})

View File

@@ -1,14 +1,16 @@
module Oceanbox.DataAgent.Archives
open System
open System.Collections
open System.Linq
open FSharpPlus
open FSharp.Linq.NullableOperators
open Microsoft.FSharp.Core
open NetTopologySuite.Geometries
open Npgsql
open Serilog
open Microsoft.EntityFrameworkCore
open System.Collections
open System.Linq
open Archmaester.Dto
@@ -168,18 +170,15 @@ let private archiveToDto (ctx: Entity.ArchiveContext) (a: Entity.Archive) =
Log.Error $"archiveToDto: {exn}"
reraise ()
let archiveToProps (ctx: Entity.ArchiveContext) (a: Entity.Archive) : ArchiveProps =
let archiveToProps (a: Entity.Archive) : ArchiveProps =
let toString (s: string) = if isNull s then "" else s
let focalPt =
if isNull a.Attribs.FocalPoint then
0f, 0f
else
a.Attribs.FocalPoint |> fun x -> single x.X, single x.Y
let archiveType =
a.Attribs.Type |> fun y -> ArchiveType.FromDbType (y.Kind, y.Variant, y.Format)
let json: string =
if not (isNull a.Json) && a.Json.Length > 0 then // use archive json if defined
a.Json
@@ -187,14 +186,20 @@ let archiveToProps (ctx: Entity.ArchiveContext) (a: Entity.Archive) : ArchivePro
a.Attribs.Json // else inherit from attribs
else
""
let owners =
ctx.Users
.Where(fun u -> a.Owners.Select(_.OwnerId).Contains u.UserId)
.Select(_.Name)
.ToArray ()
Log.Debug $"DataAgent.Archives.archiveToChart: %A{a.Name}"
let polygon =
if isNull a.Geometry then
None
else
geometryToPolygon a.Geometry |> fun y -> Some y[0 .. y.Length - 2] // last element is a dummy
let owner =
a.Owners
|> Option.ofObj
|> Option.bind (
Array.ofSeq
>> Array.tryHead
>> Option.map _.Owner.Name
)
|> Option.defaultValue ""
{
archiveId = a.ArchiveId
@@ -211,14 +216,11 @@ let archiveToProps (ctx: Entity.ArchiveContext) (a: Entity.Archive) : ArchivePro
startTime = a.StartTime
endTime = a.StartTime.AddSeconds (a.Frames * a.Attribs.Freq |> float)
created = a.Created
owner = owners |> Seq.tryHead |> Option.defaultValue "system"
owner = owner
expires = if a.Expires.HasValue then Some a.Expires.Value else None
isPublished = a.Published
isPublic = a.Public
polygon =
if isNull a.Geometry then
None
else geometryToPolygon a.Geometry |> fun y -> Some y[0 .. y.Length - 2] // last element is a dummy
polygon = polygon
json = json
}
@@ -229,7 +231,7 @@ let retireDanglingAttribs (ctx: Entity.ArchiveContext) =
type Archivist(dataSource: NpgsqlDataSource) =
let withDb qry =
try
let ctx = new Entity.ArchiveContext(dataSource)
let ctx = new Entity.ArchiveContext(dataSource, true)
qry ctx |> Ok
with e ->
Log.Error $"DataAgent.Archives.Archivist.withDb: {e}"
@@ -1076,22 +1078,90 @@ type Archivist(dataSource: NpgsqlDataSource) =
.ThenInclude(_.Type)
.Include(_.Owners)
.ToArray ()
|> Array.map (archiveToProps ctx)
|> Array.map archiveToProps
|> Ok
with e ->
Log.Error $"Archivist.getModelAreaArchives error: {e.Message}"
Log.Debug $"{e}"
Error $"Could not retrieve model area ({modelId}) archives"
member _.getArchives(page: int, rowCount: int, filter: ArchiveFilter) : Result<ArchiveProps array, exn> =
try
use ctx = new Entity.ArchiveContext (dataSource, true)
let kindOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let k, v, f = archiveType.ToDbType()
k
)
let variantOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let _kind, variant, _format = archiveType.ToDbType()
variant
)
let entities =
ctx.Archives
.AsNoTracking()
.Include(fun archive -> archive.Attribs)
.ThenInclude(fun attribs -> attribs.Type)
.OrderBy(fun archive -> archive.Name)
.Where(fun archive ->
(kindOpt.IsNone || kindOpt.Value = archive.Attribs.Type.Kind)
&& (variantOpt.IsNone || variantOpt.Value = "*" || variantOpt.Value = archive.Attribs.Type.Variant)
)
.Skip(page * rowCount)
.Take(rowCount)
.ToArray()
entities
|> Array.map archiveToProps
|> Ok
with e ->
Error e
member _.getArchiveCount(filter: ArchiveFilter) : Result<int, exn> =
try
use ctx = new Entity.ArchiveContext (dataSource, true)
let kindOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let kind, _variant, _format = archiveType.ToDbType()
kind
)
let variantOpt =
filter.archiveType
|> Option.map (fun archiveType ->
let _kind, variant, _format = archiveType.ToDbType()
variant
)
let count =
ctx.Archives
.AsNoTracking()
.Where(fun archive ->
if kindOpt.IsSome then kindOpt.Value = archive.Attribs.Type.Kind else true
&& (variantOpt.IsNone || variantOpt.Value = "*" || variantOpt.Value = archive.Attribs.Type.Variant)
)
.Count()
Ok count
with e ->
Error e
member _.updateArchive(aid: ArchiveId, item: Archmaester.Forms.ArchiveForm) =
let ctx = new Entity.ArchiveContext (dataSource)
let transaction = ctx.Database.BeginTransaction ()
let tryCommit = tryCommit transaction
let expiry =
match item.expires with
| Some e -> Nullable (e.ToUniversalTime ())
| None -> Nullable ()
item.expires
|> Option.map _.ToUniversalTime()
|> Option.toNullable
let setGeometry (a: Entity.Archive) coords =
let line =
@@ -1254,7 +1324,7 @@ type Archivist(dataSource: NpgsqlDataSource) =
Log.Debug $"Archives.tryGetEntityArchive error:\n{e}"
$"Could not find archive with id {aid}")
member x.tryGetArchive aid =
member _.tryGetArchive aid =
withDb (fun ctx ->
ctx.Archives
.AsNoTracking()
@@ -1295,7 +1365,8 @@ type Archivist(dataSource: NpgsqlDataSource) =
.ToArray ()
|> Array.map (fun archive ->
Log.Debug $"ref archive props: %A{archive.Attribs}"
archiveToProps ctx archive)
archiveToProps archive
)
|> Ok
with e ->
Log.Error $"Archivist.getRefArchivesProps error: {e.Message}"
@@ -1373,7 +1444,9 @@ type Archivist(dataSource: NpgsqlDataSource) =
.ToArray ()
|> Array.map (fun archive ->
Log.Debug $"ref archive props: {archive.Name} - {archive.ArchiveId}"
archiveToProps ctx archive))
archiveToProps archive
)
)
with e ->
Log.Error $"Archivist.getRealtedArchive error: {e.Message}"
Log.Debug $"{e}"
@@ -1423,8 +1496,8 @@ type Archivist(dataSource: NpgsqlDataSource) =
.ThenInclude(_.Type)
.Include(_.Owners)
.Single ()
|> archiveToProps ctx)
|> archiveToProps
)
|> Result.mapError (fun e -> $"Could not find archive with id: {aid}: {e}")
member x.getArchiveAcl(archiveId: ArchiveId) =
@@ -1440,6 +1513,43 @@ type Archivist(dataSource: NpgsqlDataSource) =
.Single ())
|> Result.map toAclDto
member _.getGroups () =
withDb (fun ctx ->
ctx.Groups
.AsNoTracking()
.ToArray()
)
member _.getGroupArchives (group: string) : Result<ArchiveInfo array, string> =
withDb (fun ctx ->
let archiveType = ArchiveType.FromString "fvcom:*:*"
let kind, variant, format = archiveType.ToDbType()
ctx.ArchiveGroups
.AsNoTracking()
.Where(fun archiveGroup ->
archiveGroup.Group.Name.Contains group
&& archiveGroup.Archive.Attribs.Type.Kind = kind
)
.Select(fun archiveGroup -> {
archiveId = archiveGroup.Archive.ArchiveId
name = archiveGroup.Archive.Name
frames = archiveGroup.Archive.Frames
freq = 3600 // archiveGroup.Archive.Freq
startTime = archiveGroup.Archive.StartTime
created = archiveGroup.Archive.Created
expires = Option.ofNullable archiveGroup.Archive.Expires
})
.ToArray()
)
member _.getGroupUsers (group: string) =
withDb (fun ctx ->
ctx.Users
.AsNoTracking()
.Where(fun user -> user.Name.Contains group)
.ToArray()
)
member x.tryAddToAcl(aclType: AclType, names: string[]) =
withDb (fun ctx ->
match aclType with

View File

@@ -134,6 +134,7 @@ type GeometryHandler<'T when 'T :> Geometry >() =
p.NpgsqlDbType <- NpgsqlTypes.NpgsqlDbType.Geometry
p.DataTypeName <- "public.geometry"
let register () =
OptionTypes.register ()
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Geometry>())
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Point>())
@@ -144,6 +145,8 @@ SqlMapper.AddTypeHandler(GeometryHandler<Point>())
SqlMapper.AddTypeHandler(GeometryHandler<Polygon>())
SqlMapper.AddTypeHandler(GeometryHandler<LineString>())
register ()
let taskToList (t: Task<seq<'a>>) = t |> Async.AwaitTask |> Async.RunSynchronously |> Seq.toList
let taskToArray (t: Task<seq<'a>>) = t |> Async.AwaitTask |> Async.RunSynchronously |> Seq.toArray
let inline (=>) a b = a, box b

View File

@@ -1,15 +1,14 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace Entity
{
namespace Entity;
public partial class ArchiveContext : DbContext
{
bool _debug = false;
NpgsqlDataSource _dataSource;
private readonly bool _debug = false;
private readonly NpgsqlDataSource _dataSource;
public ArchiveContext()
{
@@ -46,8 +45,6 @@ namespace Entity
{
if (optionsBuilder.IsConfigured) return;
optionsBuilder.EnableSensitiveDataLogging();
optionsBuilder.UseNpgsql(
_dataSource,
o =>
@@ -59,6 +56,7 @@ namespace Entity
if (_debug)
{
optionsBuilder.EnableSensitiveDataLogging();
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
}
}
@@ -226,4 +224,3 @@ namespace Entity
return new ArchiveContext(db, optionsBuilder.Options);
}
}
}

View File

@@ -4,6 +4,12 @@ open System
open Dto
open Forms
[<Struct>]
type AddUsersRequest = {
group: string
users: string array
}
[<RequireQualifiedAccess>]
module Api =
let apiRouteBuilder (typeName: string) (methodName: string) = $"/api/v1/{typeName}/{methodName}"
@@ -20,23 +26,23 @@ module Api =
type Acl = {
getAcl: ArchiveId -> Async<Result<ArchiveAcl, string>>
addOwners: ArchiveId * string[] -> Async<Result<unit, string>>
addUsers: ArchiveId * string[] -> Async<Result<unit, string>>
addGroups: ArchiveId * string[] -> Async<Result<unit, string>>
removeOwners: ArchiveId * string[] -> Async<Result<unit, string>>
removeUsers: ArchiveId * string[] -> Async<Result<unit, string>>
removeGroups: ArchiveId * string[] -> Async<Result<unit, string>>
addOwners: ArchiveId * string array -> Async<Result<unit, string>>
addUsers: ArchiveId * string array -> Async<Result<unit, string>>
addGroups: ArchiveId * string array -> Async<Result<unit, string>>
removeOwners: ArchiveId * string array -> Async<Result<unit, string>>
removeUsers: ArchiveId * string array -> Async<Result<unit, string>>
removeGroups: ArchiveId * string array -> Async<Result<unit, string>>
}
type Archive = {
addSubArchive: SubArchiveDef -> Async<Result<ArchiveProps, string>>
getArchive: ArchiveId -> Async<Result<ArchiveProps, string>>
getArchivePolygon: ArchiveId -> Async<Result<(single * single)[], string>>
getModelAreaArchives: ModelAreaId * ArchiveType -> Async<Result<ArchiveProps[], string>>
getRefArchives: ArchiveId * ArchiveType -> Async<Result<ArchiveProps[], string>>
addSubArchive: SubArchiveDef -> Async<Result<ArchiveProps, string>>
resizeArchive: ArchiveId * int * int -> Async<Result<unit, string>>
retireArchive: ArchiveId -> Async<Result<unit, string>>
updateArchive: ArchiveId * ArchiveForm -> Async<Result<unit, string>>
resizeArchive: ArchiveId * int * int -> Async<Result<unit, string>>
getArchivePolygon: ArchiveId -> Async<Result<(single * single)[], string>>
}
type ModelArea = {
@@ -47,26 +53,26 @@ module Api =
}
type Admin = {
newArchive: ArchiveDto -> Async<Result<unit, string>>
getArchiveDto: ArchiveId -> Async<Result<ArchiveDto, string>>
augmentFiles: ArchiveId * ArchiveFile[] -> Async<Result<unit, string>>
renameFiles: ArchiveId * (string * string)[] -> Async<Result<unit, string>>
getFiles: Guid -> Async<Result<ArchiveFiles, string>>
getAllFiles: Guid -> Async<Result<ArchiveFiles, string>>
queryModelAreaId: string -> Async<Result<ModelAreaId, string>>
addModelArea: Dto.ModelArea -> Async<Result<ModelAreaId, string>>
updateModelArea: ModelAreaId * ModelAreaForm -> Async<Result<unit, string>>
setModelAreaPolygon: ModelAreaId * (single * single)[] -> Async<Result<unit, string>>
setArchivePolygon: ArchiveId * (single * single)[] -> Async<Result<unit, string>>
updateArchiveAttribs: ArchiveId * ArchiveAttribsForm -> Async<Result<unit, string>>
deleteModelArea: ModelAreaId -> Async<Result<unit, string>>
removeRetiredAttribs: unit -> Async<Result<unit, string>>
addUsers: string[] -> Async<Result<unit, string>>
addGroups: string[] -> Async<Result<unit, string>>
removeUsers: string[] -> Async<Result<unit, string>>
removeGroups: string[] -> Async<Result<unit, string>>
addType: string -> Async<Result<unit, string>>
removeType: string -> Async<Result<unit, string>>
addAssociation: ArchiveId * ArchiveId -> Async<Result<unit, string>>
addGroups: string array -> Async<Result<unit, string>>
addModelArea: Dto.ModelArea -> Async<Result<ModelAreaId, string>>
addType: string -> Async<Result<unit, string>>
addUsers: AddUsersRequest -> Async<Result<unit, string>>
augmentFiles: ArchiveId * ArchiveFile[] -> Async<Result<unit, string>>
deleteModelArea: ModelAreaId -> Async<Result<unit, string>>
getAllFiles: Guid -> Async<Result<ArchiveFiles, string>>
getArchiveDto: ArchiveId -> Async<Result<ArchiveDto, string>>
getFiles: Guid -> Async<Result<ArchiveFiles, string>>
newArchive: ArchiveDto -> Async<Result<unit, string>>
queryModelAreaId: string -> Async<Result<ModelAreaId, string>>
removeAssociation: ArchiveId * ArchiveId -> Async<Result<unit, string>>
removeGroups: string[] -> Async<Result<unit, string>>
removeRetiredAttribs: unit -> Async<Result<unit, string>>
removeType: string -> Async<Result<unit, string>>
removeUsers: string[] -> Async<Result<unit, string>>
renameFiles: ArchiveId * (string * string)[] -> Async<Result<unit, string>>
setArchivePolygon: ArchiveId * (single * single)[] -> Async<Result<unit, string>>
setModelAreaPolygon: ModelAreaId * (single * single)[] -> Async<Result<unit, string>>
updateArchiveAttribs: ArchiveId * ArchiveAttribsForm -> Async<Result<unit, string>>
updateModelArea: ModelAreaId * ModelAreaForm -> Async<Result<unit, string>>
}

View File

@@ -131,6 +131,18 @@ module Dto =
member x.Label() = DriftersVariant.Label(x)
static member All =
[|
Lice
Virus
Transport
Sedimentation
Accumulation
AccumulationV2
WaterContact
Downwelling
|]
type DriftersFormat =
| Particle
| Field2D
@@ -213,6 +225,7 @@ module Dto =
| "accumulation_v2" -> Some DriftersVariant.AccumulationV2
| "watercontact" -> Some DriftersVariant.WaterContact
| "downwelling" -> Some DriftersVariant.Downwelling
| "*" -> Some DriftersVariant.Any
| _ -> None
let f =
@@ -310,11 +323,37 @@ module Dto =
| _ -> Any
static member FromDbType(kind, variant, format) =
let s = $"{kind}:{variant}:{format}"
let s = $"%s{kind}:%s{variant}:%s{format}"
ArchiveType.FromString s
member x.ToDbType() =
string x |> (fun s -> s.Split ':') |> (fun y -> y[0], y[1], y[2])
string x |> (fun s -> s.Split ':') |> fun y -> y[0], y[1], y[2]
member this.KindLabel =
match this with
| Fvcom _ -> "FVCOM"
| Drifters _ -> "Drifters"
| Atmo _ -> "Atmo"
| FvStats _ -> "Stats"
| Any -> "*"
member this.VariantLabel =
match this with
| Fvcom(vrt, _) -> vrt.Label ()
| Drifters(vrt, _) -> vrt.Label ()
| Atmo(vrt, _) -> vrt.Label ()
| FvStats(vrt, _) -> vrt.Label ()
| Any -> "*:*:*"
member this.FormatLabel =
match this with
| Fvcom(_, fmt) -> fmt.Label()
| Drifters(_, fmt) -> fmt.Label ()
| Atmo(_, fmt) -> fmt.Label ()
| FvStats(_, fmt) -> fmt.Label ()
| Any -> "*"
type Polygon = (single * single)[]
type UserId = string
@@ -385,10 +424,10 @@ module Dto =
}
type ArchiveAcl = {
owners: string[]
users: string[]
groups: string[]
shares: Guid[]
owners: string array
users: string array
groups: string array
shares: Guid array
} with
static member empty = {
owners = [||]
@@ -457,12 +496,16 @@ module Dto =
}
type ArchiveFilter = {
id: ArchiveId option
searchTerm: string option
archiveType: ArchiveType option
owner: string option
user: string option
groups: string[] option
groups: string array option
} with
static member empty = {
id = None
searchTerm = None
archiveType = None
owner = None
user = None

View File

@@ -4,8 +4,8 @@ open System
open System.Net.Http
open System.Security.Claims
open System.Threading.Tasks
open FSharp.Data
open FSharp.Data.HttpMethod
open IdentityModel.Client
open Azure.Identity
open Microsoft.AspNetCore.Authentication
@@ -36,7 +36,7 @@ type OpenIdSettings = {
device_authorization_endpoint: string
clientId: string
clientSecret: string
scopes: string[]
scopes: string array
redirectUri: string option
audiences: string list
} with
@@ -86,15 +86,15 @@ type SsoSettings = {
type PlainAuthUser = {
username: string
password: string
groups: string[]
roles: string[]
groups: string array
roles: string array
} with
static member empty = { username = ""; password = ""; groups = [||]; roles = [||] }
type MultiAuthSettings = {
oidc: OpenIdSettings
sso: SsoSettings
plainAuthUsers: PlainAuthUser[]
plainAuthUsers: PlainAuthUser array
}
type IPrincipalActor =
@@ -125,59 +125,60 @@ module MultiAuthDefaults =
| Cookie
| Plain
let tryStr str =
if String.IsNullOrEmpty str then
None
else
Some str
let tryGetEnv =
Environment.GetEnvironmentVariable
>> function
| null
| "" -> None
| x -> Some x
>> tryStr
let authorize: HttpHandler =
requiresAuthentication (challenge OpenIdConnectDefaults.AuthenticationScheme)
// requiresAuthentication (challenge CookieAuthenticationDefaults.AuthenticationScheme)
let addGroupsAndRoles (principal: ClaimsPrincipal) =
let addGroupsAndRoles (principal: ClaimsPrincipal) : Async<bool> =
let addToIdentity (identity: ClaimsIdentity) ctype cc =
Log.Debug $"Adding claim {ctype} to principal: %A{cc}"
eprintfn $"[MultiAuth] Adding claim {ctype} to principal: %A{cc}"
cc
|> Array.choose (fun g ->
if not (principal.HasClaim (fun claim -> claim.Type = ctype && claim.Value = g)) then
Claim (ctype, g) |> Some
else
None)
None
)
|> Array.iter identity.AddClaim
async {
let isUnclaimed = principal.HasClaim (fun claim -> claim.Type = "group") |> not
if principal.Identity.IsAuthenticated && isUnclaimed then
match principal.FindFirst ClaimTypes.Email with
| null ->
Log.Error $"Email claim missing for identity: {principal.Identity.Name}"
false
eprintfn $"[MultiAuth] Email claim missing for identity: {principal.Identity.Name}"
return false
| email ->
let identity = ClaimsIdentity ()
task {
try
let aid = ActorId email.Value
let proxy = ActorProxy.Create<IPrincipalActor> (aid, "PrincipalActor")
let! groups = proxy.GetGroups ()
let! roles = proxy.GetRoles ()
let! groups = proxy.GetGroups () |> Async.AwaitTask
let! roles = proxy.GetRoles () |> Async.AwaitTask
addToIdentity identity "group" groups
addToIdentity identity ClaimTypes.Role roles
with exn ->
Log.Error $"PrincipalActor: %A{exn}"
eprintfn $"[MultiAuth] PrincipalActor: %A{exn}"
addToIdentity identity "group" [| "guest" |]
addToIdentity identity ClaimTypes.Role [||]
principal.AddIdentity identity
return true
}
|> Async.AwaitTask
|> Async.RunSynchronously
else
false
return false
}
let private refreshOidcAccessToken (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) =
task {
async {
let refreshToken = ctx.Properties.GetTokenValue "refresh_token"
let rfr = new RefreshTokenRequest ()
rfr.Address <- oidc.issuer
@@ -186,12 +187,12 @@ let private refreshOidcAccessToken (oidc: OpenIdSettings) (ctx: CookieValidatePr
rfr.RefreshToken <- refreshToken
let httpClient = new HttpClient ()
let! response = httpClient.RequestRefreshTokenAsync rfr
Log.Debug $"refreshOidcAccessToken (raw): {response.Raw}"
let! response = httpClient.RequestRefreshTokenAsync rfr |> Async.AwaitTask
eprintfn $"[MultiAuth] refreshOidcAccessToken (raw): {response.Raw}"
if response.IsError then
ctx.RejectPrincipal ()
return! ctx.HttpContext.SignOutAsync ()
return! ctx.HttpContext.SignOutAsync () |> Async.AwaitTask
else
let expiresInSeconds = response.ExpiresIn
let updatedExpiresAt = DateTimeOffset.UtcNow.AddSeconds expiresInSeconds
@@ -204,27 +205,38 @@ let private refreshOidcAccessToken (oidc: OpenIdSettings) (ctx: CookieValidatePr
ctx.ShouldRenew <- true
}
let private updatePrincipalContext (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) =
task {
let createRefreshToken (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) (accessTokenExpiration: DateTimeOffset) =
async {
let now = DateTimeOffset.UtcNow
let expiresAt = ctx.Properties.GetTokenValue "expires_at"
let accessTokenExpiration = DateTimeOffset.Parse expiresAt
let timeRemaining = accessTokenExpiration.Subtract now
// TODO: Get this from configuration with a fall back value.
let refreshThresholdMinutes = 5.0
let refreshThreshold = TimeSpan.FromMinutes refreshThresholdMinutes
// ctx.Properties.IsPersistent <- true
if addGroupsAndRoles ctx.Principal then
Log.Debug $"add extra claims: %A{ctx.Principal.Claims}"
ctx.ShouldRenew <- true
if timeRemaining < refreshThreshold then
Log.Debug $"updatePrincipalContext: time remaining = {timeRemaining}, refresh threshold {refreshThreshold}"
Log.Debug $"updatePrincipalContext: refreshing access token: %A{ctx.Principal.Claims}"
eprintfn $"[MultiAuth] updatePrincipalContext: time remaining = {timeRemaining}, refresh threshold {refreshThreshold}"
eprintfn $"[MultiAuth] updatePrincipalContext: refreshing access token: %A{ctx.Principal.Claims}"
return! refreshOidcAccessToken oidc ctx
}
let private updatePrincipalContext (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) =
task {
match ctx.Properties.GetTokenValue "expires_at" |> tryStr with
| Some expiresAt ->
let accessTokenExpiration = DateTimeOffset.Parse expiresAt
do! createRefreshToken oidc ctx accessTokenExpiration
| None ->
eprintfn $"[MultiAuth] No 'expires_at' token"
()
let! success = addGroupsAndRoles ctx.Principal
if success then
eprintfn $"[MultiAuth] add extra claims: %A{ctx.Principal.Claims}"
ctx.ShouldRenew <- true
}
let ssoCookieOptions (settings: MultiAuthSettings) (o: CookieAuthenticationOptions) =
o.Cookie.SecurePolicy <- CookieSecurePolicy.Always
o.Cookie.SameSite <- SameSiteMode.None
@@ -243,7 +255,6 @@ let ssoCookieOptions (settings: MultiAuthSettings) (o: CookieAuthenticationOptio
updatePrincipalContext settings.oidc ctx
let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
Log.Debug("[MultiAuth] OIDC settings {@Settings}", settings.oidc)
o.Authority <- settings.oidc.issuer
o.ClientId <- settings.oidc.clientId
o.ClientSecret <- settings.oidc.clientSecret
@@ -261,7 +272,7 @@ let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
|]
o.Scope.Clear ()
settings.oidc.scopes |> Array.iter o.Scope.Add
o.SaveTokens <- true
o.SaveTokens <- false
o.UseTokenLifetime <- true
o.ResponseType <- OpenIdConnectResponseType.Code
// SameSite is needed for Chrome/Firefox, as they will give http error 500 back, if not set to unspecified.
@@ -280,14 +291,19 @@ let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
fun e -> e.HttpContext.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
o.Events.OnRedirectToIdentityProvider <-
fun e ->
task {
eprintfn "[MultiAuth] RedirectToIdentityProvider: %A" e.Request.Host.Value
// hack for https behind proxy
e.ProtocolMessage.RedirectUri <- $"https://{e.Request.Host.Value}/signin-oidc"
Task.FromResult ()
return ()
}
o.Events.OnRedirectToIdentityProviderForSignOut <-
fun e ->
task {
// hack for https behind proxy
e.ProtocolMessage.PostLogoutRedirectUri <- $"https://{e.Request.Host.Value}/signout-callback-oidc"
Task.FromResult ()
return ()
}
module Jwt =
type OidToken = {
@@ -308,7 +324,7 @@ module Jwt =
e: string
}
type private Keys = { keys: Key[] }
type private Keys = { keys: Key array }
let private keyDecoder = Thoth.Json.Net.Decode.Auto.generateDecoder<Keys> ()
@@ -449,6 +465,7 @@ module PlainAuth =
Some { user = user; groups = v.groups; roles = v.roles }
else
None))
task {
if not (String.IsNullOrEmpty request.Headers.Authorization) then
let t = request.Headers.Authorization[0].Split ' '
@@ -516,7 +533,9 @@ let addSsoDataProtection (settings: MultiAuthSettings) (services: IServiceCollec
builder.ProtectKeysWithAzureKeyVault (Uri uri, EnvironmentCredential ())
else
builder
| None -> builder
| None ->
builder
match settings.sso.keyStore with
| Some keyStore ->
let key = $"{settings.sso.environment}-{keyStore.key}"
@@ -528,60 +547,29 @@ let addSsoDataProtection (settings: MultiAuthSettings) (services: IServiceCollec
| Some token ->
let blob = $"{keyStore.uri}/{key}/key?{token}"
protector.PersistKeysToAzureBlobStorage (Uri blob) |> protectKeys
| None -> protector
| None ->
protector
else
protector
| None -> protector
|> ignore
| None ->
protector
type ApplicationBuilder with
[<CustomOperation("use_oidc")>]
member this.UseOidc(state, settings: MultiAuthSettings) =
let middleware (app: IApplicationBuilder) =
app.UseAuthentication().UseAuthorization ()
let service (services: IServiceCollection) =
services.AddHttpContextAccessor () |> ignore
services.AddTransient<IClaimsTransformation, ApplicationClaims> () |> ignore
addSsoDataProtection settings services
services
.AddAuthentication(fun o ->
o.DefaultScheme <- CookieAuthenticationDefaults.AuthenticationScheme
o.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
o.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
o.DefaultChallengeScheme <- OpenIdConnectDefaults.AuthenticationScheme)
.AddCookie(ssoCookieOptions settings)
.AddOpenIdConnect (oidOptions settings)
|> ignore
services.AddAuthorization () |> ignore
services
{
state with
ServicesConfig = service :: state.ServicesConfig
AppConfigs = middleware :: state.AppConfigs
CookiesAlreadyAdded = true
}
[<CustomOperation("use_multiauth")>]
member _.UseMultiAuth(state, settings: MultiAuthSettings) =
let middleware (app: IApplicationBuilder) =
app.UseAuthentication().UseAuthorization ()
let service (services: IServiceCollection) =
let configureMultiAuthServices settings (services: IServiceCollection) =
services.AddHttpContextAccessor () |> ignore
services.AddTransient<IClaimsTransformation, ApplicationClaims> () |> ignore
services.AddSingleton<PlainAuth.IPlainAuthUserService, PlainAuth.PlainAuthUserService> (fun _ ->
PlainAuth.PlainAuthUserService settings.plainAuthUsers)
PlainAuth.PlainAuthUserService settings.plainAuthUsers
)
|> ignore
services.AddAccessTokenManagement () |> ignore
addSsoDataProtection settings services
addSsoDataProtection settings services |> ignore
services.AddAuthentication (fun o ->
o.DefaultScheme <- MultiAuthDefaults.AuthenticationScheme
o.DefaultAuthenticateScheme <- MultiAuthDefaults.AuthenticationScheme
o.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
o.DefaultChallengeScheme <- OpenIdConnectDefaults.AuthenticationScheme
o.DefaultSignOutScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
o.DefaultSignOutScheme <- CookieAuthenticationDefaults.AuthenticationScheme
)
|> fun builder ->
builder
.AddJwtBearer(Jwt.jwtOptions settings.oidc)
@@ -607,14 +595,55 @@ type ApplicationBuilder with
MultiAuthDefaults.Bearer
else
MultiAuthDefaults.Plain
Log.Debug $"MultiAuth: {scheme}"
eprintfn $"MultiAuth: {scheme}"
match scheme with
| MultiAuthDefaults.Plain -> PlainAuth.PlainAuthDefaults.AuthenticationScheme
| MultiAuthDefaults.Bearer -> JwtBearerDefaults.AuthenticationScheme
| MultiAuthDefaults.Cookie -> CookieAuthenticationDefaults.AuthenticationScheme
)
|> ignore
services.AddAuthorization ()
services.AddAuthorization () |> ignore
services
type ApplicationBuilder with
[<CustomOperation("use_oidc")>]
member _.UseOidc(state, settings: MultiAuthSettings) =
let middleware (app: IApplicationBuilder) =
app.UseAuthentication().UseAuthorization ()
let service (services: IServiceCollection) =
services.AddHttpContextAccessor () |> ignore
services.AddTransient<IClaimsTransformation, ApplicationClaims> () |> ignore
addSsoDataProtection settings services |> ignore
services
.AddAuthentication(fun o ->
o.DefaultScheme <- CookieAuthenticationDefaults.AuthenticationScheme
o.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
o.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
o.DefaultChallengeScheme <- OpenIdConnectDefaults.AuthenticationScheme
)
.AddCookie(ssoCookieOptions settings)
.AddOpenIdConnect (oidOptions settings)
|> ignore
services.AddAuthorization () |> ignore
services
{
state with
ServicesConfig = service :: state.ServicesConfig
AppConfigs = middleware :: state.AppConfigs
CookiesAlreadyAdded = true
}
[<CustomOperation("use_multiauth")>]
member _.UseMultiAuth(state, settings: MultiAuthSettings) =
let middleware (app: IApplicationBuilder) =
app.UseAuthentication().UseAuthorization ()
let service (services: IServiceCollection) =
configureMultiAuthServices settings services
{
state with
ServicesConfig = service :: state.ServicesConfig
@@ -629,7 +658,7 @@ type ApplicationBuilder with
let service (services: IServiceCollection) =
services.AddHttpContextAccessor () |> ignore
addSsoDataProtection settings services
addSsoDataProtection settings services |> ignore
services
.AddAuthentication(fun o ->
o.DefaultScheme <- CookieAuthenticationDefaults.AuthenticationScheme