feat(Codex): edit archives
This commit is contained in:
@@ -5,6 +5,7 @@ open Fable.Core
|
||||
open Fable.Core.JsInterop
|
||||
open Feliz
|
||||
open Feliz.Router
|
||||
open FS.FluentUI
|
||||
|
||||
module Archive =
|
||||
let private fetchArchiveAcl (id: System.Guid) : JS.Promise<Result<Archmaester.Dto.ArchiveAcl, string>> =
|
||||
@@ -29,16 +30,6 @@ module Archive =
|
||||
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
|
||||
@@ -227,9 +218,6 @@ module Archive =
|
||||
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
|
||||
@@ -257,21 +245,6 @@ module Archive =
|
||||
| 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
|
||||
@@ -319,94 +292,27 @@ module Archive =
|
||||
| None ->
|
||||
match archiveOpt with
|
||||
| Some archive ->
|
||||
Html.h1 (sprintf "Archive %s" archive.name)
|
||||
Fui.text.title1 (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"
|
||||
]
|
||||
]
|
||||
Html.div [
|
||||
prop.classes [ "flex-row"; "gap-8" ]
|
||||
prop.children [
|
||||
Archives.EditArchiveDialog archive (fun edited ->
|
||||
Some
|
||||
{archive with
|
||||
name = edited.Name
|
||||
startTime = edited.StartTime
|
||||
endTime = edited.StartTime.AddHours(edited.Frames)
|
||||
frames = edited.Frames
|
||||
isPublished = edited.Published
|
||||
isPublic = edited.Public
|
||||
}
|
||||
|> setArchive
|
||||
console.debug ("response: ", edited)
|
||||
)
|
||||
Archives.DeleteArchiveDialog archive
|
||||
]
|
||||
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
|
||||
|
||||
@@ -744,4 +650,4 @@ module Archive =
|
||||
|
||||
| None ->
|
||||
Html.h1 "Archive not found"
|
||||
]
|
||||
]
|
||||
@@ -1,6 +1,8 @@
|
||||
namespace Oceanbox.Codex
|
||||
|
||||
open Fable.Core
|
||||
open Feliz
|
||||
open FS.FluentUI
|
||||
|
||||
type Archives =
|
||||
[<ReactComponent>]
|
||||
@@ -13,49 +15,131 @@ type Archives =
|
||||
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")
|
||||
Fui.table [
|
||||
table.classes [ "flex-basis-7"; "flex-grow" ]
|
||||
table.children [
|
||||
Fui.tableBody [
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.textDescriptionRegular [])
|
||||
tableCellLayout.children [ Fui.text "Description" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text archive.description ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.contactCardGenericRegular [])
|
||||
tableCellLayout.children [ Fui.text "Archive type" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (string archive.archiveType) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.globeSurfaceRegular [])
|
||||
tableCellLayout.children [ Fui.text "Projection" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text archive.projection ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.timerRegular [])
|
||||
tableCellLayout.children [ Fui.text "Frequency" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (string archive.freq) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.filmstripRegular [])
|
||||
tableCellLayout.children [ Fui.text "Frames" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (string archive.frames) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.calendarAddRegular [])
|
||||
tableCellLayout.children [ Fui.text "Time created" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (archive.created.ToLongDateString()) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.playRegular [])
|
||||
tableCellLayout.children [ Fui.text "Start time" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (archive.startTime.ToLongDateString()) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.stopRegular [])
|
||||
tableCellLayout.children [ Fui.text "End time" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (archive.endTime.ToLongDateString()) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.autoFitWidthRegular [])
|
||||
tableCellLayout.children [ Fui.text "Length" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (sprintf "%d days %d hours" archiveLength.Days archiveLength.Hours) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.eyeRegular [])
|
||||
tableCellLayout.children [ Fui.text "Published" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (string archive.isPublished) ]
|
||||
]
|
||||
]
|
||||
Fui.tableRow [
|
||||
tableRow.children [
|
||||
Fui.tableCell [
|
||||
Fui.tableCellLayout [
|
||||
tableCellLayout.media (Fui.icon.peopleEyeRegular [])
|
||||
tableCellLayout.children [ Fui.text "Public" ]
|
||||
]
|
||||
]
|
||||
Fui.tableCell [ Fui.text (string archive.isPublic) ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -70,3 +154,309 @@ type Archives =
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
[<ReactComponent>]
|
||||
static member EditArchiveDialog(archive: Archmaester.Dto.ArchiveProps) onEdit =
|
||||
let initForm : Remoting.EditArchiveRequest =
|
||||
{
|
||||
Name = archive.name
|
||||
StartTime = archive.startTime
|
||||
Frames = archive.frames
|
||||
Published = archive.isPublished
|
||||
Public = archive.isPublic
|
||||
}
|
||||
let form, setForm = React.useState initForm
|
||||
let isOpen, setIsOpen = React.useState false
|
||||
let errMsg, setErrMsg = React.useState None
|
||||
|
||||
let handleEditArchive () =
|
||||
let utcTime = System.DateTime(form.StartTime.Year, form.StartTime.Month, form.StartTime.Day, 0, 0, 0, System.DateTimeKind.Utc)
|
||||
Remoting.adminApi.updateArchive archive.archiveId {form with StartTime = utcTime}
|
||||
|> Async.StartAsPromise
|
||||
|> Promise.iter (fun res ->
|
||||
match res with
|
||||
| Ok newArchive ->
|
||||
Browser.Dom.console.info("Added archive %s with id %s", newArchive.Name, string newArchive.Id)
|
||||
onEdit newArchive
|
||||
setIsOpen false
|
||||
| Error msg ->
|
||||
Browser.Dom.console.error("Error adding archive %s: %s", form.Name, msg)
|
||||
setErrMsg (Some msg)
|
||||
)
|
||||
|
||||
Fui.dialog [
|
||||
dialog.open' isOpen
|
||||
dialog.onOpenChange (fun (d: DialogOpenChangeData<Browser.Types.MouseEvent>) ->
|
||||
setIsOpen d.``open``
|
||||
// NOTE: Reset form on open, so it's not noticeable in the UI
|
||||
if d.``open`` then
|
||||
setForm initForm
|
||||
)
|
||||
dialog.children [
|
||||
Fui.dialogTrigger [
|
||||
dialogTrigger.children (
|
||||
Fui.button [
|
||||
button.icon (Fui.icon.editRegular [])
|
||||
button.text "Edit"
|
||||
]
|
||||
)
|
||||
]
|
||||
Fui.dialogSurface [
|
||||
dialogSurface.classes []
|
||||
dialogSurface.children [
|
||||
Fui.dialogBody [
|
||||
dialogBody.classes []
|
||||
dialogBody.children [
|
||||
Fui.dialogTitle [
|
||||
dialogTitle.text (sprintf "Edit archive %s" archive.name)
|
||||
dialogTitle.action (
|
||||
Fui.dialogTrigger [
|
||||
dialogTrigger.action.close
|
||||
dialogTrigger.children (
|
||||
Fui.button [
|
||||
button.appearance.transparent
|
||||
button.icon (Fui.icon.dismissRegular [])
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
Fui.dialogContent [
|
||||
dialogContent.classes ["flex-column"; "gap-8"]
|
||||
dialogContent.children [
|
||||
Fui.field [
|
||||
field.label (
|
||||
Fui.label [
|
||||
label.required true
|
||||
label.text "Name"
|
||||
]
|
||||
)
|
||||
field.children (
|
||||
Fui.input [
|
||||
input.value form.Name
|
||||
input.onChange (fun (v: ValueProp<string>) ->
|
||||
if v.value.Length <= 80 then
|
||||
setForm {form with Name = v.value}
|
||||
)
|
||||
]
|
||||
)
|
||||
field.hint $"{form.Name.Length}/80"
|
||||
]
|
||||
Html.div [
|
||||
prop.classes ["flex-row"; "gap-8"]
|
||||
prop.children [
|
||||
Fui.field [
|
||||
field.label (
|
||||
Fui.label [
|
||||
label.required true
|
||||
label.text "Start Date"
|
||||
]
|
||||
)
|
||||
|
||||
field.children (
|
||||
Fui.datePicker [
|
||||
datePicker.placeholder "Select a date..."
|
||||
datePicker.showWeekNumbers true
|
||||
datePicker.formatDate (fun d -> d.ToShortDateString())
|
||||
datePicker.value (Some form.StartTime)
|
||||
datePicker.onSelectDate (fun d ->
|
||||
d |> Option.iter (fun d' ->
|
||||
setForm {form with StartTime = d'}
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
Fui.field [
|
||||
field.label (
|
||||
Fui.label [
|
||||
label.required true
|
||||
label.text "Days"
|
||||
]
|
||||
)
|
||||
field.children (
|
||||
Fui.spinButton [
|
||||
spinButton.value (form.Frames / 24)
|
||||
spinButton.min 1
|
||||
spinButton.onChange (fun (d: SpinButtonOnChangeData) ->
|
||||
match d.value with
|
||||
| Some v ->
|
||||
setForm {form with Frames = v * 24}
|
||||
| None ->
|
||||
if d.displayValue.ToCharArray() |> Array.forall System.Char.IsDigit then
|
||||
setForm {form with Frames = int d.displayValue * 24}
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
Fui.text.caption1 [
|
||||
let endDate = form.StartTime.AddHours(form.Frames).ToShortDateString()
|
||||
text.text (sprintf "End Date: %s, %d frames" endDate form.Frames)
|
||||
]
|
||||
Fui.checkbox [
|
||||
checkbox.label "Published"
|
||||
checkbox.checked' form.Published
|
||||
checkbox.onCheckedChange (fun c ->
|
||||
if not c then
|
||||
setForm {form with Published = c; Public = c}
|
||||
else
|
||||
setForm {form with Published = c}
|
||||
)
|
||||
]
|
||||
Fui.checkbox [
|
||||
checkbox.label "Public"
|
||||
checkbox.checked' form.Public
|
||||
checkbox.onCheckedChange (fun c -> setForm {form with Public = c})
|
||||
]
|
||||
match errMsg with
|
||||
| None -> Html.none
|
||||
| Some msg ->
|
||||
Fui.text [
|
||||
text.style [style.color Theme.tokens.colorStatusDangerForeground1]
|
||||
text.text msg
|
||||
]
|
||||
]
|
||||
]
|
||||
Fui.dialogActions [
|
||||
dialogActions.position.end'
|
||||
dialogActions.children [
|
||||
Fui.dialogTrigger [
|
||||
dialogTrigger.action.close
|
||||
dialogTrigger.children (
|
||||
Fui.button [
|
||||
button.icon (Fui.icon.dismissRegular [])
|
||||
button.text "Cancel"
|
||||
]
|
||||
)
|
||||
]
|
||||
Fui.button [
|
||||
button.appearance.primary
|
||||
button.icon (Fui.icon.saveRegular [])
|
||||
button.text "Save changes"
|
||||
button.disabled <| (form = initForm)
|
||||
button.onClick (fun _ -> handleEditArchive ())
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
[<ReactComponent>]
|
||||
static member DeleteArchiveDialog(archive: Archmaester.Dto.ArchiveProps) =
|
||||
let isOpen, setIsOpen = React.useState false
|
||||
let userConfirmed, setUserConfirmed = React.useState false
|
||||
let errMsg, setErrMsg = React.useState None
|
||||
|
||||
let deleteArchive (id: System.Guid) =
|
||||
promise {
|
||||
try
|
||||
let! res = Remoting.adminApi.deleteArchive id |> Async.StartAsPromise
|
||||
return res
|
||||
with e ->
|
||||
Browser.Dom.console.error("Error deleting archive: %o", e)
|
||||
return Error "Error deleting archive"
|
||||
}
|
||||
|
||||
let handleDeleteArchive () =
|
||||
deleteArchive archive.archiveId
|
||||
|> Promise.iter (fun res ->
|
||||
match res with
|
||||
| Ok deleted ->
|
||||
if deleted then
|
||||
Browser.Dom.console.info("Archive deleted successfully")
|
||||
setIsOpen false
|
||||
Router.Router.navigateBack ()
|
||||
else
|
||||
setErrMsg (Some "Failed to delete archive")
|
||||
| Error err ->
|
||||
Browser.Dom.console.error("Error deleting archive: %s", err)
|
||||
setErrMsg (Some err)
|
||||
)
|
||||
|
||||
Fui.dialog [
|
||||
dialog.open' isOpen
|
||||
dialog.onOpenChange (fun (d: DialogOpenChangeData<Browser.Types.MouseEvent>) ->
|
||||
setIsOpen d.``open``
|
||||
if d.``open`` then
|
||||
setUserConfirmed false
|
||||
setErrMsg None
|
||||
)
|
||||
dialog.children [
|
||||
Fui.dialogTrigger [
|
||||
dialogTrigger.children (
|
||||
Fui.button [
|
||||
button.icon (Fui.icon.deleteRegular [])
|
||||
button.text "Delete"
|
||||
]
|
||||
)
|
||||
]
|
||||
Fui.dialogSurface [
|
||||
dialogSurface.classes []
|
||||
dialogSurface.children [
|
||||
Fui.dialogBody [
|
||||
dialogBody.classes []
|
||||
dialogBody.children [
|
||||
Fui.dialogTitle [
|
||||
dialogTitle.text (sprintf "Delete archive %s" archive.name)
|
||||
dialogTitle.action (
|
||||
Fui.dialogTrigger [
|
||||
dialogTrigger.action.close
|
||||
dialogTrigger.children (
|
||||
Fui.button [
|
||||
button.appearance.transparent
|
||||
button.icon (Fui.icon.dismissRegular [])
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
Fui.dialogContent [
|
||||
dialogContent.classes ["flex-column"; "gap-8"]
|
||||
dialogContent.children [
|
||||
Fui.text "Are you sure you want to delete the archive? This action cannot be reverted!"
|
||||
Fui.checkbox [
|
||||
checkbox.label "Yes, I am sure"
|
||||
checkbox.checked' userConfirmed
|
||||
checkbox.onCheckedChange setUserConfirmed
|
||||
]
|
||||
match errMsg with
|
||||
| None -> Html.none
|
||||
| Some msg ->
|
||||
Fui.text [
|
||||
text.style [style.color Theme.tokens.colorStatusDangerForeground1]
|
||||
text.text msg
|
||||
]
|
||||
]
|
||||
]
|
||||
Fui.dialogActions [
|
||||
dialogActions.position.end'
|
||||
dialogActions.children [
|
||||
Fui.dialogTrigger [
|
||||
dialogTrigger.action.close
|
||||
dialogTrigger.children (
|
||||
Fui.button [
|
||||
button.icon (Fui.icon.dismissRegular [])
|
||||
button.text "Cancel"
|
||||
]
|
||||
)
|
||||
]
|
||||
Fui.button [
|
||||
button.appearance.primary
|
||||
button.icon (Fui.icon.deleteRegular [])
|
||||
button.text "Delete"
|
||||
button.disabled (not userConfirmed)
|
||||
button.onClick (fun _ -> handleDeleteArchive ())
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -308,7 +308,7 @@ module Admin =
|
||||
let archmaesterAddPublic (db: Entity.ArchiveContext) (archiveId: System.Guid) =
|
||||
async {
|
||||
try
|
||||
let! success = Archmaester.EFCore.setArchivePublic db archiveId
|
||||
let! success = Archmaester.EFCore.setArchivePublic db archiveId true
|
||||
if success > 0 then
|
||||
return Ok ()
|
||||
else
|
||||
@@ -316,20 +316,6 @@ module Admin =
|
||||
with e ->
|
||||
return Error (sprintf "Error adding * user to archive: %s" e.Message)
|
||||
}
|
||||
let fgaAddPrincipal (fga: OpenFgaClient) (archiveId: System.Guid) =
|
||||
async {
|
||||
let tuple = OpenFGA.Archive.defaultPrincipal archiveId
|
||||
let req = OpenFGA.Queries.write' [|tuple|]
|
||||
let! _resp = fga.Write req |> Async.AwaitTask
|
||||
return ()
|
||||
}
|
||||
let fgaAddPublic (fga: OpenFgaClient) (archiveId: System.Guid) =
|
||||
async {
|
||||
let tuple = OpenFGA.Archive.publicArchive archiveId
|
||||
let req = OpenFGA.Queries.write' [|tuple|]
|
||||
let! _resp = fga.Write req |> Async.AwaitTask
|
||||
return ()
|
||||
}
|
||||
asyncResult {
|
||||
let user = ctx.User.Identity.Name
|
||||
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
|
||||
@@ -341,11 +327,27 @@ module Admin =
|
||||
|
||||
let! newArchive = archmaesterAdd db
|
||||
try
|
||||
do! fgaAddPrincipal fga newArchive.ArchiveId
|
||||
|
||||
if newArchive.Public then
|
||||
do! archmaesterAddPublic db newArchive.ArchiveId
|
||||
do! fgaAddPublic fga newArchive.ArchiveId
|
||||
|
||||
let tuples =
|
||||
if newArchive.Public then
|
||||
[|
|
||||
OpenFGA.Archive.defaultPrincipal newArchive.ArchiveId
|
||||
OpenFGA.Archive.publicArchive newArchive.ArchiveId
|
||||
|]
|
||||
else
|
||||
[|OpenFGA.Archive.defaultPrincipal newArchive.ArchiveId|]
|
||||
|
||||
let! fgaResp =
|
||||
let req = OpenFGA.Queries.write' tuples
|
||||
fga.Write req |> Async.AwaitTask
|
||||
|
||||
logger.LogInformation (
|
||||
"addArchive: {Archive} openFGA write responded with {JSON}",
|
||||
archive.Name,
|
||||
fgaResp.ToJson ()
|
||||
)
|
||||
|
||||
do! tr.CommitAsync ()
|
||||
|
||||
@@ -360,6 +362,87 @@ module Admin =
|
||||
return! Error "Error adding archive"
|
||||
}
|
||||
|
||||
let updateArchive (ctx: HttpContext) (archiveId: System.Guid) (archive: Remoting.EditArchiveRequest) =
|
||||
let archmaesterEdit (db: Entity.ArchiveContext) =
|
||||
async {
|
||||
try
|
||||
let! existingName = Archmaester.EFCore.getArchiveName db archiveId
|
||||
let! nameTaken = Archmaester.EFCore.checkArchiveNameTaken db archive.Name
|
||||
if nameTaken && not (existingName = archive.Name) then
|
||||
return Error "Error updating archive: an archive with that name already exists"
|
||||
else
|
||||
let! newArchive = Archmaester.EFCore.editArchive db archiveId archive
|
||||
return Ok newArchive
|
||||
with e ->
|
||||
return Error (sprintf "Error updating archive: %s" e.Message)
|
||||
}
|
||||
let archmaesterSetPublic (db: Entity.ArchiveContext) (setPublic: bool) =
|
||||
async {
|
||||
try
|
||||
let! success = Archmaester.EFCore.setArchivePublic db archiveId setPublic
|
||||
if success > 0 then
|
||||
return Ok ()
|
||||
else
|
||||
return Error "Failed to add * user to public archive"
|
||||
with e ->
|
||||
return Error (sprintf "Error adding/removing * user to/from archive: %s" e.Message)
|
||||
}
|
||||
let fgaSetPublic (fga: OpenFgaClient) (setPublic: bool) =
|
||||
async {
|
||||
try
|
||||
let publicTuple = OpenFGA.Archive.publicArchive archiveId
|
||||
let checkReq =
|
||||
OpenFGA.Queries.check { User = publicTuple.User; Relation = publicTuple.Relation; Object = publicTuple.Object}
|
||||
let! publicResp = fga.Check checkReq |> Async.AwaitTask
|
||||
let publicTupleExists = publicResp.Allowed |> Option.ofNullable |> Option.defaultValue false
|
||||
|
||||
if setPublic && not publicTupleExists then
|
||||
let writeReq = OpenFGA.Queries.write' [|publicTuple|]
|
||||
let! writeResp = fga.Write writeReq |> Async.AwaitTask
|
||||
match writeResp.Writes |> Array.ofSeq with
|
||||
| [||] -> return Error (sprintf "Error writing public tuple to fga: %s" (writeResp.ToJson()))
|
||||
| _ -> return Ok ()
|
||||
elif not setPublic && publicTupleExists then
|
||||
let deleteReq = OpenFGA.Queries.delete' [|publicTuple|]
|
||||
let! deleteResp = fga.Write deleteReq |> Async.AwaitTask
|
||||
match deleteResp.Deletes |> Array.ofSeq with
|
||||
| [||] -> return Error (sprintf "Error deleting public tuple from fga: %s" (deleteResp.ToJson()))
|
||||
| _ -> return Ok ()
|
||||
else
|
||||
return Ok ()
|
||||
with e ->
|
||||
return Error (sprintf "Error adding/removing fga user:* view relation to/from archive: %s" e.Message)
|
||||
}
|
||||
asyncResult {
|
||||
let user = ctx.User.Identity.Name
|
||||
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
|
||||
try
|
||||
do logger.LogInformation("updateArchive {Archive} from {User}", archive.Name, user)
|
||||
let fga = ctx.GetService<OpenFgaClient> ()
|
||||
let db = ctx.GetService<Entity.ArchiveContext> ()
|
||||
let tr = db.Database.BeginTransaction()
|
||||
|
||||
let! newArchive = archmaesterEdit db
|
||||
try
|
||||
if newArchive.Public && not archive.Public then
|
||||
do! archmaesterSetPublic db true
|
||||
do! fgaSetPublic fga true
|
||||
elif not newArchive.Public && archive.Public then
|
||||
do! archmaesterSetPublic db false
|
||||
do! fgaSetPublic fga false
|
||||
|
||||
do! tr.CommitAsync ()
|
||||
|
||||
return! newArchive |> EFType.toArchive |> Ok
|
||||
with e ->
|
||||
do logger.LogError (e, "updateArchive setting public failed, rolling back Archmaester")
|
||||
do! tr.RollbackAsync ()
|
||||
return! Error (sprintf "Error updating archive: %s" e.Message)
|
||||
with e ->
|
||||
do logger.LogError (e, "updateArchive {Archive} from {User}", archive.Name, user)
|
||||
return! Error (sprintf "Error updating archive: %s" e.Message)
|
||||
}
|
||||
|
||||
let getArchiveTypes (ctx: HttpContext) =
|
||||
async {
|
||||
let user = ctx.User.Identity.Name
|
||||
@@ -446,6 +529,7 @@ module Admin =
|
||||
removeUsers = Handler.removeUsers ctx
|
||||
getDataSets = fun () -> Handler.getAllDataSets ctx
|
||||
addArchive = Handler.addArchive ctx
|
||||
updateArchive = Handler.updateArchive ctx
|
||||
}
|
||||
|
||||
let endpoints: HttpHandler =
|
||||
|
||||
@@ -250,22 +250,31 @@ module Archmaester =
|
||||
.AnyAsync()
|
||||
|> Async.AwaitTask
|
||||
|
||||
let getArchiveName (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<string> =
|
||||
db.Archives
|
||||
.AsNoTracking()
|
||||
.Where(fun archive -> archive.ArchiveId = archiveId)
|
||||
.Select(_.Name)
|
||||
.SingleOrDefaultAsync()
|
||||
|> Async.AwaitTask
|
||||
|
||||
/// Align archive start time with the actual files in the dataset
|
||||
let private getAlignedStartTime (db: Entity.ArchiveContext) (dataSetId: int) (archiveStartTime: System.DateTime) =
|
||||
let utcStartTime = archiveStartTime.ToUniversalTime()
|
||||
db.Files
|
||||
.AsNoTracking()
|
||||
.Where(fun file ->
|
||||
file.AttribsId = dataSetId
|
||||
&& file.StartTime >= utcStartTime
|
||||
)
|
||||
.OrderBy(_.StartTime)
|
||||
.Select(_.StartTime)
|
||||
.FirstAsync()
|
||||
|> Async.AwaitTask
|
||||
|
||||
let addArchive (db: Entity.ArchiveContext) (archive: Remoting.AddArchiveRequest) : Async<Entity.Archive> =
|
||||
async {
|
||||
// NOTE: We need to align the start time with an actual file
|
||||
let! alignedStartTime =
|
||||
let dataSetId = archive.DataSetId
|
||||
let archiveStartTime = archive.StartTime.ToUniversalTime()
|
||||
db.Files
|
||||
.AsNoTracking()
|
||||
.Where(fun file ->
|
||||
file.AttribsId = dataSetId
|
||||
&& file.StartTime >= archiveStartTime
|
||||
)
|
||||
.OrderBy(_.StartTime)
|
||||
.Select(_.StartTime)
|
||||
.FirstAsync()
|
||||
|> Async.AwaitTask
|
||||
let! alignedStartTime = getAlignedStartTime db archive.DataSetId archive.StartTime
|
||||
|
||||
let newArchive =
|
||||
Entity.Archive(
|
||||
@@ -295,9 +304,30 @@ module Archmaester =
|
||||
return newArchive
|
||||
}
|
||||
|
||||
let setArchivePublic (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<int> =
|
||||
let editArchive (db: Entity.ArchiveContext) (archiveId: System.Guid) (archive: Remoting.EditArchiveRequest) : Async<Entity.Archive> =
|
||||
async {
|
||||
let! existingArchive =
|
||||
db.Archives
|
||||
.Where(fun archive -> archive.ArchiveId = archiveId)
|
||||
.SingleAsync()
|
||||
|> Async.AwaitTask
|
||||
|
||||
let! alignedStartTime = getAlignedStartTime db existingArchive.AttribsId archive.StartTime
|
||||
|
||||
existingArchive.Name <- archive.Name
|
||||
existingArchive.StartTime <- alignedStartTime
|
||||
existingArchive.Frames <- archive.Frames
|
||||
existingArchive.Published <- archive.Published
|
||||
existingArchive.Public <- archive.Public
|
||||
|
||||
do db.Update existingArchive |> ignore
|
||||
do! db.SaveChangesAsync () |> Async.AwaitTask |> Async.Ignore
|
||||
|
||||
return existingArchive
|
||||
}
|
||||
|
||||
let setArchivePublic (db: Entity.ArchiveContext) (archiveId: System.Guid) (setPublic: bool) : Async<int> =
|
||||
async {
|
||||
let! starUserId =
|
||||
db.Users
|
||||
.AsNoTracking()
|
||||
@@ -306,12 +336,19 @@ module Archmaester =
|
||||
.SingleAsync()
|
||||
|> Async.AwaitTask
|
||||
|
||||
let starArchiveUser = Entity.ArchiveUser(UserId = starUserId, ArchiveId = archiveId)
|
||||
db.Add starArchiveUser |> ignore
|
||||
if setPublic then
|
||||
let starArchiveUser = Entity.ArchiveUser(UserId = starUserId, ArchiveId = archiveId)
|
||||
db.Add starArchiveUser |> ignore
|
||||
else
|
||||
let! starArchiveUser =
|
||||
db.ArchiveUsers
|
||||
.Where(fun au -> au.UserId = starUserId && au.ArchiveId = archiveId)
|
||||
.SingleOrDefaultAsync()
|
||||
|> Async.AwaitTask
|
||||
|
||||
let! created = db.SaveChangesAsync () |> Async.AwaitTask
|
||||
starArchiveUser |> Option.ofObj |> Option.iter (db.Remove >> ignore)
|
||||
|
||||
return created
|
||||
return! db.SaveChangesAsync () |> Async.AwaitTask
|
||||
}
|
||||
|
||||
let deleteArchive (db: Entity.ArchiveContext) (archiveId: System.Guid) : Async<int> =
|
||||
|
||||
@@ -148,6 +148,15 @@ module OpenFGA =
|
||||
|
||||
result
|
||||
|
||||
let read' (tuple: ClientTupleKey) : ClientReadRequest =
|
||||
let result = ClientReadRequest ()
|
||||
|
||||
do result.User <- tuple.User
|
||||
do result.Relation <- tuple.Relation
|
||||
do result.Object <- tuple.Object
|
||||
|
||||
result
|
||||
|
||||
let delete (tuples: Remoting.Tuple array) =
|
||||
let result = ClientWriteRequest ()
|
||||
|
||||
@@ -167,6 +176,25 @@ module OpenFGA =
|
||||
|
||||
result
|
||||
|
||||
let delete' (tuples: ClientTupleKey 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 ()
|
||||
|
||||
|
||||
@@ -174,6 +174,15 @@ module Remoting =
|
||||
Public = false
|
||||
}
|
||||
|
||||
[<Struct>]
|
||||
type EditArchiveRequest = {
|
||||
Name: string
|
||||
StartTime: System.DateTime
|
||||
Frames: int
|
||||
Published: bool
|
||||
Public: bool
|
||||
}
|
||||
|
||||
[<Struct>]
|
||||
type AddUsersRequest = {
|
||||
Group: string
|
||||
@@ -206,6 +215,7 @@ module Remoting =
|
||||
removeUsers: string array -> Async<Result<unit, string>>
|
||||
getDataSets: unit -> Async<Result<DataSet array, string>>
|
||||
addArchive: AddArchiveRequest -> Async<Result<Archive, string>>
|
||||
updateArchive: System.Guid -> EditArchiveRequest -> Async<Result<Archive, string>>
|
||||
}
|
||||
|
||||
type OpenFGA = {
|
||||
|
||||
Reference in New Issue
Block a user