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:
@@ -41,3 +41,9 @@ include:
|
|||||||
- changes:
|
- changes:
|
||||||
- 'src/ServerPack/**/*'
|
- 'src/ServerPack/**/*'
|
||||||
- 'nix/packages/serverpack.nix'
|
- 'nix/packages/serverpack.nix'
|
||||||
|
- local: '/src/Codex/.gitlab-ci.yml'
|
||||||
|
rules:
|
||||||
|
- changes:
|
||||||
|
- 'src/Codex/**/*'
|
||||||
|
- 'nix/packages/node-modules.nix'
|
||||||
|
- 'nix/packages/sources.nix'
|
||||||
@@ -24,6 +24,10 @@
|
|||||||
<Project Path="src/Atlantis/src/Server/Petimeter/Petimeter.fsproj" />
|
<Project Path="src/Atlantis/src/Server/Petimeter/Petimeter.fsproj" />
|
||||||
<Project Path="src/Atlantis/src/Server/Server.fsproj" />
|
<Project Path="src/Atlantis/src/Server/Server.fsproj" />
|
||||||
</Folder>
|
</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/">
|
<Folder Name="/DataAgent/">
|
||||||
<Project Path="src/DataAgent/src/Entity/Entity.csproj" />
|
<Project Path="src/DataAgent/src/Entity/Entity.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
27
default.nix
27
default.nix
@@ -28,25 +28,16 @@ let
|
|||||||
|
|
||||||
packages = import ./nix/packages {
|
packages = import ./nix/packages {
|
||||||
inherit
|
inherit
|
||||||
|
env
|
||||||
|
deps
|
||||||
pkgs
|
pkgs
|
||||||
version
|
version
|
||||||
dotnet-sdk
|
dotnet-sdk
|
||||||
dotnet-runtime
|
dotnet-runtime
|
||||||
env
|
|
||||||
deps
|
|
||||||
;
|
;
|
||||||
inherit netrcConfig;
|
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 {
|
containers = pkgs.callPackage ./nix/containers.nix {
|
||||||
inherit (packages)
|
inherit (packages)
|
||||||
atlantis
|
atlantis
|
||||||
@@ -58,9 +49,21 @@ rec {
|
|||||||
version
|
version
|
||||||
env
|
env
|
||||||
;
|
;
|
||||||
|
codex = packages.codex;
|
||||||
};
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit packages;
|
||||||
|
|
||||||
|
inherit scripts;
|
||||||
|
|
||||||
|
# Expose atlantis as default packages
|
||||||
|
default = packages.atlantis;
|
||||||
|
|
||||||
|
# Docker and Singurlarity images
|
||||||
|
containers = containers;
|
||||||
|
|
||||||
checks = {
|
checks = {
|
||||||
pre-commit = import ./nix/pre-commit.nix;
|
pre-commit = import ./nix/pre-commit.nix;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
atlantis-client,
|
atlantis-client,
|
||||||
sorcerer,
|
sorcerer,
|
||||||
archivist,
|
archivist,
|
||||||
|
codex,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
# Entrypoints
|
# Entrypoints
|
||||||
@@ -35,6 +36,7 @@ in
|
|||||||
cp -r ${atlantis}/lib/Atlantis/* ./app/
|
cp -r ${atlantis}/lib/Atlantis/* ./app/
|
||||||
cp -r ${atlantis-client}/public ./app/
|
cp -r ${atlantis-client}/public ./app/
|
||||||
'';
|
'';
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
cmd = [ "Server" ];
|
cmd = [ "Server" ];
|
||||||
workingDir = "/app";
|
workingDir = "/app";
|
||||||
@@ -65,6 +67,7 @@ in
|
|||||||
workingDir = "/app";
|
workingDir = "/app";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
archivist = pkgs.dockerTools.buildLayeredImage {
|
archivist = pkgs.dockerTools.buildLayeredImage {
|
||||||
name = "archivist";
|
name = "archivist";
|
||||||
tag = archivist.version;
|
tag = archivist.version;
|
||||||
@@ -95,4 +98,5 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
codex = pkgs.callPackage ../src/Codex/container.nix { server = codex; };
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ let
|
|||||||
mkdir -p $out
|
mkdir -p $out
|
||||||
cp -r node_modules $out/
|
cp -r node_modules $out/
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Required else we get errors that our fixed-output derivation references store paths
|
# Required else we get errors that our fixed-output derivation references store paths
|
||||||
@@ -76,70 +76,69 @@ let
|
|||||||
outputHash = "sha256-T9X1EFeoNV3yKdVUIMOvaYtja6XR0fne6CDkKHD5rhE=";
|
outputHash = "sha256-T9X1EFeoNV3yKdVUIMOvaYtja6XR0fne6CDkKHD5rhE=";
|
||||||
};
|
};
|
||||||
|
|
||||||
atlantis-client = buildDotnetModule {
|
|
||||||
inherit dotnet-sdk dotnet-runtime;
|
|
||||||
inherit src version;
|
|
||||||
pname = "${pname}-Client";
|
|
||||||
|
|
||||||
projectFile = "src/Atlantis/src/Client/Client.fsproj";
|
|
||||||
dotnetRestoreFlags = "--force-evaluate";
|
|
||||||
# nugetDeps = ./atlantis-client.json; # nix-build -A packages.atlantis-client.fetch-deps && ./result src/Atlantis/nix/atlantis-client.json
|
|
||||||
nugetDeps = deps {
|
|
||||||
inherit
|
|
||||||
pkgs
|
|
||||||
netrcConfig
|
|
||||||
packageSources
|
|
||||||
;
|
|
||||||
name = "${pname}-Client";
|
|
||||||
lockfiles = [
|
|
||||||
../../src/Atlantis/src/Client/packages.lock.json
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Skip the default dotnet build since we're using Fable
|
|
||||||
dontDotnetBuild = true;
|
|
||||||
|
|
||||||
buildInputs = [
|
|
||||||
fable
|
|
||||||
bun
|
|
||||||
];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
export HOME=$TMPDIR
|
|
||||||
|
|
||||||
cp -r ${nodeDeps}/node_modules ./.
|
|
||||||
chmod -R u+rw node_modules
|
|
||||||
|
|
||||||
chmod -R u+x node_modules/.bin
|
|
||||||
patchShebangs node_modules/.bin
|
|
||||||
export PATH="./node_modules/.bin:$PATH"
|
|
||||||
|
|
||||||
cd src/Atlantis/src/Client
|
|
||||||
|
|
||||||
# NOTE(mrtz): Uses fable from nixpkgs instead of dotnet (Could be out of sync). --MSBuildCracker
|
|
||||||
${lib.getExe fable} -e .jsx -o build
|
|
||||||
|
|
||||||
# Run vite from the Atlantis directory with proper config, always bundle for prod
|
|
||||||
${lib.getExe bun} ../../../../node_modules/.bin/vite build -c ../../vite.config.js --outDir dist/public --mode Production
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
runHook preInstall
|
|
||||||
|
|
||||||
# Copy output (*.js, *.css and *.html) to `/public`.
|
|
||||||
mkdir -p $out/public
|
|
||||||
cp -r dist/public/* $out/public/
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
dontFixup = true;
|
|
||||||
dontPatchELF = true;
|
|
||||||
dontStrip = true;
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
atlantis-client
|
buildDotnetModule {
|
||||||
|
inherit dotnet-sdk dotnet-runtime;
|
||||||
|
inherit src version;
|
||||||
|
pname = "${pname}-Client";
|
||||||
|
|
||||||
|
projectFile = "src/Atlantis/src/Client/Client.fsproj";
|
||||||
|
dotnetRestoreFlags = "--force-evaluate";
|
||||||
|
# nugetDeps = ./atlantis-client.json; # nix-build -A packages.atlantis-client.fetch-deps && ./result src/Atlantis/nix/atlantis-client.json
|
||||||
|
nugetDeps = deps {
|
||||||
|
inherit
|
||||||
|
pkgs
|
||||||
|
netrcConfig
|
||||||
|
packageSources
|
||||||
|
;
|
||||||
|
name = "${pname}-Client";
|
||||||
|
lockfiles = [
|
||||||
|
../../src/Atlantis/src/Client/packages.lock.json
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Skip the default dotnet build since we're using Fable
|
||||||
|
dontDotnetBuild = true;
|
||||||
|
|
||||||
|
buildInputs = [
|
||||||
|
fable
|
||||||
|
bun
|
||||||
|
];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
export HOME=$TMPDIR
|
||||||
|
|
||||||
|
cp -r ${nodeDeps}/node_modules ./.
|
||||||
|
chmod -R u+rw node_modules
|
||||||
|
|
||||||
|
chmod -R u+x node_modules/.bin
|
||||||
|
patchShebangs node_modules/.bin
|
||||||
|
export PATH="./node_modules/.bin:$PATH"
|
||||||
|
|
||||||
|
cd src/Atlantis/src/Client
|
||||||
|
|
||||||
|
# NOTE(mrtz): Uses fable from nixpkgs instead of dotnet (Could be out of sync). --MSBuildCracker
|
||||||
|
${lib.getExe fable} -e .jsx -o build
|
||||||
|
|
||||||
|
# Run vite from the Atlantis directory with proper config, always bundle for prod
|
||||||
|
${lib.getExe bun} ../../../../node_modules/.bin/vite build -c ../../vite.config.js --outDir dist/public --mode Production
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
# Copy output (*.js, *.css and *.html) to `/public`.
|
||||||
|
mkdir -p $out/public
|
||||||
|
cp -r dist/public/* $out/public/
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
dontFixup = true;
|
||||||
|
dontPatchELF = true;
|
||||||
|
dontStrip = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ buildDotnetModule rec {
|
|||||||
pname = "Atlantis";
|
pname = "Atlantis";
|
||||||
# NOTE(mrtz): Ensures reproducibility and reduces closure size,
|
# NOTE(mrtz): Ensures reproducibility and reduces closure size,
|
||||||
# by filtering out irrelevant files and `.git` which changes between commits.
|
# 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";
|
projectFile = "src/Atlantis/src/Server/Server.fsproj";
|
||||||
dotnetRestoreFlags = "--force-evaluate";
|
dotnetRestoreFlags = "--force-evaluate";
|
||||||
nugetDeps = deps {
|
nugetDeps = deps {
|
||||||
|
|||||||
@@ -9,23 +9,18 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
# NOTE(mrtz): Gitlab Nuget Registry does not support groupwide fetches :/
|
# NOTE(mrtz): Gitlab Nuget Registry does not support groupwide fetches :/
|
||||||
packageSources = {
|
packageSources = import ./sources.nix;
|
||||||
"Oceanbox.FvcomKit" = "https://gitlab.com/api/v4/projects/35569541/packages/nuget/download";
|
|
||||||
"ProjNet.FSharp" = "https://gitlab.com/api/v4/projects/35009572/packages/nuget/download";
|
nodeModules = pkgs.callPackage ./node-modules.nix {};
|
||||||
"SDSLite.Oceanbox" = "https://gitlab.com/api/v4/projects/34025102/packages/nuget/download";
|
|
||||||
"Oceanbox.ServerPack" = "https://gitlab.com/api/v4/projects/67427353/packages/nuget/download";
|
codex-client = pkgs.callPackage ../../src/Codex/src/Client {
|
||||||
"Oceanbox.DataAgent" = "https://gitlab.com/api/v4/projects/37541600/packages/nuget/download";
|
inherit
|
||||||
"Drifters.Api" = "https://gitlab.com/api/v4/projects/37086336/packages/nuget/download";
|
deps
|
||||||
"Fable.SignalR.AspNetCore" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
|
dotnet-sdk
|
||||||
"Fable.SignalR.Saturn" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
|
netrcConfig
|
||||||
"Fable.SignalR.Shared" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
|
nodeModules
|
||||||
"Fable.SignalR" = "https://gitlab.com/api/v4/projects/40255650/packages/nuget/download";
|
packageSources
|
||||||
"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";
|
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -38,6 +33,7 @@ in
|
|||||||
packageSources
|
packageSources
|
||||||
;
|
;
|
||||||
};
|
};
|
||||||
|
|
||||||
# NOTE(mrtz): It's acutally Oceanbox.DataAgent
|
# NOTE(mrtz): It's acutally Oceanbox.DataAgent
|
||||||
archmaester = pkgs.callPackage ./dataagent.nix {
|
archmaester = pkgs.callPackage ./dataagent.nix {
|
||||||
inherit
|
inherit
|
||||||
@@ -48,6 +44,7 @@ in
|
|||||||
packageSources
|
packageSources
|
||||||
;
|
;
|
||||||
};
|
};
|
||||||
|
|
||||||
# NOTE(mrtz): It's acutally Poseidon.Api
|
# NOTE(mrtz): It's acutally Poseidon.Api
|
||||||
interfaces = pkgs.callPackage ./api.nix {
|
interfaces = pkgs.callPackage ./api.nix {
|
||||||
inherit
|
inherit
|
||||||
@@ -59,6 +56,7 @@ in
|
|||||||
packageSources
|
packageSources
|
||||||
;
|
;
|
||||||
};
|
};
|
||||||
|
|
||||||
atlantis = pkgs.callPackage ./atlantis.nix {
|
atlantis = pkgs.callPackage ./atlantis.nix {
|
||||||
inherit
|
inherit
|
||||||
env
|
env
|
||||||
@@ -70,6 +68,7 @@ in
|
|||||||
packageSources
|
packageSources
|
||||||
;
|
;
|
||||||
};
|
};
|
||||||
|
|
||||||
sorcerer = pkgs.callPackage ./sorcerer.nix {
|
sorcerer = pkgs.callPackage ./sorcerer.nix {
|
||||||
inherit
|
inherit
|
||||||
env
|
env
|
||||||
@@ -101,4 +100,15 @@ in
|
|||||||
packageSources
|
packageSources
|
||||||
;
|
;
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
codex = pkgs.callPackage ../../src/Codex/src/Server {
|
||||||
|
inherit
|
||||||
|
deps
|
||||||
|
dotnet-sdk
|
||||||
|
netrcConfig
|
||||||
|
dotnet-runtime
|
||||||
|
packageSources
|
||||||
|
;
|
||||||
|
client = codex-client;
|
||||||
|
};
|
||||||
|
}
|
||||||
52
nix/packages/node-modules.nix
Normal file
52
nix/packages/node-modules.nix
Normal 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
19
nix/packages/sources.nix
Normal 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";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -29,4 +29,4 @@ pkgs.mkShellNoCC {
|
|||||||
export APP_NAME=atlantis
|
export APP_NAME=atlantis
|
||||||
export APP_NAMESPACE=$USER-atlantis
|
export APP_NAMESPACE=$USER-atlantis
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
@@ -17,5 +17,25 @@ let private uk : obj =
|
|||||||
|
|
||||||
dateTimeFormat "en-GB" opts
|
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"
|
/// Returns date string formatted as e.g.: "Wednesday 11 June 2025 at 06:00"
|
||||||
let format (date: System.DateTime) : string = uk?format date
|
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
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ let fromBase64String (s: string) : string = jsNative
|
|||||||
[<Emit("btoa($0)")>]
|
[<Emit("btoa($0)")>]
|
||||||
let toBase64String (s: string) : string = jsNative
|
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
|
/// Helper function for testing whether a string is null or only whitespace
|
||||||
let tryStr str =
|
let tryStr str = if strNotNull str then Some str else None
|
||||||
if String.IsNullOrWhiteSpace str then None else Some str
|
|
||||||
|
|
||||||
/// Uses js getElementById on the HTML id. Tests elem for isNullOrUndefined
|
/// Uses js getElementById on the HTML id. Tests elem for isNullOrUndefined
|
||||||
let tryElem (id: string) : Types.Element option =
|
let tryElem (id: string) : Types.Element option =
|
||||||
@@ -130,6 +131,12 @@ let onEnterOrEscape onEnter onEscape (ev: Types.Event) =
|
|||||||
| "Escape" -> onEscape ev
|
| "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>
|
/// <summary>
|
||||||
/// Calculate the ISO week number from a date. Taken from https://weeknumber.com/how-to/javascript since we cannot
|
/// 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
|
/// use https://learn.microsoft.com/en-us/dotnet/api/system.globalization.calendar.getweekofyear?view=net-8.0
|
||||||
@@ -226,4 +233,4 @@ module Array =
|
|||||||
|
|
||||||
init |> Array.foldBack folder array
|
init |> Array.foldBack folder array
|
||||||
|
|
||||||
let sequenceResults x = traverseResult id x
|
let sequenceResults x = traverseResult id x
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ open Archmaester
|
|||||||
|
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Handlers =
|
module Handlers =
|
||||||
let notImplemented = fun _ -> async.Return (Error "not implemented")
|
let notImplemented = fun _ -> failwith "Not implemented"
|
||||||
|
|
||||||
type private Permission = {
|
type private Permission = {
|
||||||
uid: string
|
uid: string
|
||||||
@@ -334,10 +334,12 @@ module Handlers =
|
|||||||
|> Array.ofSeq
|
|> Array.ofSeq
|
||||||
|
|
||||||
let filter = {
|
let filter = {
|
||||||
|
id = None
|
||||||
archiveType = Some aType
|
archiveType = Some aType
|
||||||
owner = Some user
|
owner = Some user
|
||||||
user = Some user
|
user = Some user
|
||||||
groups = Some groups
|
groups = Some groups
|
||||||
|
searchTerm = None
|
||||||
}
|
}
|
||||||
|
|
||||||
async {
|
async {
|
||||||
@@ -607,19 +609,32 @@ module Handlers =
|
|||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
|
|
||||||
let getArchives (ctx: HttpContext) (mid: ModelAreaId, filter: ArchiveFilter) =
|
let getArchives (ctx: HttpContext) (page: int) (rowCount: int) (filter: ArchiveFilter) =
|
||||||
async {
|
async {
|
||||||
let db = Archives.Archivist (Db.getDataSource ())
|
let db = Archives.Archivist (Db.getDataSource ())
|
||||||
|
match db.getArchives(page, rowCount, filter) with
|
||||||
match db.getModelAreaArchives (mid, filter) with
|
|
||||||
| Error err ->
|
|
||||||
Log.Error $"getArchives: error: {err}"
|
|
||||||
ctx.SetStatusCode 500
|
|
||||||
return Error "Could not retrieve model area archives"
|
|
||||||
| Ok archives ->
|
| Ok archives ->
|
||||||
Log.Debug $"getArchives: %A{archives}"
|
Log.Debug("[Archmaester] getArchives: length for filter {Filter}: {ArchiveCount}", filter, archives.Length)
|
||||||
ctx.SetStatusCode 200
|
ctx.SetStatusCode 200
|
||||||
return Ok archives
|
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) =
|
let getArchiveOrModelPolygon (ctx: HttpContext) (aid: ArchiveId) =
|
||||||
@@ -703,9 +718,8 @@ module Handlers =
|
|||||||
}
|
}
|
||||||
|
|
||||||
let getAcl (ctx: HttpContext) (aid: ArchiveId) =
|
let getAcl (ctx: HttpContext) (aid: ArchiveId) =
|
||||||
Log.Information $"Getting archive acl: {aid}"
|
|
||||||
|
|
||||||
async {
|
async {
|
||||||
|
Log.Information $"Getting archive acl: {aid}"
|
||||||
let db = Archives.Archivist (Db.getDataSource ())
|
let db = Archives.Archivist (Db.getDataSource ())
|
||||||
|
|
||||||
match db.getArchiveAcl aid with
|
match db.getArchiveAcl aid with
|
||||||
@@ -719,6 +733,42 @@ module Handlers =
|
|||||||
return Error $"Could not retrieve acl: {err}"
|
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[]) =
|
let addToArchiveAcl (ctx: HttpContext) (aclType: AclType) (aid: ArchiveId, names: string[]) =
|
||||||
Log.Information $"Adding acl to archive: {aid}"
|
Log.Information $"Adding acl to archive: {aid}"
|
||||||
|
|
||||||
@@ -748,13 +798,13 @@ module Handlers =
|
|||||||
return Error $"Could not add acl: {err}"
|
return Error $"Could not add acl: {err}"
|
||||||
}
|
}
|
||||||
|
|
||||||
let addToAcl (ctx: HttpContext) (aclType: AclType) (names: string[]) =
|
let addToAcl (ctx: HttpContext) (aclType: AclType) (request: AddUsersRequest) =
|
||||||
Log.Information $"Adding acl {aclType}: %A{names}"
|
Log.Information $"Adding acl {aclType}: %A{request.users}"
|
||||||
|
|
||||||
async {
|
async {
|
||||||
let db = Archives.Archivist (Db.getDataSource ())
|
let db = Archives.Archivist (Db.getDataSource ())
|
||||||
|
|
||||||
match db.tryAddToAcl (aclType, names) with
|
match db.tryAddToAcl (aclType, request.users) with
|
||||||
| Ok _ ->
|
| Ok _ ->
|
||||||
ctx.SetStatusCode 201
|
ctx.SetStatusCode 201
|
||||||
return Ok ()
|
return Ok ()
|
||||||
@@ -928,14 +978,14 @@ module Handlers =
|
|||||||
let user = ctx.User.Identity.Name
|
let user = ctx.User.Identity.Name
|
||||||
|
|
||||||
{
|
{
|
||||||
getModelAreaArchives = getModelAreaArchives ctx
|
|
||||||
addSubArchive = fun sub -> requireEditor user sub.reference (fun () -> createSubArchive ctx sub)
|
addSubArchive = fun sub -> requireEditor user sub.reference (fun () -> createSubArchive ctx sub)
|
||||||
getArchive = fun aid -> requireViewer user aid (fun () -> getArchiveProps ctx aid)
|
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)
|
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)
|
retireArchive = fun aid -> requireEditor user aid (fun () -> deleteArchive ctx aid)
|
||||||
updateArchive = fun (aid, _ as args) -> requireEditor user aid (fun () -> updateArchive ctx args)
|
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 = {
|
let modelAreaHandlers (ctx: HttpContext) : Api.ModelArea = {
|
||||||
@@ -946,28 +996,28 @@ module Handlers =
|
|||||||
}
|
}
|
||||||
|
|
||||||
let adminHandlers (ctx: HttpContext) : Api.Admin = {
|
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
|
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
|
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 =
|
module Endpoints =
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
namespace Common
|
namespace Common
|
||||||
|
|
||||||
module Utils =
|
open System
|
||||||
open System
|
|
||||||
open System.Net.Http
|
|
||||||
|
|
||||||
|
module Utils =
|
||||||
open Azure.Identity
|
open Azure.Identity
|
||||||
open Azure.Security.KeyVault.Secrets
|
open Azure.Security.KeyVault.Secrets
|
||||||
open Serilog
|
open Serilog
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ let corsPolicy (policy: CorsPolicyBuilder) =
|
|||||||
.AllowCredentials()
|
.AllowCredentials()
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.WithOrigins appsettings.file.allowedOrigins
|
.WithOrigins(appsettings.file.allowedOrigins)
|
||||||
|
.SetIsOriginAllowedToAllowWildcardSubdomains()
|
||||||
|> ignore
|
|> ignore
|
||||||
|
|
||||||
type UserIdProvider() =
|
type UserIdProvider() =
|
||||||
|
|||||||
@@ -61,9 +61,8 @@
|
|||||||
"connString": "Username=postgres;Password=secret;Host=localhost;Port=5432;Database=app;Pooling=true;",
|
"connString": "Username=postgres;Password=secret;Host=localhost;Port=5432;Database=app;Pooling=true;",
|
||||||
"sorcerer" : "https://<x>-sorcerer.ekman.oceanbox.io",
|
"sorcerer" : "https://<x>-sorcerer.ekman.oceanbox.io",
|
||||||
"allowedOrigins": [
|
"allowedOrigins": [
|
||||||
"https://atlantis.beta.oceanbox.io",
|
"http://*.oceanbox.io",
|
||||||
"https://<x>-atlantis.dev.oceanbox.io",
|
"https://*.oceanbox.io",
|
||||||
"https://atlantis.local.oceanbox.io:8080"
|
|
||||||
],
|
],
|
||||||
"appName": "atlantis",
|
"appName": "atlantis",
|
||||||
"appEnv": "<x>",
|
"appEnv": "<x>",
|
||||||
|
|||||||
12
src/Codex/.gitlab-ci.yml
Normal file
12
src/Codex/.gitlab-ci.yml
Normal 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
6
src/Codex/Dockerfile
Normal 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
45
src/Codex/Tiltfile
Normal 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
23
src/Codex/container.nix
Normal 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
38
src/Codex/default.nix
Normal 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; };
|
||||||
|
}
|
||||||
747
src/Codex/src/Client/Archive.fs
Normal file
747
src/Codex/src/Client/Archive.fs
Normal 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"
|
||||||
|
]
|
||||||
252
src/Codex/src/Client/Archives.fs
Normal file
252
src/Codex/src/Client/Archives.fs
Normal 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 ()
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
72
src/Codex/src/Client/Archives/InfoSection.fs
Normal file
72
src/Codex/src/Client/Archives/InfoSection.fs
Normal 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 [||])
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
199
src/Codex/src/Client/Archives/List.fs
Normal file
199
src/Codex/src/Client/Archives/List.fs
Normal 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 ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
192
src/Codex/src/Client/Archives/TypeSelect.fs
Normal file
192
src/Codex/src/Client/Archives/TypeSelect.fs
Normal 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
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
73
src/Codex/src/Client/Archives/Utils.fs
Normal file
73
src/Codex/src/Client/Archives/Utils.fs
Normal 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"
|
||||||
|
}
|
||||||
|
|
||||||
70
src/Codex/src/Client/ArchivesList.fs
Normal file
70
src/Codex/src/Client/ArchivesList.fs
Normal 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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
62
src/Codex/src/Client/Codex.Client.fsproj
Normal file
62
src/Codex/src/Client/Codex.Client.fsproj
Normal 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>
|
||||||
57
src/Codex/src/Client/Components.fs
Normal file
57
src/Codex/src/Client/Components.fs
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
66
src/Codex/src/Client/Drifters.fs
Normal file
66
src/Codex/src/Client/Drifters.fs
Normal 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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
9
src/Codex/src/Client/DriftersList.fs
Normal file
9
src/Codex/src/Client/DriftersList.fs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Oceanbox.Codex
|
||||||
|
|
||||||
|
open Browser
|
||||||
|
open Fable.Core
|
||||||
|
open Fable.Remoting.Client
|
||||||
|
open Feliz
|
||||||
|
open Feliz.Router
|
||||||
|
|
||||||
|
module Drifters
|
||||||
24
src/Codex/src/Client/Extensions.fs
Normal file
24
src/Codex/src/Client/Extensions.fs
Normal 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
|
||||||
473
src/Codex/src/Client/Group.fs
Normal file
473
src/Codex/src/Client/Group.fs
Normal 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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
262
src/Codex/src/Client/GroupArchive.fs
Normal file
262
src/Codex/src/Client/GroupArchive.fs
Normal 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)
|
||||||
|
]
|
||||||
234
src/Codex/src/Client/GroupArchiveAddForm.fs
Normal file
234
src/Codex/src/Client/GroupArchiveAddForm.fs
Normal 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 -> ()
|
||||||
|
]
|
||||||
|
]
|
||||||
14
src/Codex/src/Client/Groups.fs
Normal file
14
src/Codex/src/Client/Groups.fs
Normal 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 ()
|
||||||
|
]
|
||||||
|
]
|
||||||
189
src/Codex/src/Client/Groups/ExecForm.fs
Normal file
189
src/Codex/src/Client/Groups/ExecForm.fs
Normal 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."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
41
src/Codex/src/Client/Groups/List.fs
Normal file
41
src/Codex/src/Client/Groups/List.fs
Normal 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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
14
src/Codex/src/Client/Groups/Utils.fs
Normal file
14
src/Codex/src/Client/Groups/Utils.fs
Normal 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"
|
||||||
55
src/Codex/src/Client/Groups/ViewForm.fs
Normal file
55
src/Codex/src/Client/Groups/ViewForm.fs
Normal 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."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
51
src/Codex/src/Client/Groups/useGroups.fs
Normal file
51
src/Codex/src/Client/Groups/useGroups.fs
Normal 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
|
||||||
45
src/Codex/src/Client/Index.fs
Normal file
45
src/Codex/src/Client/Index.fs
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
8
src/Codex/src/Client/Main.fs
Normal file
8
src/Codex/src/Client/Main.fs
Normal 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())
|
||||||
62
src/Codex/src/Client/Map.fs
Normal file
62
src/Codex/src/Client/Map.fs
Normal 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)
|
||||||
|
]
|
||||||
|
]
|
||||||
122
src/Codex/src/Client/ModelArea.fs
Normal file
122
src/Codex/src/Client/ModelArea.fs
Normal 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"
|
||||||
|
]
|
||||||
95
src/Codex/src/Client/ModelAreas.fs
Normal file
95
src/Codex/src/Client/ModelAreas.fs
Normal 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)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
38
src/Codex/src/Client/OpenFGA/ArchiveOwnerList.fs
Normal file
38
src/Codex/src/Client/OpenFGA/ArchiveOwnerList.fs
Normal 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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
54
src/Codex/src/Client/OpenFGA/Checkbox.fs
Normal file
54
src/Codex/src/Client/OpenFGA/Checkbox.fs
Normal 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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
66
src/Codex/src/Client/OpenFGA/Types.fs
Normal file
66
src/Codex/src/Client/OpenFGA/Types.fs
Normal 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
|
||||||
51
src/Codex/src/Client/OpenFGA/useObjects.fs
Normal file
51
src/Codex/src/Client/OpenFGA/useObjects.fs
Normal 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
|
||||||
56
src/Codex/src/Client/OpenFGA/useReadTuples.fs
Normal file
56
src/Codex/src/Client/OpenFGA/useReadTuples.fs
Normal 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
|
||||||
51
src/Codex/src/Client/OpenFGA/useUsers.fs
Normal file
51
src/Codex/src/Client/OpenFGA/useUsers.fs
Normal 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
|
||||||
102
src/Codex/src/Client/Organization.fs
Normal file
102
src/Codex/src/Client/Organization.fs
Normal 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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
44
src/Codex/src/Client/Organizations.fs
Normal file
44
src/Codex/src/Client/Organizations.fs
Normal 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"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
43
src/Codex/src/Client/README.md
Normal file
43
src/Codex/src/Client/README.md
Normal 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
|
||||||
|
```
|
||||||
42
src/Codex/src/Client/Remoting.fs
Normal file
42
src/Codex/src/Client/Remoting.fs
Normal 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>
|
||||||
13
src/Codex/src/Client/Types.fs
Normal file
13
src/Codex/src/Client/Types.fs
Normal 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
|
||||||
|
}
|
||||||
226
src/Codex/src/Client/User.fs
Normal file
226
src/Codex/src/Client/User.fs
Normal 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
6
src/Codex/src/Client/build.sh
Executable 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
|
||||||
75
src/Codex/src/Client/default.nix
Normal file
75
src/Codex/src/Client/default.nix
Normal 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
|
||||||
|
'';
|
||||||
|
}
|
||||||
15
src/Codex/src/Client/index.html
Normal file
15
src/Codex/src/Client/index.html
Normal 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>
|
||||||
656
src/Codex/src/Client/packages.lock.json
Normal file
656
src/Codex/src/Client/packages.lock.json
Normal 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, )"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/Codex/src/Client/public/img/favicon-16x16.png
Normal file
BIN
src/Codex/src/Client/public/img/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 514 B |
BIN
src/Codex/src/Client/public/img/favicon-32x32.png
Normal file
BIN
src/Codex/src/Client/public/img/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1010 B |
150
src/Codex/src/Client/public/main.css
Normal file
150
src/Codex/src/Client/public/main.css
Normal 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
3
src/Codex/src/Client/run
Executable 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
|
||||||
328
src/Codex/src/Server/Admin.fs
Normal file
328
src/Codex/src/Server/Admin.fs
Normal 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
|
||||||
423
src/Codex/src/Server/Archmaester.fs
Normal file
423
src/Codex/src/Server/Archmaester.fs
Normal 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
|
||||||
|
}
|
||||||
43
src/Codex/src/Server/Codex.Server.fsproj
Normal file
43
src/Codex/src/Server/Codex.Server.fsproj
Normal 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>
|
||||||
332
src/Codex/src/Server/OpenFGA.fs
Normal file
332
src/Codex/src/Server/OpenFGA.fs
Normal 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
|
||||||
296
src/Codex/src/Server/Program.fs
Normal file
296
src/Codex/src/Server/Program.fs
Normal 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
|
||||||
|
}
|
||||||
|
)
|
||||||
84
src/Codex/src/Server/Settings.fs
Normal file
84
src/Codex/src/Server/Settings.fs
Normal 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
|
||||||
|
}
|
||||||
32
src/Codex/src/Server/Utils.fs
Normal file
32
src/Codex/src/Server/Utils.fs
Normal 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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Host: auth.oceanbox.io
|
||||||
66
src/Codex/src/Server/appsettings.Development.json
Normal file
66
src/Codex/src/Server/appsettings.Development.json
Normal 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" ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
src/Codex/src/Server/appsettings.json
Normal file
10
src/Codex/src/Server/appsettings.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
41
src/Codex/src/Server/default.nix
Normal file
41
src/Codex/src/Server/default.nix
Normal 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
|
||||||
|
'';
|
||||||
|
}
|
||||||
1381
src/Codex/src/Server/packages.lock.json
Normal file
1381
src/Codex/src/Server/packages.lock.json
Normal file
File diff suppressed because it is too large
Load Diff
9
src/Codex/src/Server/web.config
Normal file
9
src/Codex/src/Server/web.config
Normal 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>
|
||||||
163
src/Codex/src/Shared/Remoting.fs
Normal file
163
src/Codex/src/Shared/Remoting.fs
Normal 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>>
|
||||||
|
}
|
||||||
30
src/Codex/tilt/deploy.yaml
Normal file
30
src/Codex/tilt/deploy.yaml
Normal 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
|
||||||
28
src/Codex/tilt/ingress.yaml
Normal file
28
src/Codex/tilt/ingress.yaml
Normal 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
138
src/Codex/tilt/k8s.yaml
Normal 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
43
src/Codex/vite.config.js
Normal 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"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
module Oceanbox.DataAgent.Archives
|
module Oceanbox.DataAgent.Archives
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
open System.Collections
|
||||||
|
open System.Linq
|
||||||
|
|
||||||
open FSharpPlus
|
open FSharpPlus
|
||||||
|
open FSharp.Linq.NullableOperators
|
||||||
open Microsoft.FSharp.Core
|
open Microsoft.FSharp.Core
|
||||||
open NetTopologySuite.Geometries
|
open NetTopologySuite.Geometries
|
||||||
open Npgsql
|
open Npgsql
|
||||||
open Serilog
|
open Serilog
|
||||||
open Microsoft.EntityFrameworkCore
|
open Microsoft.EntityFrameworkCore
|
||||||
open System.Collections
|
|
||||||
open System.Linq
|
|
||||||
|
|
||||||
open Archmaester.Dto
|
open Archmaester.Dto
|
||||||
|
|
||||||
@@ -168,18 +170,15 @@ let private archiveToDto (ctx: Entity.ArchiveContext) (a: Entity.Archive) =
|
|||||||
Log.Error $"archiveToDto: {exn}"
|
Log.Error $"archiveToDto: {exn}"
|
||||||
reraise ()
|
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 toString (s: string) = if isNull s then "" else s
|
||||||
|
|
||||||
let focalPt =
|
let focalPt =
|
||||||
if isNull a.Attribs.FocalPoint then
|
if isNull a.Attribs.FocalPoint then
|
||||||
0f, 0f
|
0f, 0f
|
||||||
else
|
else
|
||||||
a.Attribs.FocalPoint |> fun x -> single x.X, single x.Y
|
a.Attribs.FocalPoint |> fun x -> single x.X, single x.Y
|
||||||
|
|
||||||
let archiveType =
|
let archiveType =
|
||||||
a.Attribs.Type |> fun y -> ArchiveType.FromDbType (y.Kind, y.Variant, y.Format)
|
a.Attribs.Type |> fun y -> ArchiveType.FromDbType (y.Kind, y.Variant, y.Format)
|
||||||
|
|
||||||
let json: string =
|
let json: string =
|
||||||
if not (isNull a.Json) && a.Json.Length > 0 then // use archive json if defined
|
if not (isNull a.Json) && a.Json.Length > 0 then // use archive json if defined
|
||||||
a.Json
|
a.Json
|
||||||
@@ -187,14 +186,20 @@ let archiveToProps (ctx: Entity.ArchiveContext) (a: Entity.Archive) : ArchivePro
|
|||||||
a.Attribs.Json // else inherit from attribs
|
a.Attribs.Json // else inherit from attribs
|
||||||
else
|
else
|
||||||
""
|
""
|
||||||
|
let polygon =
|
||||||
let owners =
|
if isNull a.Geometry then
|
||||||
ctx.Users
|
None
|
||||||
.Where(fun u -> a.Owners.Select(_.OwnerId).Contains u.UserId)
|
else
|
||||||
.Select(_.Name)
|
geometryToPolygon a.Geometry |> fun y -> Some y[0 .. y.Length - 2] // last element is a dummy
|
||||||
.ToArray ()
|
let owner =
|
||||||
|
a.Owners
|
||||||
Log.Debug $"DataAgent.Archives.archiveToChart: %A{a.Name}"
|
|> Option.ofObj
|
||||||
|
|> Option.bind (
|
||||||
|
Array.ofSeq
|
||||||
|
>> Array.tryHead
|
||||||
|
>> Option.map _.Owner.Name
|
||||||
|
)
|
||||||
|
|> Option.defaultValue ""
|
||||||
|
|
||||||
{
|
{
|
||||||
archiveId = a.ArchiveId
|
archiveId = a.ArchiveId
|
||||||
@@ -211,14 +216,11 @@ let archiveToProps (ctx: Entity.ArchiveContext) (a: Entity.Archive) : ArchivePro
|
|||||||
startTime = a.StartTime
|
startTime = a.StartTime
|
||||||
endTime = a.StartTime.AddSeconds (a.Frames * a.Attribs.Freq |> float)
|
endTime = a.StartTime.AddSeconds (a.Frames * a.Attribs.Freq |> float)
|
||||||
created = a.Created
|
created = a.Created
|
||||||
owner = owners |> Seq.tryHead |> Option.defaultValue "system"
|
owner = owner
|
||||||
expires = if a.Expires.HasValue then Some a.Expires.Value else None
|
expires = if a.Expires.HasValue then Some a.Expires.Value else None
|
||||||
isPublished = a.Published
|
isPublished = a.Published
|
||||||
isPublic = a.Public
|
isPublic = a.Public
|
||||||
polygon =
|
polygon = polygon
|
||||||
if isNull a.Geometry then
|
|
||||||
None
|
|
||||||
else geometryToPolygon a.Geometry |> fun y -> Some y[0 .. y.Length - 2] // last element is a dummy
|
|
||||||
json = json
|
json = json
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +231,7 @@ let retireDanglingAttribs (ctx: Entity.ArchiveContext) =
|
|||||||
type Archivist(dataSource: NpgsqlDataSource) =
|
type Archivist(dataSource: NpgsqlDataSource) =
|
||||||
let withDb qry =
|
let withDb qry =
|
||||||
try
|
try
|
||||||
let ctx = new Entity.ArchiveContext(dataSource)
|
let ctx = new Entity.ArchiveContext(dataSource, true)
|
||||||
qry ctx |> Ok
|
qry ctx |> Ok
|
||||||
with e ->
|
with e ->
|
||||||
Log.Error $"DataAgent.Archives.Archivist.withDb: {e}"
|
Log.Error $"DataAgent.Archives.Archivist.withDb: {e}"
|
||||||
@@ -1076,22 +1078,90 @@ type Archivist(dataSource: NpgsqlDataSource) =
|
|||||||
.ThenInclude(_.Type)
|
.ThenInclude(_.Type)
|
||||||
.Include(_.Owners)
|
.Include(_.Owners)
|
||||||
.ToArray ()
|
.ToArray ()
|
||||||
|> Array.map (archiveToProps ctx)
|
|> Array.map archiveToProps
|
||||||
|> Ok
|
|> Ok
|
||||||
with e ->
|
with e ->
|
||||||
Log.Error $"Archivist.getModelAreaArchives error: {e.Message}"
|
Log.Error $"Archivist.getModelAreaArchives error: {e.Message}"
|
||||||
Log.Debug $"{e}"
|
Log.Debug $"{e}"
|
||||||
Error $"Could not retrieve model area ({modelId}) archives"
|
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) =
|
member _.updateArchive(aid: ArchiveId, item: Archmaester.Forms.ArchiveForm) =
|
||||||
let ctx = new Entity.ArchiveContext (dataSource)
|
let ctx = new Entity.ArchiveContext (dataSource)
|
||||||
let transaction = ctx.Database.BeginTransaction ()
|
let transaction = ctx.Database.BeginTransaction ()
|
||||||
let tryCommit = tryCommit transaction
|
let tryCommit = tryCommit transaction
|
||||||
|
|
||||||
let expiry =
|
let expiry =
|
||||||
match item.expires with
|
item.expires
|
||||||
| Some e -> Nullable (e.ToUniversalTime ())
|
|> Option.map _.ToUniversalTime()
|
||||||
| None -> Nullable ()
|
|> Option.toNullable
|
||||||
|
|
||||||
let setGeometry (a: Entity.Archive) coords =
|
let setGeometry (a: Entity.Archive) coords =
|
||||||
let line =
|
let line =
|
||||||
@@ -1254,7 +1324,7 @@ type Archivist(dataSource: NpgsqlDataSource) =
|
|||||||
Log.Debug $"Archives.tryGetEntityArchive error:\n{e}"
|
Log.Debug $"Archives.tryGetEntityArchive error:\n{e}"
|
||||||
$"Could not find archive with id {aid}")
|
$"Could not find archive with id {aid}")
|
||||||
|
|
||||||
member x.tryGetArchive aid =
|
member _.tryGetArchive aid =
|
||||||
withDb (fun ctx ->
|
withDb (fun ctx ->
|
||||||
ctx.Archives
|
ctx.Archives
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -1295,7 +1365,8 @@ type Archivist(dataSource: NpgsqlDataSource) =
|
|||||||
.ToArray ()
|
.ToArray ()
|
||||||
|> Array.map (fun archive ->
|
|> Array.map (fun archive ->
|
||||||
Log.Debug $"ref archive props: %A{archive.Attribs}"
|
Log.Debug $"ref archive props: %A{archive.Attribs}"
|
||||||
archiveToProps ctx archive)
|
archiveToProps archive
|
||||||
|
)
|
||||||
|> Ok
|
|> Ok
|
||||||
with e ->
|
with e ->
|
||||||
Log.Error $"Archivist.getRefArchivesProps error: {e.Message}"
|
Log.Error $"Archivist.getRefArchivesProps error: {e.Message}"
|
||||||
@@ -1373,7 +1444,9 @@ type Archivist(dataSource: NpgsqlDataSource) =
|
|||||||
.ToArray ()
|
.ToArray ()
|
||||||
|> Array.map (fun archive ->
|
|> Array.map (fun archive ->
|
||||||
Log.Debug $"ref archive props: {archive.Name} - {archive.ArchiveId}"
|
Log.Debug $"ref archive props: {archive.Name} - {archive.ArchiveId}"
|
||||||
archiveToProps ctx archive))
|
archiveToProps archive
|
||||||
|
)
|
||||||
|
)
|
||||||
with e ->
|
with e ->
|
||||||
Log.Error $"Archivist.getRealtedArchive error: {e.Message}"
|
Log.Error $"Archivist.getRealtedArchive error: {e.Message}"
|
||||||
Log.Debug $"{e}"
|
Log.Debug $"{e}"
|
||||||
@@ -1423,8 +1496,8 @@ type Archivist(dataSource: NpgsqlDataSource) =
|
|||||||
.ThenInclude(_.Type)
|
.ThenInclude(_.Type)
|
||||||
.Include(_.Owners)
|
.Include(_.Owners)
|
||||||
.Single ()
|
.Single ()
|
||||||
|> archiveToProps ctx)
|
|> archiveToProps
|
||||||
|
)
|
||||||
|> Result.mapError (fun e -> $"Could not find archive with id: {aid}: {e}")
|
|> Result.mapError (fun e -> $"Could not find archive with id: {aid}: {e}")
|
||||||
|
|
||||||
member x.getArchiveAcl(archiveId: ArchiveId) =
|
member x.getArchiveAcl(archiveId: ArchiveId) =
|
||||||
@@ -1440,6 +1513,43 @@ type Archivist(dataSource: NpgsqlDataSource) =
|
|||||||
.Single ())
|
.Single ())
|
||||||
|> Result.map toAclDto
|
|> 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[]) =
|
member x.tryAddToAcl(aclType: AclType, names: string[]) =
|
||||||
withDb (fun ctx ->
|
withDb (fun ctx ->
|
||||||
match aclType with
|
match aclType with
|
||||||
|
|||||||
@@ -134,15 +134,18 @@ type GeometryHandler<'T when 'T :> Geometry >() =
|
|||||||
p.NpgsqlDbType <- NpgsqlTypes.NpgsqlDbType.Geometry
|
p.NpgsqlDbType <- NpgsqlTypes.NpgsqlDbType.Geometry
|
||||||
p.DataTypeName <- "public.geometry"
|
p.DataTypeName <- "public.geometry"
|
||||||
|
|
||||||
OptionTypes.register ()
|
let register () =
|
||||||
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Geometry>())
|
OptionTypes.register ()
|
||||||
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Point>())
|
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Geometry>())
|
||||||
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Polygon>())
|
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Point>())
|
||||||
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<LineString>())
|
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<Polygon>())
|
||||||
SqlMapper.AddTypeHandler(GeometryHandler<Geometry>())
|
SqlMapper.AddTypeHandler(OptionTypes.OptionHandler<LineString>())
|
||||||
SqlMapper.AddTypeHandler(GeometryHandler<Point>())
|
SqlMapper.AddTypeHandler(GeometryHandler<Geometry>())
|
||||||
SqlMapper.AddTypeHandler(GeometryHandler<Polygon>())
|
SqlMapper.AddTypeHandler(GeometryHandler<Point>())
|
||||||
SqlMapper.AddTypeHandler(GeometryHandler<LineString>())
|
SqlMapper.AddTypeHandler(GeometryHandler<Polygon>())
|
||||||
|
SqlMapper.AddTypeHandler(GeometryHandler<LineString>())
|
||||||
|
|
||||||
|
register ()
|
||||||
|
|
||||||
let taskToList (t: Task<seq<'a>>) = t |> Async.AwaitTask |> Async.RunSynchronously |> Seq.toList
|
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 taskToArray (t: Task<seq<'a>>) = t |> Async.AwaitTask |> Async.RunSynchronously |> Seq.toArray
|
||||||
|
|||||||
@@ -1,229 +1,226 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace Entity
|
namespace Entity;
|
||||||
|
|
||||||
|
public partial class ArchiveContext : DbContext
|
||||||
{
|
{
|
||||||
public partial class ArchiveContext : DbContext
|
private readonly bool _debug = false;
|
||||||
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
|
|
||||||
|
public ArchiveContext()
|
||||||
{
|
{
|
||||||
bool _debug = false;
|
_dataSource = NpgsqlDataSource.Create("");
|
||||||
NpgsqlDataSource _dataSource;
|
}
|
||||||
|
|
||||||
public ArchiveContext()
|
public ArchiveContext(NpgsqlDataSource dataSource, bool debug = false)
|
||||||
{
|
{
|
||||||
_dataSource = NpgsqlDataSource.Create("");
|
_dataSource = dataSource;
|
||||||
}
|
_debug = debug;
|
||||||
|
}
|
||||||
|
|
||||||
public ArchiveContext(NpgsqlDataSource dataSource, bool debug = false)
|
public ArchiveContext(string conn, DbContextOptions<ArchiveContext> options)
|
||||||
{
|
: base(options)
|
||||||
_dataSource = dataSource;
|
{
|
||||||
_debug = debug;
|
_dataSource = NpgsqlDataSource.Create(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ArchiveContext(string conn, DbContextOptions<ArchiveContext> options)
|
public DbSet<ModelArea> ModelAreas { get; set; }
|
||||||
: base(options)
|
public DbSet<Archive> Archives { get; set; }
|
||||||
{
|
public DbSet<Attribs> Attribs { get; set; }
|
||||||
_dataSource = NpgsqlDataSource.Create(conn);
|
public DbSet<Type> Types { get; set; }
|
||||||
}
|
public DbSet<File> Files { get; set; }
|
||||||
|
public DbSet<Group> Groups { get; set; }
|
||||||
|
public DbSet<User> Users { get; set; }
|
||||||
|
public DbSet<ArchiveFile> ArchiveFiles { get; set; }
|
||||||
|
public DbSet<ArchiveOwner> ArchiveOwners { get; set; }
|
||||||
|
public DbSet<ArchiveUser> ArchiveUsers { get; set; }
|
||||||
|
public DbSet<ArchiveGroup> ArchiveGroups { get; set; }
|
||||||
|
public DbSet<Share> Shares { get; set; }
|
||||||
|
public DbSet<Association> Association { get; set; }
|
||||||
|
|
||||||
public DbSet<ModelArea> ModelAreas { get; set; }
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
public DbSet<Archive> Archives { get; set; }
|
{
|
||||||
public DbSet<Attribs> Attribs { get; set; }
|
if (optionsBuilder.IsConfigured) return;
|
||||||
public DbSet<Type> Types { get; set; }
|
|
||||||
public DbSet<File> Files { get; set; }
|
|
||||||
public DbSet<Group> Groups { get; set; }
|
|
||||||
public DbSet<User> Users { get; set; }
|
|
||||||
public DbSet<ArchiveFile> ArchiveFiles { get; set; }
|
|
||||||
public DbSet<ArchiveOwner> ArchiveOwners { get; set; }
|
|
||||||
public DbSet<ArchiveUser> ArchiveUsers { get; set; }
|
|
||||||
public DbSet<ArchiveGroup> ArchiveGroups { get; set; }
|
|
||||||
public DbSet<Share> Shares { get; set; }
|
|
||||||
public DbSet<Association> Association { get; set; }
|
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
optionsBuilder.UseNpgsql(
|
||||||
{
|
_dataSource,
|
||||||
if (optionsBuilder.IsConfigured) return;
|
o =>
|
||||||
|
|
||||||
optionsBuilder.EnableSensitiveDataLogging();
|
|
||||||
|
|
||||||
optionsBuilder.UseNpgsql(
|
|
||||||
_dataSource,
|
|
||||||
o =>
|
|
||||||
{
|
|
||||||
o.UseNetTopologySuite();
|
|
||||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (_debug)
|
|
||||||
{
|
{
|
||||||
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
|
o.UseNetTopologySuite();
|
||||||
|
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
if (_debug)
|
||||||
{
|
{
|
||||||
modelBuilder.HasPostgresExtension("postgis");
|
optionsBuilder.EnableSensitiveDataLogging();
|
||||||
|
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
|
||||||
modelBuilder.Entity<ModelArea>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("model_areas");
|
|
||||||
entity
|
|
||||||
.HasIndex(p => p.Name)
|
|
||||||
.IsUnique();
|
|
||||||
entity
|
|
||||||
.Property(p => p.Json)
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Archive>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("archives");
|
|
||||||
|
|
||||||
entity.HasMany(e => e.Files)
|
|
||||||
.WithOne(f => f.Archive)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(e => e.Owners)
|
|
||||||
.WithOne(f => f.Archive)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(e => e.Users)
|
|
||||||
.WithOne(f => f.Archive)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(e => e.Groups)
|
|
||||||
.WithOne(f => f.Archive)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(e => e.Shares)
|
|
||||||
.WithOne(f => f.Archive)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(e => e.RefArchives)
|
|
||||||
.WithOne(f => f.Ref)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity
|
|
||||||
.Property(p => p.Json)
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Attribs>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("attribs");
|
|
||||||
entity.HasMany(e => e.Archives)
|
|
||||||
.WithOne(e => e.Attribs)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
entity.HasMany(e => e.Files)
|
|
||||||
.WithOne(e => e.Attribs)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity.HasMany(e => e.Associations)
|
|
||||||
.WithOne(f => f.Attributes)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
entity
|
|
||||||
.Property(p => p.Json)
|
|
||||||
.HasColumnType("jsonb");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Type>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasIndex(f => new { f.Kind, f.Variant, f.Format }).IsUnique();
|
|
||||||
entity.ToTable("types");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<File>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("files");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<ArchiveFile>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(af => new { af.ArchiveId, af.FileId });
|
|
||||||
entity.ToTable("archive_files");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<ArchiveOwner>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("archive_owners");
|
|
||||||
entity.HasKey(p => new {p.ArchiveId , p.OwnerId});
|
|
||||||
entity
|
|
||||||
.HasOne(f => f.Archive)
|
|
||||||
.WithMany(f => f.Owners);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<User>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("users");
|
|
||||||
entity.HasIndex(i => i.Name).IsUnique();
|
|
||||||
entity
|
|
||||||
.HasMany(x => x.Archives)
|
|
||||||
.WithOne(x => x.User)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<ArchiveUser>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("archive_users");
|
|
||||||
entity.HasKey(p => new {p.ArchiveId , p.UserId});
|
|
||||||
entity
|
|
||||||
.HasOne(f => f.Archive)
|
|
||||||
.WithMany(f => f.Users);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Group>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("groups");
|
|
||||||
entity.HasIndex(i => i.Name).IsUnique();
|
|
||||||
entity
|
|
||||||
.HasMany(x => x.Archives)
|
|
||||||
.WithOne(x => x.Group)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<ArchiveGroup>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("archive_groups");
|
|
||||||
entity.HasKey(p => new {p.ArchiveId , p.GroupId});
|
|
||||||
entity
|
|
||||||
.HasOne(f => f.Archive)
|
|
||||||
.WithMany(f => f.Groups);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Share>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("shares");
|
|
||||||
entity.HasKey(p => new {p.ShareId , p.ArchiveId});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Association>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasKey(rt => new { rt.AttributesId, rt.RefId });
|
|
||||||
entity.ToTable("associations");
|
|
||||||
});
|
|
||||||
|
|
||||||
OnModelCreatingPartial(modelBuilder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ArchiveContextFactory : IDesignTimeDbContextFactory<ArchiveContext>
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
public ArchiveContext CreateDbContext(string[] args)
|
modelBuilder.HasPostgresExtension("postgis");
|
||||||
{
|
|
||||||
const string db = "Host=localhost;Port=54322;Database=app;Username=postgres;Password=secret;Pooling=true";
|
|
||||||
var optionsBuilder = new DbContextOptionsBuilder<ArchiveContext>();
|
|
||||||
optionsBuilder.UseNpgsql(db, o => o.UseNetTopologySuite());
|
|
||||||
optionsBuilder.EnableSensitiveDataLogging(true);
|
|
||||||
|
|
||||||
return new ArchiveContext(db, optionsBuilder.Options);
|
modelBuilder.Entity<ModelArea>(entity =>
|
||||||
}
|
{
|
||||||
|
entity.ToTable("model_areas");
|
||||||
|
entity
|
||||||
|
.HasIndex(p => p.Name)
|
||||||
|
.IsUnique();
|
||||||
|
entity
|
||||||
|
.Property(p => p.Json)
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Archive>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("archives");
|
||||||
|
|
||||||
|
entity.HasMany(e => e.Files)
|
||||||
|
.WithOne(f => f.Archive)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(e => e.Owners)
|
||||||
|
.WithOne(f => f.Archive)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(e => e.Users)
|
||||||
|
.WithOne(f => f.Archive)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(e => e.Groups)
|
||||||
|
.WithOne(f => f.Archive)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(e => e.Shares)
|
||||||
|
.WithOne(f => f.Archive)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(e => e.RefArchives)
|
||||||
|
.WithOne(f => f.Ref)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.Property(p => p.Json)
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Attribs>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("attribs");
|
||||||
|
entity.HasMany(e => e.Archives)
|
||||||
|
.WithOne(e => e.Attribs)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
entity.HasMany(e => e.Files)
|
||||||
|
.WithOne(e => e.Attribs)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(e => e.Associations)
|
||||||
|
.WithOne(f => f.Attributes)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.Property(p => p.Json)
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Type>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(f => new { f.Kind, f.Variant, f.Format }).IsUnique();
|
||||||
|
entity.ToTable("types");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<File>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("files");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ArchiveFile>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(af => new { af.ArchiveId, af.FileId });
|
||||||
|
entity.ToTable("archive_files");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ArchiveOwner>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("archive_owners");
|
||||||
|
entity.HasKey(p => new {p.ArchiveId , p.OwnerId});
|
||||||
|
entity
|
||||||
|
.HasOne(f => f.Archive)
|
||||||
|
.WithMany(f => f.Owners);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<User>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("users");
|
||||||
|
entity.HasIndex(i => i.Name).IsUnique();
|
||||||
|
entity
|
||||||
|
.HasMany(x => x.Archives)
|
||||||
|
.WithOne(x => x.User)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ArchiveUser>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("archive_users");
|
||||||
|
entity.HasKey(p => new {p.ArchiveId , p.UserId});
|
||||||
|
entity
|
||||||
|
.HasOne(f => f.Archive)
|
||||||
|
.WithMany(f => f.Users);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Group>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("groups");
|
||||||
|
entity.HasIndex(i => i.Name).IsUnique();
|
||||||
|
entity
|
||||||
|
.HasMany(x => x.Archives)
|
||||||
|
.WithOne(x => x.Group)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ArchiveGroup>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("archive_groups");
|
||||||
|
entity.HasKey(p => new {p.ArchiveId , p.GroupId});
|
||||||
|
entity
|
||||||
|
.HasOne(f => f.Archive)
|
||||||
|
.WithMany(f => f.Groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Share>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("shares");
|
||||||
|
entity.HasKey(p => new {p.ShareId , p.ArchiveId});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Association>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(rt => new { rt.AttributesId, rt.RefId });
|
||||||
|
entity.ToTable("associations");
|
||||||
|
});
|
||||||
|
|
||||||
|
OnModelCreatingPartial(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ArchiveContextFactory : IDesignTimeDbContextFactory<ArchiveContext>
|
||||||
|
{
|
||||||
|
public ArchiveContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
const string db = "Host=localhost;Port=54322;Database=app;Username=postgres;Password=secret;Pooling=true";
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<ArchiveContext>();
|
||||||
|
optionsBuilder.UseNpgsql(db, o => o.UseNetTopologySuite());
|
||||||
|
optionsBuilder.EnableSensitiveDataLogging(true);
|
||||||
|
|
||||||
|
return new ArchiveContext(db, optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ open System
|
|||||||
open Dto
|
open Dto
|
||||||
open Forms
|
open Forms
|
||||||
|
|
||||||
|
[<Struct>]
|
||||||
|
type AddUsersRequest = {
|
||||||
|
group: string
|
||||||
|
users: string array
|
||||||
|
}
|
||||||
|
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Api =
|
module Api =
|
||||||
let apiRouteBuilder (typeName: string) (methodName: string) = $"/api/v1/{typeName}/{methodName}"
|
let apiRouteBuilder (typeName: string) (methodName: string) = $"/api/v1/{typeName}/{methodName}"
|
||||||
@@ -20,23 +26,23 @@ module Api =
|
|||||||
|
|
||||||
type Acl = {
|
type Acl = {
|
||||||
getAcl: ArchiveId -> Async<Result<ArchiveAcl, string>>
|
getAcl: ArchiveId -> Async<Result<ArchiveAcl, string>>
|
||||||
addOwners: ArchiveId * string[] -> Async<Result<unit, string>>
|
addOwners: ArchiveId * string array -> Async<Result<unit, string>>
|
||||||
addUsers: ArchiveId * string[] -> Async<Result<unit, string>>
|
addUsers: ArchiveId * string array -> Async<Result<unit, string>>
|
||||||
addGroups: ArchiveId * string[] -> Async<Result<unit, string>>
|
addGroups: ArchiveId * string array -> Async<Result<unit, string>>
|
||||||
removeOwners: ArchiveId * string[] -> Async<Result<unit, string>>
|
removeOwners: ArchiveId * string array -> Async<Result<unit, string>>
|
||||||
removeUsers: ArchiveId * string[] -> Async<Result<unit, string>>
|
removeUsers: ArchiveId * string array -> Async<Result<unit, string>>
|
||||||
removeGroups: ArchiveId * string[] -> Async<Result<unit, string>>
|
removeGroups: ArchiveId * string array -> Async<Result<unit, string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Archive = {
|
type Archive = {
|
||||||
|
addSubArchive: SubArchiveDef -> Async<Result<ArchiveProps, string>>
|
||||||
getArchive: ArchiveId -> Async<Result<ArchiveProps, string>>
|
getArchive: ArchiveId -> Async<Result<ArchiveProps, string>>
|
||||||
|
getArchivePolygon: ArchiveId -> Async<Result<(single * single)[], string>>
|
||||||
getModelAreaArchives: ModelAreaId * ArchiveType -> Async<Result<ArchiveProps[], string>>
|
getModelAreaArchives: ModelAreaId * ArchiveType -> Async<Result<ArchiveProps[], string>>
|
||||||
getRefArchives: ArchiveId * 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>>
|
retireArchive: ArchiveId -> Async<Result<unit, string>>
|
||||||
updateArchive: ArchiveId * ArchiveForm -> 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 = {
|
type ModelArea = {
|
||||||
@@ -47,26 +53,26 @@ module Api =
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Admin = {
|
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>>
|
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>>
|
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>>
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ module Dto =
|
|||||||
|
|
||||||
member x.Label() = DriftersVariant.Label(x)
|
member x.Label() = DriftersVariant.Label(x)
|
||||||
|
|
||||||
|
static member All =
|
||||||
|
[|
|
||||||
|
Lice
|
||||||
|
Virus
|
||||||
|
Transport
|
||||||
|
Sedimentation
|
||||||
|
Accumulation
|
||||||
|
AccumulationV2
|
||||||
|
WaterContact
|
||||||
|
Downwelling
|
||||||
|
|]
|
||||||
|
|
||||||
type DriftersFormat =
|
type DriftersFormat =
|
||||||
| Particle
|
| Particle
|
||||||
| Field2D
|
| Field2D
|
||||||
@@ -213,6 +225,7 @@ module Dto =
|
|||||||
| "accumulation_v2" -> Some DriftersVariant.AccumulationV2
|
| "accumulation_v2" -> Some DriftersVariant.AccumulationV2
|
||||||
| "watercontact" -> Some DriftersVariant.WaterContact
|
| "watercontact" -> Some DriftersVariant.WaterContact
|
||||||
| "downwelling" -> Some DriftersVariant.Downwelling
|
| "downwelling" -> Some DriftersVariant.Downwelling
|
||||||
|
| "*" -> Some DriftersVariant.Any
|
||||||
| _ -> None
|
| _ -> None
|
||||||
|
|
||||||
let f =
|
let f =
|
||||||
@@ -310,11 +323,37 @@ module Dto =
|
|||||||
| _ -> Any
|
| _ -> Any
|
||||||
|
|
||||||
static member FromDbType(kind, variant, format) =
|
static member FromDbType(kind, variant, format) =
|
||||||
let s = $"{kind}:{variant}:{format}"
|
let s = $"%s{kind}:%s{variant}:%s{format}"
|
||||||
ArchiveType.FromString s
|
ArchiveType.FromString s
|
||||||
|
|
||||||
member x.ToDbType() =
|
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 Polygon = (single * single)[]
|
||||||
type UserId = string
|
type UserId = string
|
||||||
@@ -385,10 +424,10 @@ module Dto =
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ArchiveAcl = {
|
type ArchiveAcl = {
|
||||||
owners: string[]
|
owners: string array
|
||||||
users: string[]
|
users: string array
|
||||||
groups: string[]
|
groups: string array
|
||||||
shares: Guid[]
|
shares: Guid array
|
||||||
} with
|
} with
|
||||||
static member empty = {
|
static member empty = {
|
||||||
owners = [||]
|
owners = [||]
|
||||||
@@ -457,12 +496,16 @@ module Dto =
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ArchiveFilter = {
|
type ArchiveFilter = {
|
||||||
|
id: ArchiveId option
|
||||||
|
searchTerm: string option
|
||||||
archiveType: ArchiveType option
|
archiveType: ArchiveType option
|
||||||
owner: string option
|
owner: string option
|
||||||
user: string option
|
user: string option
|
||||||
groups: string[] option
|
groups: string array option
|
||||||
} with
|
} with
|
||||||
static member empty = {
|
static member empty = {
|
||||||
|
id = None
|
||||||
|
searchTerm = None
|
||||||
archiveType = None
|
archiveType = None
|
||||||
owner = None
|
owner = None
|
||||||
user = None
|
user = None
|
||||||
@@ -492,4 +535,4 @@ module Dto =
|
|||||||
json = ""
|
json = ""
|
||||||
isPublished = false
|
isPublished = false
|
||||||
isPublic = true
|
isPublic = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ open System
|
|||||||
open System.Net.Http
|
open System.Net.Http
|
||||||
open System.Security.Claims
|
open System.Security.Claims
|
||||||
open System.Threading.Tasks
|
open System.Threading.Tasks
|
||||||
|
|
||||||
open FSharp.Data
|
open FSharp.Data
|
||||||
open FSharp.Data.HttpMethod
|
|
||||||
open IdentityModel.Client
|
open IdentityModel.Client
|
||||||
open Azure.Identity
|
open Azure.Identity
|
||||||
open Microsoft.AspNetCore.Authentication
|
open Microsoft.AspNetCore.Authentication
|
||||||
@@ -36,7 +36,7 @@ type OpenIdSettings = {
|
|||||||
device_authorization_endpoint: string
|
device_authorization_endpoint: string
|
||||||
clientId: string
|
clientId: string
|
||||||
clientSecret: string
|
clientSecret: string
|
||||||
scopes: string[]
|
scopes: string array
|
||||||
redirectUri: string option
|
redirectUri: string option
|
||||||
audiences: string list
|
audiences: string list
|
||||||
} with
|
} with
|
||||||
@@ -86,15 +86,15 @@ type SsoSettings = {
|
|||||||
type PlainAuthUser = {
|
type PlainAuthUser = {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
groups: string[]
|
groups: string array
|
||||||
roles: string[]
|
roles: string array
|
||||||
} with
|
} with
|
||||||
static member empty = { username = ""; password = ""; groups = [||]; roles = [||] }
|
static member empty = { username = ""; password = ""; groups = [||]; roles = [||] }
|
||||||
|
|
||||||
type MultiAuthSettings = {
|
type MultiAuthSettings = {
|
||||||
oidc: OpenIdSettings
|
oidc: OpenIdSettings
|
||||||
sso: SsoSettings
|
sso: SsoSettings
|
||||||
plainAuthUsers: PlainAuthUser[]
|
plainAuthUsers: PlainAuthUser array
|
||||||
}
|
}
|
||||||
|
|
||||||
type IPrincipalActor =
|
type IPrincipalActor =
|
||||||
@@ -125,59 +125,60 @@ module MultiAuthDefaults =
|
|||||||
| Cookie
|
| Cookie
|
||||||
| Plain
|
| Plain
|
||||||
|
|
||||||
|
let tryStr str =
|
||||||
|
if String.IsNullOrEmpty str then
|
||||||
|
None
|
||||||
|
else
|
||||||
|
Some str
|
||||||
let tryGetEnv =
|
let tryGetEnv =
|
||||||
Environment.GetEnvironmentVariable
|
Environment.GetEnvironmentVariable
|
||||||
>> function
|
>> tryStr
|
||||||
| null
|
|
||||||
| "" -> None
|
|
||||||
| x -> Some x
|
|
||||||
|
|
||||||
let authorize: HttpHandler =
|
let authorize: HttpHandler =
|
||||||
requiresAuthentication (challenge OpenIdConnectDefaults.AuthenticationScheme)
|
requiresAuthentication (challenge OpenIdConnectDefaults.AuthenticationScheme)
|
||||||
// requiresAuthentication (challenge CookieAuthenticationDefaults.AuthenticationScheme)
|
// requiresAuthentication (challenge CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
|
||||||
let addGroupsAndRoles (principal: ClaimsPrincipal) =
|
let addGroupsAndRoles (principal: ClaimsPrincipal) : Async<bool> =
|
||||||
let addToIdentity (identity: ClaimsIdentity) ctype cc =
|
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
|
cc
|
||||||
|> Array.choose (fun g ->
|
|> Array.choose (fun g ->
|
||||||
if not (principal.HasClaim (fun claim -> claim.Type = ctype && claim.Value = g)) then
|
if not (principal.HasClaim (fun claim -> claim.Type = ctype && claim.Value = g)) then
|
||||||
Claim (ctype, g) |> Some
|
Claim (ctype, g) |> Some
|
||||||
else
|
else
|
||||||
None)
|
None
|
||||||
|
)
|
||||||
|> Array.iter identity.AddClaim
|
|> Array.iter identity.AddClaim
|
||||||
|
|
||||||
let isUnclaimed = principal.HasClaim (fun claim -> claim.Type = "group") |> not
|
async {
|
||||||
|
let isUnclaimed = principal.HasClaim (fun claim -> claim.Type = "group") |> not
|
||||||
|
|
||||||
if principal.Identity.IsAuthenticated && isUnclaimed then
|
if principal.Identity.IsAuthenticated && isUnclaimed then
|
||||||
match principal.FindFirst ClaimTypes.Email with
|
match principal.FindFirst ClaimTypes.Email with
|
||||||
| null ->
|
| null ->
|
||||||
Log.Error $"Email claim missing for identity: {principal.Identity.Name}"
|
eprintfn $"[MultiAuth] Email claim missing for identity: {principal.Identity.Name}"
|
||||||
false
|
return false
|
||||||
| email ->
|
| email ->
|
||||||
let identity = ClaimsIdentity ()
|
let identity = ClaimsIdentity ()
|
||||||
task {
|
|
||||||
try
|
try
|
||||||
let aid = ActorId email.Value
|
let aid = ActorId email.Value
|
||||||
let proxy = ActorProxy.Create<IPrincipalActor> (aid, "PrincipalActor")
|
let proxy = ActorProxy.Create<IPrincipalActor> (aid, "PrincipalActor")
|
||||||
let! groups = proxy.GetGroups ()
|
let! groups = proxy.GetGroups () |> Async.AwaitTask
|
||||||
let! roles = proxy.GetRoles ()
|
let! roles = proxy.GetRoles () |> Async.AwaitTask
|
||||||
addToIdentity identity "group" groups
|
addToIdentity identity "group" groups
|
||||||
addToIdentity identity ClaimTypes.Role roles
|
addToIdentity identity ClaimTypes.Role roles
|
||||||
with exn ->
|
with exn ->
|
||||||
Log.Error $"PrincipalActor: %A{exn}"
|
eprintfn $"[MultiAuth] PrincipalActor: %A{exn}"
|
||||||
addToIdentity identity "group" [| "guest" |]
|
addToIdentity identity "group" [| "guest" |]
|
||||||
addToIdentity identity ClaimTypes.Role [||]
|
addToIdentity identity ClaimTypes.Role [||]
|
||||||
principal.AddIdentity identity
|
principal.AddIdentity identity
|
||||||
return true
|
return true
|
||||||
}
|
else
|
||||||
|> Async.AwaitTask
|
return false
|
||||||
|> Async.RunSynchronously
|
}
|
||||||
else
|
|
||||||
false
|
|
||||||
|
|
||||||
let private refreshOidcAccessToken (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) =
|
let private refreshOidcAccessToken (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) =
|
||||||
task {
|
async {
|
||||||
let refreshToken = ctx.Properties.GetTokenValue "refresh_token"
|
let refreshToken = ctx.Properties.GetTokenValue "refresh_token"
|
||||||
let rfr = new RefreshTokenRequest ()
|
let rfr = new RefreshTokenRequest ()
|
||||||
rfr.Address <- oidc.issuer
|
rfr.Address <- oidc.issuer
|
||||||
@@ -186,12 +187,12 @@ let private refreshOidcAccessToken (oidc: OpenIdSettings) (ctx: CookieValidatePr
|
|||||||
rfr.RefreshToken <- refreshToken
|
rfr.RefreshToken <- refreshToken
|
||||||
|
|
||||||
let httpClient = new HttpClient ()
|
let httpClient = new HttpClient ()
|
||||||
let! response = httpClient.RequestRefreshTokenAsync rfr
|
let! response = httpClient.RequestRefreshTokenAsync rfr |> Async.AwaitTask
|
||||||
Log.Debug $"refreshOidcAccessToken (raw): {response.Raw}"
|
eprintfn $"[MultiAuth] refreshOidcAccessToken (raw): {response.Raw}"
|
||||||
|
|
||||||
if response.IsError then
|
if response.IsError then
|
||||||
ctx.RejectPrincipal ()
|
ctx.RejectPrincipal ()
|
||||||
return! ctx.HttpContext.SignOutAsync ()
|
return! ctx.HttpContext.SignOutAsync () |> Async.AwaitTask
|
||||||
else
|
else
|
||||||
let expiresInSeconds = response.ExpiresIn
|
let expiresInSeconds = response.ExpiresIn
|
||||||
let updatedExpiresAt = DateTimeOffset.UtcNow.AddSeconds expiresInSeconds
|
let updatedExpiresAt = DateTimeOffset.UtcNow.AddSeconds expiresInSeconds
|
||||||
@@ -204,27 +205,38 @@ let private refreshOidcAccessToken (oidc: OpenIdSettings) (ctx: CookieValidatePr
|
|||||||
ctx.ShouldRenew <- true
|
ctx.ShouldRenew <- true
|
||||||
}
|
}
|
||||||
|
|
||||||
let private updatePrincipalContext (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) =
|
let createRefreshToken (oidc: OpenIdSettings) (ctx: CookieValidatePrincipalContext) (accessTokenExpiration: DateTimeOffset) =
|
||||||
task {
|
async {
|
||||||
let now = DateTimeOffset.UtcNow
|
let now = DateTimeOffset.UtcNow
|
||||||
let expiresAt = ctx.Properties.GetTokenValue "expires_at"
|
|
||||||
let accessTokenExpiration = DateTimeOffset.Parse expiresAt
|
|
||||||
let timeRemaining = accessTokenExpiration.Subtract now
|
let timeRemaining = accessTokenExpiration.Subtract now
|
||||||
// TODO: Get this from configuration with a fall back value.
|
// TODO: Get this from configuration with a fall back value.
|
||||||
let refreshThresholdMinutes = 5.0
|
let refreshThresholdMinutes = 5.0
|
||||||
let refreshThreshold = TimeSpan.FromMinutes refreshThresholdMinutes
|
let refreshThreshold = TimeSpan.FromMinutes refreshThresholdMinutes
|
||||||
// ctx.Properties.IsPersistent <- true
|
// 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
|
if timeRemaining < refreshThreshold then
|
||||||
Log.Debug $"updatePrincipalContext: time remaining = {timeRemaining}, refresh threshold {refreshThreshold}"
|
eprintfn $"[MultiAuth] updatePrincipalContext: time remaining = {timeRemaining}, refresh threshold {refreshThreshold}"
|
||||||
Log.Debug $"updatePrincipalContext: refreshing access token: %A{ctx.Principal.Claims}"
|
eprintfn $"[MultiAuth] updatePrincipalContext: refreshing access token: %A{ctx.Principal.Claims}"
|
||||||
return! refreshOidcAccessToken oidc ctx
|
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) =
|
let ssoCookieOptions (settings: MultiAuthSettings) (o: CookieAuthenticationOptions) =
|
||||||
o.Cookie.SecurePolicy <- CookieSecurePolicy.Always
|
o.Cookie.SecurePolicy <- CookieSecurePolicy.Always
|
||||||
o.Cookie.SameSite <- SameSiteMode.None
|
o.Cookie.SameSite <- SameSiteMode.None
|
||||||
@@ -243,7 +255,6 @@ let ssoCookieOptions (settings: MultiAuthSettings) (o: CookieAuthenticationOptio
|
|||||||
updatePrincipalContext settings.oidc ctx
|
updatePrincipalContext settings.oidc ctx
|
||||||
|
|
||||||
let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
|
let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
|
||||||
Log.Debug("[MultiAuth] OIDC settings {@Settings}", settings.oidc)
|
|
||||||
o.Authority <- settings.oidc.issuer
|
o.Authority <- settings.oidc.issuer
|
||||||
o.ClientId <- settings.oidc.clientId
|
o.ClientId <- settings.oidc.clientId
|
||||||
o.ClientSecret <- settings.oidc.clientSecret
|
o.ClientSecret <- settings.oidc.clientSecret
|
||||||
@@ -261,7 +272,7 @@ let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
|
|||||||
|]
|
|]
|
||||||
o.Scope.Clear ()
|
o.Scope.Clear ()
|
||||||
settings.oidc.scopes |> Array.iter o.Scope.Add
|
settings.oidc.scopes |> Array.iter o.Scope.Add
|
||||||
o.SaveTokens <- true
|
o.SaveTokens <- false
|
||||||
o.UseTokenLifetime <- true
|
o.UseTokenLifetime <- true
|
||||||
o.ResponseType <- OpenIdConnectResponseType.Code
|
o.ResponseType <- OpenIdConnectResponseType.Code
|
||||||
// SameSite is needed for Chrome/Firefox, as they will give http error 500 back, if not set to unspecified.
|
// 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
|
fun e -> e.HttpContext.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
o.Events.OnRedirectToIdentityProvider <-
|
o.Events.OnRedirectToIdentityProvider <-
|
||||||
fun e ->
|
fun e ->
|
||||||
// hack for https behind proxy
|
task {
|
||||||
e.ProtocolMessage.RedirectUri <- $"https://{e.Request.Host.Value}/signin-oidc"
|
eprintfn "[MultiAuth] RedirectToIdentityProvider: %A" e.Request.Host.Value
|
||||||
Task.FromResult ()
|
// hack for https behind proxy
|
||||||
|
e.ProtocolMessage.RedirectUri <- $"https://{e.Request.Host.Value}/signin-oidc"
|
||||||
|
return ()
|
||||||
|
}
|
||||||
o.Events.OnRedirectToIdentityProviderForSignOut <-
|
o.Events.OnRedirectToIdentityProviderForSignOut <-
|
||||||
fun e ->
|
fun e ->
|
||||||
// hack for https behind proxy
|
task {
|
||||||
e.ProtocolMessage.PostLogoutRedirectUri <- $"https://{e.Request.Host.Value}/signout-callback-oidc"
|
// hack for https behind proxy
|
||||||
Task.FromResult ()
|
e.ProtocolMessage.PostLogoutRedirectUri <- $"https://{e.Request.Host.Value}/signout-callback-oidc"
|
||||||
|
return ()
|
||||||
|
}
|
||||||
|
|
||||||
module Jwt =
|
module Jwt =
|
||||||
type OidToken = {
|
type OidToken = {
|
||||||
@@ -308,7 +324,7 @@ module Jwt =
|
|||||||
e: string
|
e: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type private Keys = { keys: Key[] }
|
type private Keys = { keys: Key array }
|
||||||
|
|
||||||
let private keyDecoder = Thoth.Json.Net.Decode.Auto.generateDecoder<Keys> ()
|
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 }
|
Some { user = user; groups = v.groups; roles = v.roles }
|
||||||
else
|
else
|
||||||
None))
|
None))
|
||||||
|
|
||||||
task {
|
task {
|
||||||
if not (String.IsNullOrEmpty request.Headers.Authorization) then
|
if not (String.IsNullOrEmpty request.Headers.Authorization) then
|
||||||
let t = request.Headers.Authorization[0].Split ' '
|
let t = request.Headers.Authorization[0].Split ' '
|
||||||
@@ -516,7 +533,9 @@ let addSsoDataProtection (settings: MultiAuthSettings) (services: IServiceCollec
|
|||||||
builder.ProtectKeysWithAzureKeyVault (Uri uri, EnvironmentCredential ())
|
builder.ProtectKeysWithAzureKeyVault (Uri uri, EnvironmentCredential ())
|
||||||
else
|
else
|
||||||
builder
|
builder
|
||||||
| None -> builder
|
| None ->
|
||||||
|
builder
|
||||||
|
|
||||||
match settings.sso.keyStore with
|
match settings.sso.keyStore with
|
||||||
| Some keyStore ->
|
| Some keyStore ->
|
||||||
let key = $"{settings.sso.environment}-{keyStore.key}"
|
let key = $"{settings.sso.environment}-{keyStore.key}"
|
||||||
@@ -528,28 +547,82 @@ let addSsoDataProtection (settings: MultiAuthSettings) (services: IServiceCollec
|
|||||||
| Some token ->
|
| Some token ->
|
||||||
let blob = $"{keyStore.uri}/{key}/key?{token}"
|
let blob = $"{keyStore.uri}/{key}/key?{token}"
|
||||||
protector.PersistKeysToAzureBlobStorage (Uri blob) |> protectKeys
|
protector.PersistKeysToAzureBlobStorage (Uri blob) |> protectKeys
|
||||||
| None -> protector
|
| None ->
|
||||||
|
protector
|
||||||
else
|
else
|
||||||
protector
|
protector
|
||||||
| None -> protector
|
| None ->
|
||||||
|
protector
|
||||||
|
|
||||||
|
let configureMultiAuthServices settings (services: IServiceCollection) =
|
||||||
|
services.AddHttpContextAccessor () |> ignore
|
||||||
|
services.AddTransient<IClaimsTransformation, ApplicationClaims> () |> ignore
|
||||||
|
services.AddSingleton<PlainAuth.IPlainAuthUserService, PlainAuth.PlainAuthUserService> (fun _ ->
|
||||||
|
PlainAuth.PlainAuthUserService settings.plainAuthUsers
|
||||||
|
)
|
||||||
|> ignore
|
|> ignore
|
||||||
|
services.AddAccessTokenManagement () |> ignore
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|> fun builder ->
|
||||||
|
builder
|
||||||
|
.AddJwtBearer(Jwt.jwtOptions settings.oidc)
|
||||||
|
.AddOpenIdConnect(oidOptions settings)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, PlainAuth.PlainAuthHandler>(
|
||||||
|
PlainAuth.PlainAuthDefaults.AuthenticationScheme,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
.AddCookie(ssoCookieOptions settings)
|
||||||
|
.AddPolicyScheme (
|
||||||
|
MultiAuthDefaults.AuthenticationScheme,
|
||||||
|
MultiAuthDefaults.AuthenticationScheme,
|
||||||
|
fun o ->
|
||||||
|
o.ForwardSignOut <- OpenIdConnectDefaults.AuthenticationScheme
|
||||||
|
o.ForwardDefaultSelector <-
|
||||||
|
fun ctx ->
|
||||||
|
let scheme =
|
||||||
|
if String.IsNullOrEmpty ctx.Request.Headers.Authorization then
|
||||||
|
MultiAuthDefaults.Cookie
|
||||||
|
else
|
||||||
|
let n = ctx.Request.Headers.Authorization[0].ToLower().Split('.').Length
|
||||||
|
if n = 3 then
|
||||||
|
MultiAuthDefaults.Bearer
|
||||||
|
else
|
||||||
|
MultiAuthDefaults.Plain
|
||||||
|
eprintfn $"MultiAuth: {scheme}"
|
||||||
|
match scheme with
|
||||||
|
| MultiAuthDefaults.Plain -> PlainAuth.PlainAuthDefaults.AuthenticationScheme
|
||||||
|
| MultiAuthDefaults.Bearer -> JwtBearerDefaults.AuthenticationScheme
|
||||||
|
| MultiAuthDefaults.Cookie -> CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
|
)
|
||||||
|
|> ignore
|
||||||
|
services.AddAuthorization () |> ignore
|
||||||
|
|
||||||
|
services
|
||||||
|
|
||||||
type ApplicationBuilder with
|
type ApplicationBuilder with
|
||||||
[<CustomOperation("use_oidc")>]
|
[<CustomOperation("use_oidc")>]
|
||||||
member this.UseOidc(state, settings: MultiAuthSettings) =
|
member _.UseOidc(state, settings: MultiAuthSettings) =
|
||||||
let middleware (app: IApplicationBuilder) =
|
let middleware (app: IApplicationBuilder) =
|
||||||
app.UseAuthentication().UseAuthorization ()
|
app.UseAuthentication().UseAuthorization ()
|
||||||
|
|
||||||
let service (services: IServiceCollection) =
|
let service (services: IServiceCollection) =
|
||||||
services.AddHttpContextAccessor () |> ignore
|
services.AddHttpContextAccessor () |> ignore
|
||||||
services.AddTransient<IClaimsTransformation, ApplicationClaims> () |> ignore
|
services.AddTransient<IClaimsTransformation, ApplicationClaims> () |> ignore
|
||||||
addSsoDataProtection settings services
|
addSsoDataProtection settings services |> ignore
|
||||||
services
|
services
|
||||||
.AddAuthentication(fun o ->
|
.AddAuthentication(fun o ->
|
||||||
o.DefaultScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
o.DefaultScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
o.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
o.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
o.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
o.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
o.DefaultChallengeScheme <- OpenIdConnectDefaults.AuthenticationScheme)
|
o.DefaultChallengeScheme <- OpenIdConnectDefaults.AuthenticationScheme
|
||||||
|
)
|
||||||
.AddCookie(ssoCookieOptions settings)
|
.AddCookie(ssoCookieOptions settings)
|
||||||
.AddOpenIdConnect (oidOptions settings)
|
.AddOpenIdConnect (oidOptions settings)
|
||||||
|> ignore
|
|> ignore
|
||||||
@@ -569,52 +642,8 @@ type ApplicationBuilder with
|
|||||||
app.UseAuthentication().UseAuthorization ()
|
app.UseAuthentication().UseAuthorization ()
|
||||||
|
|
||||||
let service (services: IServiceCollection) =
|
let service (services: IServiceCollection) =
|
||||||
services.AddHttpContextAccessor () |> ignore
|
configureMultiAuthServices settings services
|
||||||
services.AddTransient<IClaimsTransformation, ApplicationClaims> () |> ignore
|
|
||||||
services.AddSingleton<PlainAuth.IPlainAuthUserService, PlainAuth.PlainAuthUserService> (fun _ ->
|
|
||||||
PlainAuth.PlainAuthUserService settings.plainAuthUsers)
|
|
||||||
|> ignore
|
|
||||||
services.AddAccessTokenManagement () |> ignore
|
|
||||||
addSsoDataProtection settings services
|
|
||||||
services.AddAuthentication (fun o ->
|
|
||||||
o.DefaultScheme <- MultiAuthDefaults.AuthenticationScheme
|
|
||||||
o.DefaultAuthenticateScheme <- MultiAuthDefaults.AuthenticationScheme
|
|
||||||
o.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
|
||||||
o.DefaultChallengeScheme <- OpenIdConnectDefaults.AuthenticationScheme
|
|
||||||
o.DefaultSignOutScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
|
|
||||||
|> fun builder ->
|
|
||||||
builder
|
|
||||||
.AddJwtBearer(Jwt.jwtOptions settings.oidc)
|
|
||||||
.AddOpenIdConnect(oidOptions settings)
|
|
||||||
.AddScheme<AuthenticationSchemeOptions, PlainAuth.PlainAuthHandler>(
|
|
||||||
PlainAuth.PlainAuthDefaults.AuthenticationScheme,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
.AddCookie(ssoCookieOptions settings)
|
|
||||||
.AddPolicyScheme (
|
|
||||||
MultiAuthDefaults.AuthenticationScheme,
|
|
||||||
MultiAuthDefaults.AuthenticationScheme,
|
|
||||||
fun o ->
|
|
||||||
o.ForwardSignOut <- OpenIdConnectDefaults.AuthenticationScheme
|
|
||||||
o.ForwardDefaultSelector <-
|
|
||||||
fun ctx ->
|
|
||||||
let scheme =
|
|
||||||
if String.IsNullOrEmpty ctx.Request.Headers.Authorization then
|
|
||||||
MultiAuthDefaults.Cookie
|
|
||||||
else
|
|
||||||
let n = ctx.Request.Headers.Authorization[0].ToLower().Split('.').Length
|
|
||||||
if n = 3 then
|
|
||||||
MultiAuthDefaults.Bearer
|
|
||||||
else
|
|
||||||
MultiAuthDefaults.Plain
|
|
||||||
Log.Debug $"MultiAuth: {scheme}"
|
|
||||||
match scheme with
|
|
||||||
| MultiAuthDefaults.Plain -> PlainAuth.PlainAuthDefaults.AuthenticationScheme
|
|
||||||
| MultiAuthDefaults.Bearer -> JwtBearerDefaults.AuthenticationScheme
|
|
||||||
| MultiAuthDefaults.Cookie -> CookieAuthenticationDefaults.AuthenticationScheme
|
|
||||||
)
|
|
||||||
|> ignore
|
|
||||||
services.AddAuthorization ()
|
|
||||||
{
|
{
|
||||||
state with
|
state with
|
||||||
ServicesConfig = service :: state.ServicesConfig
|
ServicesConfig = service :: state.ServicesConfig
|
||||||
@@ -629,7 +658,7 @@ type ApplicationBuilder with
|
|||||||
|
|
||||||
let service (services: IServiceCollection) =
|
let service (services: IServiceCollection) =
|
||||||
services.AddHttpContextAccessor () |> ignore
|
services.AddHttpContextAccessor () |> ignore
|
||||||
addSsoDataProtection settings services
|
addSsoDataProtection settings services |> ignore
|
||||||
services
|
services
|
||||||
.AddAuthentication(fun o ->
|
.AddAuthentication(fun o ->
|
||||||
o.DefaultScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
o.DefaultScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
@@ -643,4 +672,4 @@ type ApplicationBuilder with
|
|||||||
state with
|
state with
|
||||||
ServicesConfig = service :: state.ServicesConfig
|
ServicesConfig = service :: state.ServicesConfig
|
||||||
AppConfigs = middleware :: state.AppConfigs
|
AppConfigs = middleware :: state.AppConfigs
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user