Refactored queries to not be strings (#68)

* Rel8ed backend

* Removed rel8 dependency from common

* Format frontend

* Made frontend compile

* Migrations

* Fixed Actions encoding

* Fixed timeout

* Fixed overrides

* Fixed deployment duration

* Fixed inserting deployment logs

* Removed postgresql-simple

* Fixed octo CLI
This commit is contained in:
iko 2021-08-26 18:14:29 +03:00 committed by GitHub
parent 90fa7b8108
commit 0336964bab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1555 additions and 1522 deletions

View File

@ -35,7 +35,7 @@ let
}
];
index-state = "2021-07-02T00:00:00Z";
index-state = "2021-08-04T00:00:00Z";
compiler-nix-name = "ghc8105";
};
in

View File

@ -0,0 +1,62 @@
-- Deploy octopod:migrate_2.0 to pg
BEGIN;
-- XXX Add DDLs here.
ALTER TABLE deployments ADD COLUMN app_overrides jsonb;
UPDATE deployments AS d SET app_overrides =
( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array())
FROM deployment_overrides as "do"
WHERE d.id = "do".deployment_id AND scope = 'ApplicationScope'
);
ALTER TABLE deployments ALTER COLUMN app_overrides SET NOT NULL;
ALTER TABLE deployments ADD COLUMN deployment_overrides jsonb;
UPDATE deployments AS d SET deployment_overrides =
( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array())
FROM deployment_overrides as "do"
WHERE d.id = "do".deployment_id AND scope = 'DeploymentScope'
);
ALTER TABLE deployments ALTER COLUMN deployment_overrides SET NOT NULL;
ALTER TABLE deployments ADD COLUMN links jsonb;
UPDATE deployments AS d SET links =
( SELECT COALESCE(jsonb_agg(jsonb_build_object('name', "dm".key, 'link', "dm".value)), jsonb_build_array())
FROM deployment_metadata as "dm"
WHERE d.id = "dm".deployment_id
);
ALTER TABLE deployments ALTER COLUMN links SET NOT NULL;
ALTER TABLE deployments ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE deployments ALTER COLUMN "status" TYPE text;
DROP TABLE deployment_overrides;
DROP TABLE deployment_metadata;
ALTER TABLE deployment_logs ALTER COLUMN "action" TYPE text;
ALTER TABLE deployment_logs ALTER COLUMN "duration" DROP DEFAULT;
ALTER TABLE deployment_logs ALTER COLUMN "duration" TYPE interval USING (duration || ' seconds') :: interval;
ALTER TABLE deployment_logs ADD COLUMN deployment_overrides jsonb;
UPDATE deployment_logs AS d SET deployment_overrides =
( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array())
FROM deployment_log_overrides as "do"
WHERE d.id = "do".deployment_log_id AND scope = 'DeploymentScope'
);
ALTER TABLE deployment_logs ALTER COLUMN deployment_overrides SET NOT NULL;
ALTER TABLE deployment_logs ADD COLUMN app_overrides jsonb;
UPDATE deployment_logs AS d SET app_overrides =
( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array())
FROM deployment_log_overrides as "do"
WHERE d.id = "do".deployment_log_id AND scope = 'ApplicationScope'
);
ALTER TABLE deployment_logs ALTER COLUMN app_overrides SET NOT NULL;
DROP TABLE deployment_log_overrides;
COMMIT;

View File

@ -0,0 +1,12 @@
-- Revert octopod:migrate_2.0 from pg
BEGIN;
DO LANGUAGE 'plpgsql'
$$
BEGIN
RAISE EXCEPTION 'Revert not supported for this migration';
END
$$;
COMMIT;

View File

@ -15,3 +15,4 @@ rename-delete-to-archive 2020-11-23T10:53:10Z Ilya <octopod@typeable.io> # Renam
rename_delete_to_archive_2 2020-11-26T08:28:58Z Typeable LLC <octopod@typeable.io> # Renamed delete to archive
add_detailed_failures 2021-02-04T11:01:15Z Typeable LLC <octopod@typeable.io> # Added more failure states
remove_archived_column 2021-01-28T18:44:54Z Typeable LLC <octopod@typeable.io> # Removed 'archived' column
migrate_2.0 2021-08-11T17:09:37Z Typeable LLC <octopod@typeable.io> # Migrate to 2.0-style tables. (Less tables)

View File

@ -0,0 +1,37 @@
-- Verify octopod:migrate_2.0 on pg
BEGIN;
SELECT
id,
name,
tag,
app_overrides,
deployment_overrides,
created_at,
updated_at,
archived_at,
status,
status_updated_at,
checked_at,
links
FROM deployments
WHERE false;
SELECT
id,
deployment_id,
action,
tag,
exit_code,
created_at,
archived,
duration,
stdout,
stderr,
app_overrides,
deployment_overrides
FROM deployment_logs
WHERE false;
ROLLBACK;

View File

@ -44,7 +44,7 @@ executable octo
aeson,
base,
bytestring,
chronos,
time,
data-default-class,
generic-lens,
http-client-tls,
@ -59,6 +59,7 @@ executable octo
transformers,
table-layout ^>= 0.9.0.0,
servant-auth,
ordered-containers,
hs-source-dirs: src
default-language: Haskell2010
ghc-options:

View File

@ -2,9 +2,9 @@
module Octopod.CLI (runOcto) where
import Chronos
import Common.Types
import Common.Utils (dfiName)
import Control.Exception
import Control.Lens hiding (List)
import Control.Monad
import Control.Monad.IO.Class
@ -14,11 +14,14 @@ import qualified Data.ByteString.Lazy.Char8 as LBSC
import Data.Coerce
import Data.Generics.Labels ()
import Data.Generics.Product
import qualified Data.Map.Ordered.Strict as OM
import Data.Maybe
import Data.Proxy
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Data.Text.Lens
import Data.Time
import GHC.IO.Encoding
import Network.HTTP.Client.TLS
import Octopod.CLI.Args
@ -45,6 +48,9 @@ import Text.Layout.Table
import Text.Layout.Table.Extras ()
import Prelude as P
dieT :: MonadIO m => Text -> m a
dieT = liftIO . die . T.unpack
-- | Runs the octo CLI.
runOcto :: IO ()
runOcto = do
@ -58,27 +64,17 @@ runOcto = do
let clientEnv = mkClientEnv manager env
flip runReaderT clientEnv $
case args of
Create tName tTag tSetAp tSetDep tSetPAp tSetPDep -> do
setApp <- liftIO $ parseSetApplicationOverrides Public tSetAp
setDep <- liftIO $ parseSetDeploymentOverrides Public tSetDep
setPApp <- liftIO $ parseSetApplicationOverrides Private tSetPAp
setPDep <- liftIO $ parseSetDeploymentOverrides Private tSetPDep
let appOvs = setApp ++ setPApp
depOvs = setDep ++ setPDep
Create tName tTag tSetAp tSetDep -> do
appOvs <- either dieT pure $ parseSetOverrides tSetAp
depOvs <- either dieT pure $ parseSetOverrides tSetDep
handleCreate auth $ Deployment (coerce tName) (coerce tTag) appOvs depOvs
List -> handleList auth
Archive tName -> handleArchive auth . coerce $ tName
Update tName tTag tSetAp tUnsAp tSetD tUnsD tSetPAp tSetPD -> do
setApp <- liftIO $ parseSetApplicationOverrides Public tSetAp
setDep <- liftIO $ parseSetDeploymentOverrides Public tSetD
unsetApp <- liftIO $ parseUnsetApplicationOverrides Public tUnsAp
unsetDep <- liftIO $ parseUnsetDeploymentOverrides Public tUnsD
setPApp <- liftIO $ parseSetApplicationOverrides Private tSetPAp
setPDep <- liftIO $ parseSetDeploymentOverrides Private tSetPD
let appOvs = setApp ++ setPApp
depOvs = setDep ++ setPDep
tName' = coerce tName
tTag' = coerce tTag
Update tName tTag tSetAp unsetApp tSetD unsetDep -> do
appOvs <- either dieT pure $ parseSetOverrides tSetAp
depOvs <- either dieT pure $ parseSetOverrides tSetD
let tName' = coerce tName
tTag' = coerce <$> tTag
handleUpdate auth tName' tTag' appOvs unsetApp depOvs unsetDep
Info tName ->
handleInfo auth . coerce $ tName
@ -130,26 +126,46 @@ handleArchive auth dName = do
handleUpdate ::
AuthContext AuthHeaderAuth ->
DeploymentName ->
DeploymentTag ->
ApplicationOverrides ->
ApplicationOverrides ->
DeploymentOverrides ->
DeploymentOverrides ->
Maybe DeploymentTag ->
Overrides 'ApplicationLevel ->
[Text] ->
Overrides 'DeploymentLevel ->
[Text] ->
ReaderT ClientEnv IO ()
handleUpdate auth dName dTag dNewAppOvs dOldAppOvs dNewDepOvs dOldDepOvs = do
handleUpdate auth dName dTag dNewAppOvs removedAppOvs dNewDepOvs removedDepOvs = do
clientEnv <- ask
dep <- runClientM' (_fullInfoH auth dName) clientEnv
let removeAll :: Ord k => [k] -> OM.OMap k v -> Either k (OM.OMap k v)
removeAll [] m = Right m
removeAll (k : kk) m =
if k `OM.member` m
then removeAll kk $ OM.delete k m
else Left k
removeAllM :: MonadIO m => [Text] -> Overrides l -> m (Overrides l)
removeAllM ks (Overrides m) =
either
(\k -> dieT $ "Override " <> k <> " not present in deployment.")
(pure . Overrides)
$ removeAll ks m
appOverrides' <-
fmap (<> dNewAppOvs) $
removeAllM removedAppOvs $ dep ^. #deployment . #appOverrides
deploymentOverrides' <-
fmap (<> dNewDepOvs) $
removeAllM removedDepOvs $ dep ^. #deployment . #deploymentOverrides
liftIO $ do
let dUpdate =
DeploymentUpdate
{ newTag = dTag
, newAppOverrides = dNewAppOvs
, oldAppOverrides = dOldAppOvs
, newDeploymentOverrides = dNewDepOvs
, oldDeploymentOverrides = dOldDepOvs
{ newTag = fromMaybe (dep ^. #deployment . #tag) dTag
, appOverrides = appOverrides'
, deploymentOverrides = deploymentOverrides'
}
response <- runClientM (updateH auth dName dUpdate) clientEnv
handleResponse (const $ pure ()) response
runClientM' :: MonadIO m => ClientM a -> ClientEnv -> m a
runClientM' req env = liftIO $ runClientM req env >>= either (die . displayException) pure
-- | Handles the 'info' subcommand.
handleInfo :: AuthContext AuthHeaderAuth -> DeploymentName -> ReaderT ClientEnv IO ()
handleInfo auth dName = do
@ -192,13 +208,13 @@ handleGetActionInfo auth aId l = do
runClientM (getActionInfoH auth aId) clientEnv >>= \case
Left err -> print err
Right x -> case l of
Out -> T.putStrLn $ x ^. #stdout
Err -> T.putStrLn $ x ^. #stderr
Out -> T.putStrLn . unStdout $ x ^. #stdout
Err -> T.putStrLn . unStderr $ x ^. #stderr
ErrOut -> do
T.putStrLn "\t\tstdout:\n"
T.putStrLn $ x ^. #stdout
T.putStrLn . unStdout $ x ^. #stdout
T.putStrLn "\t\tstderr:\n"
T.putStrLn $ x ^. #stderr
T.putStrLn . unStderr $ x ^. #stderr
listH :: AuthContext AuthHeaderAuth -> ClientM [DeploymentFullInfo]
createH :: AuthContext AuthHeaderAuth -> Deployment -> ClientM CommandResponse
@ -260,19 +276,19 @@ decodeError body =
-- | Pretty-prints the 'info' subcommand result.
printInfo :: DeploymentInfo -> IO ()
printInfo (DeploymentInfo (Deployment _ dTag dAppOvs dStOvs) dMeta dLogs) = do
printInfo (DeploymentInfo (Deployment _ dTag dAppOvs dStOvs) (DeploymentMetadata dMeta) dLogs) = do
T.putStrLn "Current settings:"
T.putStrLn $ "tag: " <> coerce dTag
T.putStrLn $
"application overrides: "
<> (formatOverrides $ coerce <$> dAppOvs)
<> formatOverrides dAppOvs
T.putStrLn $
"deployment overrides: "
<> (formatOverrides $ coerce <$> dStOvs)
<> formatOverrides dStOvs
T.putStrLn $ "metadata: "
forM_ dMeta $ \m ->
T.putStrLn $
" " <> deploymentMetadataKey m <> ": " <> deploymentMetadataValue m
" " <> m ^. #name <> ": " <> m ^. #link
T.putStrLn ""
T.putStrLn "Last logs:"
ppDeploymentLogs dLogs
@ -308,21 +324,13 @@ ppDeploymentLogRow dLog =
colsAllG
top
[
[ encode_YmdHMS
SubsecondPrecisionAuto
w3c
( timeToDatetime . Time . fromIntegral $
dLog ^. field @"createdAt" * 10 ^ (9 :: Int)
)
[ T.pack . formatTime defaultTimeLocale (iso8601DateFormat (Just "%H:%M:%S")) $
dLog ^. field @"createdAt"
]
, [dLog ^. field @"actionId" . to unActionId . re _Show . packed]
, [dLog ^. field @"action" . coerced]
, [dLog ^. field @"action" . to actionToText]
, [dLog ^. field @"deploymentTag" . coerced]
, dLog
^. field @"deploymentAppOverrides"
. to (fmap $ formatOverride . coerce)
, dLog
^. field @"deploymentDepOverrides"
. to (fmap $ formatOverride . coerce)
, dLog ^. field @"deploymentAppOverrides" . to formatOverrides'
, dLog ^. field @"deploymentDepOverrides" . to formatOverrides'
, [dLog ^. field @"exitCode" . re _Show . packed]
]

View File

@ -20,10 +20,6 @@ data Args
setAppOverrides :: [Text]
, -- | deployment-level overrides to set
setDeploymentOverrides :: [Text]
, -- | application-level private overrides to set
setAppPrivateOverrides :: [Text]
, -- | deployment-level private overrides to set
setDeploymentPrivateOverrides :: [Text]
}
| List
| Archive
@ -34,7 +30,7 @@ data Args
{ -- | deployment name
name :: Text
, -- | deployment tag
tag :: Text
newTag :: Maybe Text
, -- | application-level overrides to set
setAppOverrides :: [Text]
, -- | application-level overrides to unset
@ -43,10 +39,6 @@ data Args
setDeploymentOverrides :: [Text]
, -- | deployment-level overrides to unset
unsetDeploymentOverrides :: [Text]
, -- | application-level private overrides to set
setAppPrivateOverrides :: [Text]
, -- | deployment-level private overrides to set
setDeploymentPrivateOverrides :: [Text]
}
| Info
{ -- | deployment name
@ -119,22 +111,6 @@ createArgs =
<> help "set deployment level override"
)
)
<*> many
( strOption
( long "set-app-env-private-override"
<> short 'a'
<> help "set application level private override"
<> internal
)
)
<*> many
( strOption
( long "set-deployment-private-override"
<> short 's'
<> help "set deployment level private override"
<> internal
)
)
-- | Parses arguments of 'list' subcommand.
listArgs :: Parser Args
@ -152,7 +128,7 @@ updateArgs :: Parser Args
updateArgs =
Update
<$> strOption (long "name" <> short 'n' <> help "deployment name")
<*> strOption (long "tag" <> short 't' <> help "deployment tag")
<*> optional (strOption (long "tag" <> short 't' <> help "deployment tag"))
<*> many
( strOption
( long "set-app-env-override"
@ -181,22 +157,6 @@ updateArgs =
<> help "unset a deployment level override"
)
)
<*> many
( strOption
( long "set-app-env-private-override"
<> short 'a'
<> help "set application level private override"
<> internal
)
)
<*> many
( strOption
( long "set-deployment-private-override"
<> short 's'
<> help "set deployment level private override"
<> internal
)
)
-- | Parses arguments of 'info' subcommand.
infoArgs :: Parser Args

View File

@ -63,9 +63,9 @@ library
Octopod.Server.Posix
Orphans
Types
Database.PostgreSQL.Simple.Instances
Control.Octopod.DeploymentLock
Octopod.PowerAPI.Auth.Server
Octopod.Schema
hs-source-dirs:
src
build-depends:
@ -73,7 +73,6 @@ library
, async
, base >=4.7 && <5
, bytestring
, chronos
, conduit
, deriving-aeson
, octopod-common
@ -87,7 +86,6 @@ library
, optparse-applicative
, optparse-generic
, postgresql-error-codes
, postgresql-simple
, resource-pool
, servant
, servant-server
@ -107,6 +105,11 @@ library
, servant-auth-server
, wai
, jose
, rel8
, time
, hasql
, hasql-transaction
, ordered-containers
default-language: Haskell2010
executable octopod-exe

View File

@ -1,33 +0,0 @@
{-# OPTIONS_GHC -Wno-orphans #-}
module Database.PostgreSQL.Simple.Instances
(
) where
import Common.Types
import Control.Applicative
import Control.Arrow
import qualified Data.Map as M
import Data.Text (Text)
import Database.PostgreSQL.Simple.FromField
import Database.PostgreSQL.Simple.ToField
import qualified Data.Text.Encoding as T
instance ToField DeploymentStatus where
toField = toField @Text . deploymentStatusText
instance FromField DeploymentStatus where
fromField _ (Just b) =
(either (const empty) pure . T.decodeUtf8' $ b) >>= maybe empty return . flip M.lookup m
where
m = M.fromList . fmap (deploymentStatusText &&& id) $
[ Running
, Failure GenericFailure
, Failure TagMismatch
, Failure PartialAvailability
, CreatePending
, UpdatePending
, ArchivePending
, Archived
]
fromField _ Nothing = empty

View File

@ -0,0 +1,101 @@
module Octopod.Schema
( DeploymentSchema (..),
deploymentSchema,
extractDeployment,
DeploymentLogSchema (..),
deploymentLogSchema,
extractDeploymentLog,
)
where
import Data.Generics.Product
import Data.Int
import Data.Time
import GHC.Generics (Generic)
import Orphans ()
import Rel8
import Types
deploymentSchema :: TableSchema (DeploymentSchema Name)
deploymentSchema =
TableSchema
{ name = "deployments"
, schema = Nothing
, columns =
DeploymentSchema
{ id_ = "id"
, name = "name"
, tag = "tag"
, appOverrides = "app_overrides"
, deploymentOverrides = "deployment_overrides"
, createdAt = "created_at"
, updatedAt = "updated_at"
, archivedAt = "archived_at"
, status = "status"
, statusUpdatedAt = "status_updated_at"
, checkedAt = "checked_at"
, metadata = "links"
}
}
data DeploymentSchema f = DeploymentSchema
{ id_ :: Column f DeploymentId
, name :: Column f DeploymentName
, tag :: Column f DeploymentTag
, appOverrides :: Column f (Overrides 'ApplicationLevel)
, deploymentOverrides :: Column f (Overrides 'DeploymentLevel)
, createdAt :: Column f UTCTime
, updatedAt :: Column f UTCTime
, archivedAt :: Column f (Maybe UTCTime)
, status :: Column f DeploymentStatus
, statusUpdatedAt :: Column f UTCTime
, checkedAt :: Column f UTCTime
, metadata :: Column f DeploymentMetadata
}
deriving stock (Generic)
deriving anyclass (Rel8able)
extractDeployment :: DeploymentSchema Result -> Deployment
extractDeployment = upcast
data DeploymentLogSchema f = DeploymentLogSchema
{ actionId :: Column f ActionId
, deploymentId :: Column f DeploymentId
, action :: Column f Action
, deploymentTag :: Column f DeploymentTag
, exitCode :: Column f Int64
, createdAt :: Column f UTCTime
, archived :: Column f Bool
, duration :: Column f Duration
, stdout :: Column f Stdout
, stderr :: Column f Stderr
, deploymentAppOverrides :: Column f (Overrides 'ApplicationLevel)
, deploymentDepOverrides :: Column f (Overrides 'DeploymentLevel)
}
deriving stock (Generic)
deriving anyclass (Rel8able)
deploymentLogSchema :: TableSchema (DeploymentLogSchema Name)
deploymentLogSchema =
TableSchema
{ name = "deployment_logs"
, schema = Nothing
, columns =
DeploymentLogSchema
{ actionId = "id"
, deploymentId = "deployment_id"
, action = "action"
, deploymentTag = "tag"
, exitCode = "exit_code"
, createdAt = "created_at"
, archived = "archived"
, duration = "duration"
, stdout = "stdout"
, stderr = "stderr"
, deploymentAppOverrides = "app_overrides"
, deploymentDepOverrides = "deployment_overrides"
}
}
extractDeploymentLog :: DeploymentLogSchema Result -> DeploymentLog
extractDeploymentLog = upcast

File diff suppressed because it is too large Load Diff

View File

@ -15,19 +15,18 @@ module Octopod.Server.ControlScriptUtils
tagCheckCommandArgs,
-- * Helpers
applicationOverrideToArg,
applicationOverridesToArgs,
deploymentOverrideToArg,
deploymentOverridesToArgs,
fullConfigArgs,
overridesArgs,
)
where
import Control.Lens
import Control.Monad.Base
import Control.Monad.Reader
import qualified Data.ByteString.Lazy as TL
import Data.Coerce
import Data.Generics.Product.Typed
import Data.Text (Text)
import qualified Data.Map.Ordered.Strict as MO
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Octopod.Server.Logger
@ -43,9 +42,10 @@ infoCommandArgs ::
, HasType ProjectName r
, HasType Domain r
) =>
FullDefaultConfig ->
Deployment ->
m ControlScriptArgs
infoCommandArgs dep = do
infoCommandArgs dCfg dep = do
(Namespace namespace) <- asks getTyped
(ProjectName projectName) <- asks getTyped
(Domain domain) <- asks getTyped
@ -58,12 +58,11 @@ infoCommandArgs dep = do
, "--namespace"
, T.unpack . coerce $ namespace
, "--name"
, T.unpack . coerce $ name dep
, T.unpack . coerce $ dep ^. #name
, "--tag"
, T.unpack . coerce $ tag dep
]
<> getApplicationOverrideArgs dep
<> getDeploymentOverrideArgs dep
<> fullConfigArgs dCfg dep
notificationCommandArgs ::
( MonadReader r m
@ -95,9 +94,9 @@ notificationCommandArgs dName dTag old new = do
, "--tag"
, T.unpack . coerce $ dTag
, "--old-status"
, T.unpack $ deploymentStatusText old
, T.unpack $ deploymentStatusToText old
, "--new-status"
, T.unpack $ deploymentStatusText new
, T.unpack $ deploymentStatusToText new
]
checkCommandArgs ::
@ -106,9 +105,10 @@ checkCommandArgs ::
, HasType ProjectName r
, HasType Domain r
) =>
FullDefaultConfig ->
Deployment ->
m ControlScriptArgs
checkCommandArgs dep = do
checkCommandArgs dCfg dep = do
(Namespace namespace) <- asks getTyped
(ProjectName projectName) <- asks getTyped
(Domain domain) <- asks getTyped
@ -121,12 +121,11 @@ checkCommandArgs dep = do
, "--namespace"
, T.unpack . coerce $ namespace
, "--name"
, T.unpack . coerce $ name dep
, T.unpack . coerce $ dep ^. #name
, "--tag"
, T.unpack . coerce $ tag dep
]
<> getApplicationOverrideArgs dep
<> getDeploymentOverrideArgs dep
<> fullConfigArgs dCfg dep
tagCheckCommandArgs ::
( MonadReader r m
@ -134,9 +133,10 @@ tagCheckCommandArgs ::
, HasType ProjectName r
, HasType Domain r
) =>
FullDefaultConfig ->
Deployment ->
m ControlScriptArgs
tagCheckCommandArgs dep = do
tagCheckCommandArgs dCfg dep = do
(Namespace namespace) <- asks getTyped
(ProjectName projectName) <- asks getTyped
(Domain domain) <- asks getTyped
@ -149,12 +149,11 @@ tagCheckCommandArgs dep = do
, "--namespace"
, T.unpack . coerce $ namespace
, "--name"
, T.unpack . coerce $ name dep
, T.unpack . coerce $ dep ^. #name
, "--tag"
, T.unpack . coerce $ tag dep
]
<> getApplicationOverrideArgs dep
<> getDeploymentOverrideArgs dep
<> fullConfigArgs dCfg dep
archiveCheckArgs ::
( MonadReader r m
@ -219,27 +218,18 @@ runCommandWithoutPipes :: FilePath -> [String] -> IO ExitCode
runCommandWithoutPipes cmd args =
withProcessWait (proc cmd args) waitExitCode
-- | Converts an application-level override list to command arguments.
applicationOverrideToArg :: ApplicationOverride -> [Text]
applicationOverrideToArg o = ["--app-env-override", overrideToArg . coerce $ o]
fullConfigArgs :: FullDefaultConfig -> Deployment -> ControlScriptArgs
fullConfigArgs defCfg dep =
overridesArgs (applyOverrides (dep ^. #appOverrides) (appDefaultConfig defCfg))
<> overridesArgs (applyOverrides (dep ^. #deploymentOverrides) (depDefaultConfig defCfg))
-- | Helper to convert an application-level override to command arguments.
applicationOverridesToArgs :: ApplicationOverrides -> [Text]
applicationOverridesToArgs ovs = concat [applicationOverrideToArg o | o <- ovs]
getApplicationOverrideArgs :: Deployment -> ControlScriptArgs
getApplicationOverrideArgs =
ControlScriptArgs . map T.unpack . applicationOverridesToArgs . appOverrides
-- | Converts a deployment-level override list to command arguments.
deploymentOverrideToArg :: DeploymentOverride -> [Text]
deploymentOverrideToArg o =
["--deployment-override", overrideToArg . coerce $ o]
-- | Helper to convert a deployment-level override to command arguments.
deploymentOverridesToArgs :: DeploymentOverrides -> [Text]
deploymentOverridesToArgs ovs = concat [deploymentOverrideToArg o | o <- ovs]
getDeploymentOverrideArgs :: Deployment -> ControlScriptArgs
getDeploymentOverrideArgs =
ControlScriptArgs . map T.unpack . deploymentOverridesToArgs . deploymentOverrides
overridesArgs :: forall l. KnownOverrideLevel l => Config l -> ControlScriptArgs
overridesArgs (Config cc) =
ControlScriptArgs
. concatMap (\(T.unpack -> k, T.unpack -> v) -> [argumentName, k <> "=" <> v])
. MO.assocs
$ cc
where
argumentName = case knownOverrideLevel @l of
ApplicationLevel -> "--app-env-override"
DeploymentLevel -> "--deployment-override"

View File

@ -3,17 +3,48 @@
module Orphans () where
import Common.Types
import Database.PostgreSQL.Simple.FromField (FromField)
import Database.PostgreSQL.Simple.ToField (ToField)
import Data.Maybe
import Rel8
deriving newtype instance FromField Action
parseTypeInformationFromMapping :: (Eq a, Eq b, DBType b, Show b, Show a) => [(a, b)] -> TypeInformation a
parseTypeInformationFromMapping m =
parseTypeInformation
(\v -> maybe (Left $ "unknown value: " <> show v) Right . flip lookup reversedM $ v)
(\v -> fromMaybe (error $ "forgot case: " <> show v) . flip lookup m $ v)
typeInformation
where
reversedM = (\(x, y) -> (y, x)) <$> m
deriving newtype instance ToField Action
deriving via JSONBEncoded (Overrides l) instance (DBType (Overrides l))
deriving newtype instance FromField DeploymentTag
deriving newtype instance DBType DeploymentId
deriving newtype instance DBEq DeploymentId
deriving newtype instance ToField DeploymentTag
deriving newtype instance DBType DeploymentName
deriving newtype instance DBEq DeploymentName
deriving newtype instance FromField DeploymentName
deriving newtype instance DBType DeploymentTag
deriving newtype instance DBEq DeploymentTag
deriving newtype instance ToField DeploymentName
instance DBType Action where
typeInformation = parseTypeInformationFromMapping actionText
deriving newtype instance DBType ArchivedFlag
deriving newtype instance DBType Duration
deriving newtype instance DBType Timestamp
deriving newtype instance DBType ProjectName
deriving anyclass instance DBEq DeploymentStatus
instance DBType DeploymentStatus where
typeInformation = parseTypeInformationFromMapping deploymentStatusText
deriving via JSONBEncoded DeploymentMetadata instance DBType DeploymentMetadata
deriving newtype instance DBType Stdout
deriving newtype instance DBType Stderr
deriving newtype instance DBType ActionId
deriving newtype instance DBEq ActionId

View File

@ -25,15 +25,16 @@ import Data.Text as T
import Data.Traversable
import Common.Types
import Data.Time
-- | Parses deployment metadata.
parseDeploymentMetadata :: [Text] -> IO [DeploymentMetadata]
parseDeploymentMetadata texts =
parseDeploymentMetadata :: [Text] -> IO DeploymentMetadata
parseDeploymentMetadata texts = fmap DeploymentMetadata $
for texts $ \t ->
case T.findIndex (== ',') t of
Just i -> do
let (key, value) = bimap strip (T.tail . strip) $ T.splitAt i t
pure $ DeploymentMetadata key value
pure $ DeploymentMetadatum key value
Nothing ->
error $
"Malformed metadata key-value pair " <> T.unpack t
@ -60,11 +61,11 @@ newtype Namespace = Namespace {unNamespace :: Text}
deriving stock (Show)
-- | Archive retention.
newtype ArchiveRetention = ArchiveRetention {unArchiveRetention :: Int}
newtype ArchiveRetention = ArchiveRetention {unArchiveRetention :: NominalDiffTime}
deriving stock (Show)
-- | Timeout.
newtype Timeout = Timeout {unTimeout :: Int}
newtype Timeout = Timeout {unTimeout :: CalendarDiffTime}
deriving stock (Show)
-- | Path to a deployment control script.

View File

@ -18,6 +18,7 @@ library
exposed-modules: Common.Types
, Common.Utils
, Common.Validation
, Data.Map.Ordered.Strict.Extra
-- other-modules:
-- other-extensions:
default-extensions: BlockArguments
@ -35,6 +36,16 @@ library
, TypeApplications
, TypeOperators
, ViewPatterns
, DeriveAnyClass
, OverloadedLabels
, StandaloneDeriving
, TypeSynonymInstances
, FlexibleInstances
, DataKinds
, KindSignatures
, EmptyDataDeriving
, TupleSections
, AllowAmbiguousTypes
build-depends: base
, aeson
, bytestring
@ -43,6 +54,9 @@ library
, http-api-data
, lens
, text
, containers
, time
, ordered-containers
hs-source-dirs: src
default-language: Haskell2010
ghc-options:
@ -56,3 +70,4 @@ library
-Wno-missing-local-signatures
-Wno-partial-fields
-Wno-unsafe
-Wno-missed-specialisations

View File

@ -7,55 +7,87 @@
-- This module contains common types between the backend and the frontend.
module Common.Types where
import Data.Bifunctor
import Data.Coerce
import Data.String
import Data.Text as T hiding (filter)
import Control.Lens
import Data.Aeson hiding (Result)
import Data.Generics.Labels ()
import Data.Int
import Data.Map.Ordered.Strict (OMap, (<>|))
import qualified Data.Map.Ordered.Strict as OM
import Data.Map.Ordered.Strict.Extra ()
import Data.Maybe
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time
import Data.Traversable
import Deriving.Aeson
import Deriving.Aeson.Stock
import Web.HttpApiData
-- | Deployment override.
data Override = Override
{ overrideKey :: Text
, overrideValue :: Text
, overrideVisibility :: OverrideVisibility
data OverrideLevel = ApplicationLevel | DeploymentLevel
class KnownOverrideLevel (l :: OverrideLevel) where
knownOverrideLevel :: OverrideLevel
instance KnownOverrideLevel 'ApplicationLevel where
knownOverrideLevel = ApplicationLevel
instance KnownOverrideLevel 'DeploymentLevel where
knownOverrideLevel = DeploymentLevel
data OverrideValue = ValueAdded Text | ValueDeleted
deriving (ToJSON, FromJSON) via Snake OverrideValue
deriving stock (Eq, Ord, Show, Generic)
newtype DefaultConfig (l :: OverrideLevel) = DefaultConfig (OMap Text Text)
deriving newtype (Eq, Ord, Show, ToJSON, FromJSON)
newtype Config (l :: OverrideLevel) = Config {unConfig :: OMap Text Text}
deriving newtype (Eq, Ord, Show, ToJSON, FromJSON)
data FullDefaultConfig = FullDefaultConfig
{ appDefaultConfig :: DefaultConfig 'ApplicationLevel
, depDefaultConfig :: DefaultConfig 'DeploymentLevel
}
deriving stock (Generic, Show, Eq)
deriving (FromJSON, ToJSON) via Snake Override
deriving stock (Show, Ord, Eq, Generic)
deriving (ToJSON, FromJSON) via Snake FullDefaultConfig
type Overrides = [Override]
data FullConfig = FullConfig
{ appConfig :: Config 'ApplicationLevel
, depConfig :: Config 'DeploymentLevel
}
deriving stock (Show, Ord, Eq, Generic)
deriving (ToJSON, FromJSON) via Snake FullConfig
-- | Deployment override scope.
data OverrideScope
= ApplicationScope
| DeploymentScope
deriving stock (Generic, Show, Read, Eq)
deriving (FromJSON, ToJSON) via Snake OverrideScope
applyOverrides :: Overrides l -> DefaultConfig l -> Config l
applyOverrides (Overrides oo) (DefaultConfig dd) =
Config . extract $ oo <>| (ValueAdded <$> dd)
where
extract :: OMap Text OverrideValue -> OMap Text Text
extract =
fmap
( \case
ValueAdded v -> v
ValueDeleted -> error "invariant"
)
. OM.filter
( \_ -> \case
ValueAdded _ -> True
ValueDeleted -> False
)
-- | Deployment override visibility.
data OverrideVisibility
= Private
| Public
deriving stock (Generic, Show, Read, Eq)
deriving (FromJSON, ToJSON) via Snake OverrideVisibility
newtype Overrides (l :: OverrideLevel) = Overrides {unOverrides :: OMap Text OverrideValue}
deriving newtype (Eq, Ord, Show, ToJSON, FromJSON)
-- | Deployment application-level override.
newtype ApplicationOverride = ApplicationOverride {unApplicationOverride :: Override}
deriving newtype (Show, Eq, FromJSON, ToJSON)
ov :: Text -> OverrideValue -> Overrides l
ov k v = Overrides $ OM.singleton (k, v)
-- | Deployment application-level overrides.
type ApplicationOverrides = [ApplicationOverride]
instance Semigroup (Overrides l) where
(Overrides lhs) <> (Overrides rhs) = Overrides $ rhs <>| lhs
-- | Deployment-level override.
newtype DeploymentOverride = DeploymentOverride
{unDeploymentOverride :: Override}
deriving newtype (Show, Eq, FromJSON, ToJSON)
instance Monoid (Overrides l) where
mempty = Overrides OM.empty
-- | Deployment-level overrides.
type DeploymentOverrides = [DeploymentOverride]
newtype DeploymentId = DeploymentId {unDeploymentId :: Int}
newtype DeploymentId = DeploymentId {unDeploymentId :: Int64}
deriving stock (Show)
newtype DeploymentName = DeploymentName {unDeploymentName :: Text}
@ -66,30 +98,47 @@ newtype DeploymentTag = DeploymentTag {unDeploymentTag :: Text}
deriving newtype
(Show, FromJSON, ToJSON, ToHttpApiData, FromHttpApiData, Eq)
newtype Action = Action {unAction :: Text}
deriving newtype (Show, FromJSON, ToJSON, IsString)
data Action = RestoreAction | ArchiveAction | UpdateAction | CreateAction
deriving stock (Show, Read, Eq, Ord, Generic)
deriving (FromJSON, ToJSON) via Snake Action
actionText :: [(Action, Text)]
actionText =
[ (RestoreAction, "restore")
, (ArchiveAction, "archive")
, (UpdateAction, "update")
, (CreateAction, "create")
]
actionToText :: Action -> Text
actionToText k = fromMaybe (error $ "forgot case: " <> show k) . Prelude.lookup k $ actionText
newtype ArchivedFlag = ArchivedFlag {unArchivedFlag :: Bool}
deriving newtype (Show, FromJSON, ToJSON)
newtype Duration = Duration {unDuration :: Int}
deriving newtype (Show, FromJSON, ToJSON)
newtype Duration = Duration {unDuration :: CalendarDiffTime}
deriving newtype (Show, Eq, FromJSON, ToJSON, FormatTime)
newtype Timestamp = Timestamp {unTimestamp :: Int}
deriving newtype (Show, Eq, Ord, FromJSON, ToJSON)
newtype Timestamp = Timestamp {unTimestamp :: CalendarDiffTime}
deriving newtype (Show, Eq, FromJSON, ToJSON)
newtype ProjectName = ProjectName {uProjectName :: Text}
deriving newtype (Show, FromJSON, ToJSON)
deploymentStatusText :: DeploymentStatus -> Text
deploymentStatusText Running = "Running"
deploymentStatusText (Failure GenericFailure) = "GenericFailure"
deploymentStatusText (Failure TagMismatch) = "TagMismatch"
deploymentStatusText (Failure PartialAvailability) = "PartialAvailability"
deploymentStatusText CreatePending = "CreatePending"
deploymentStatusText UpdatePending = "UpdatePending"
deploymentStatusText ArchivePending = "ArchivePending"
deploymentStatusText Archived = "Archived"
deploymentStatusText :: [(DeploymentStatus, Text)]
deploymentStatusText =
[ (Running, "Running")
, (Failure GenericFailure, "GenericFailure")
, (Failure TagMismatch, "TagMismatch")
, (Failure PartialAvailability, "PartialAvailability")
, (CreatePending, "CreatePending")
, (UpdatePending, "UpdatePending")
, (ArchivePending, "ArchivePending")
, (Archived, "Archived")
]
deploymentStatusToText :: DeploymentStatus -> Text
deploymentStatusToText k = fromMaybe (error $ "forgot case: " <> show k) . Prelude.lookup k $ deploymentStatusText
data DeploymentStatus
= Running
@ -124,8 +173,8 @@ isArchivedStatus = (`elem` archivedStatuses)
data Deployment = Deployment
{ name :: DeploymentName
, tag :: DeploymentTag
, appOverrides :: ApplicationOverrides
, deploymentOverrides :: DeploymentOverrides
, appOverrides :: Overrides 'ApplicationLevel
, deploymentOverrides :: Overrides 'DeploymentLevel
}
deriving stock (Generic, Show, Eq)
deriving (FromJSON, ToJSON) via Snake Deployment
@ -134,27 +183,30 @@ data DeploymentLog = DeploymentLog
{ actionId :: ActionId
, action :: Action
, deploymentTag :: DeploymentTag
, deploymentAppOverrides :: ApplicationOverrides
, deploymentDepOverrides :: DeploymentOverrides
, exitCode :: Int
, deploymentAppOverrides :: Overrides 'ApplicationLevel
, deploymentDepOverrides :: Overrides 'DeploymentLevel
, exitCode :: Int64
, duration :: Duration
, createdAt :: Int
}
deriving stock (Generic, Show)
deriving (FromJSON, ToJSON) via Snake DeploymentLog
data DeploymentMetadata = DeploymentMetadata
{ -- | The name of the link
deploymentMetadataKey :: Text
, -- | The URL
deploymentMetadataValue :: Text
, createdAt :: UTCTime
}
deriving stock (Generic, Show, Eq)
deriving (FromJSON, ToJSON) via Snake DeploymentMetadata
deriving (ToJSON, FromJSON) via Snake DeploymentLog
newtype DeploymentMetadata = DeploymentMetadata {unDeploymentMetadata :: [DeploymentMetadatum]}
deriving newtype (Eq, Show, Ord, FromJSON, ToJSON)
data DeploymentMetadatum = DeploymentMetadatum
{ -- | The name of the link
name :: Text
, -- | The URL
link :: Text
}
deriving stock (Generic, Show, Eq, Ord)
deriving (FromJSON, ToJSON) via Snake DeploymentMetadatum
data DeploymentInfo = DeploymentInfo
{ deployment :: Deployment
, metadata :: [DeploymentMetadata]
, metadata :: DeploymentMetadata
, logs :: [DeploymentLog]
}
deriving stock (Generic, Show)
@ -163,13 +215,21 @@ data DeploymentInfo = DeploymentInfo
data DeploymentFullInfo = DeploymentFullInfo
{ deployment :: Deployment
, status :: PreciseDeploymentStatus
, metadata :: [DeploymentMetadata]
, createdAt :: Int
, updatedAt :: Int
, metadata :: DeploymentMetadata
, createdAt :: UTCTime
, updatedAt :: UTCTime
, deploymentDefaultConfig :: FullDefaultConfig
}
deriving stock (Generic, Show, Eq)
deriving (FromJSON, ToJSON) via Snake DeploymentFullInfo
getDeploymentConfig :: DeploymentFullInfo -> FullConfig
getDeploymentConfig d =
FullConfig
{ appConfig = applyOverrides (d ^. #deployment . #appOverrides) (d ^. #deploymentDefaultConfig . #appDefaultConfig)
, depConfig = applyOverrides (d ^. #deployment . #deploymentOverrides) (d ^. #deploymentDefaultConfig . #depDefaultConfig)
}
isDeploymentArchived :: DeploymentFullInfo -> Bool
isDeploymentArchived DeploymentFullInfo {status = s} = case s of
DeploymentNotPending s' -> isArchivedStatus s'
@ -179,27 +239,12 @@ isDeploymentArchived DeploymentFullInfo {status = s} = case s of
data DeploymentUpdate = DeploymentUpdate
{ newTag :: DeploymentTag
, newAppOverrides :: ApplicationOverrides
, oldAppOverrides :: ApplicationOverrides
, newDeploymentOverrides :: DeploymentOverrides
, oldDeploymentOverrides :: DeploymentOverrides
, appOverrides :: Overrides 'ApplicationLevel
, deploymentOverrides :: Overrides 'DeploymentLevel
}
deriving stock (Generic, Show)
deriving (FromJSON, ToJSON) via Snake DeploymentUpdate
applyDeploymentUpdate :: DeploymentUpdate -> Deployment -> Deployment
applyDeploymentUpdate du d =
Deployment
{ name = name d
, tag = newTag du
, appOverrides =
filter (`notElem` oldAppOverrides du) (appOverrides d)
<> newAppOverrides du
, deploymentOverrides =
filter (`notElem` oldDeploymentOverrides du) (deploymentOverrides d)
<> newDeploymentOverrides du
}
data CurrentStatus
= Ok
| Error
@ -225,7 +270,7 @@ data WSEvent = FrontendPleaseUpdateEverything
deriving stock (Generic, Show)
deriving (FromJSON, ToJSON) via Snake WSEvent
newtype ActionId = ActionId {unActionId :: Int}
newtype ActionId = ActionId {unActionId :: Int64}
deriving newtype
(Show, Read, FromJSON, ToJSON, ToHttpApiData, FromHttpApiData, Eq)
@ -238,74 +283,36 @@ newtype Stderr = Stderr {unStderr :: Text}
deriving (FromJSON, ToJSON) via Snake Stderr
data ActionInfo = ActionInfo
{ stdout :: Text
, stderr :: Text
{ stdout :: Stdout
, stderr :: Stderr
}
deriving stock (Generic, Show)
deriving (FromJSON, ToJSON) via Snake ActionInfo
-- | Parses setting application-level overrides.
parseSetApplicationOverrides ::
OverrideVisibility ->
[Text] ->
IO [ApplicationOverride]
parseSetApplicationOverrides visibility texts =
coerce <$> parseSetOverrides visibility texts
-- | Parses setting deployment-level overrides.
parseSetDeploymentOverrides ::
OverrideVisibility ->
[Text] ->
IO [DeploymentOverride]
parseSetDeploymentOverrides visibility texts =
coerce <$> parseSetOverrides visibility texts
-- | Parses setting overrides.
parseSetOverrides :: OverrideVisibility -> [Text] -> IO [Override]
parseSetOverrides visibility texts =
for texts $ \t ->
case T.findIndex (== '=') t of
Just i -> do
let (key, value) = bimap strip (T.tail . strip) $ T.splitAt i t
pure $ Override key value visibility
Nothing ->
error $
"Malformed override key-value pair " <> T.unpack t
<> ", should be similar to FOO=bar"
parseSetOverrides :: [Text] -> Either Text (Overrides l)
parseSetOverrides texts = do
pairs' <- for texts $ \text -> case parseSingleOverride text of
Just x -> Right x
Nothing ->
Left $ "Malformed override key-value pair " <> text <> ", should be similar to FOO=bar"
return . Overrides $ OM.fromList pairs'
where
parseSingleOverride :: Text -> Maybe (Text, OverrideValue)
parseSingleOverride t
| Just i <- T.findIndex (== '=') t =
let (key, value) = bimap T.strip (T.tail . T.strip) $ T.splitAt i t
in Just (key, ValueAdded value)
parseSingleOverride _ = Nothing
-- | Parses unsetting application-level overrides.
parseUnsetApplicationOverrides ::
OverrideVisibility ->
[Text] ->
IO [ApplicationOverride]
parseUnsetApplicationOverrides visibility texts =
coerce <$> parseUnsetOverrides visibility texts
parseUnsetOverrides :: [Text] -> Overrides l
parseUnsetOverrides = Overrides . OM.fromList . fmap (,ValueDeleted)
-- | Parses unsetting deployment-level overrides.
parseUnsetDeploymentOverrides ::
OverrideVisibility ->
[Text] ->
IO [DeploymentOverride]
parseUnsetDeploymentOverrides visibility texts =
coerce <$> parseUnsetOverrides visibility texts
formatOverrides :: Overrides l -> Text
formatOverrides = T.unlines . formatOverrides'
-- | Parses unsetting overrides.
parseUnsetOverrides :: OverrideVisibility -> [Text] -> IO [Override]
parseUnsetOverrides visibility texts =
for texts $ \key ->
pure $ Override key "" visibility
-- | Creates pretty-printed text from override.
formatOverride :: Override -> Text
formatOverride o@(Override _ _ vis) =
overrideToArg o <> case vis of
Private -> " (" <> pack (show vis) <> ")"
Public -> mempty
-- | Creates pretty-printed texts from overrides.
formatOverrides :: Overrides -> Text
formatOverrides = T.unlines . fmap formatOverride
-- | Creates a CLI argument from an override.
overrideToArg :: Override -> Text
overrideToArg (Override k v _) = k <> "=" <> v
formatOverrides' :: Overrides l -> [Text]
formatOverrides' (Overrides m) = fmap (\(k, v) -> k <> "=" <> showValue v) . OM.assocs $ m
where
showValue (ValueAdded v) = v
showValue ValueDeleted = "<removed>"

View File

@ -36,7 +36,7 @@ dfiName ::
(DeploymentName -> f DeploymentName) ->
DeploymentFullInfo ->
f DeploymentFullInfo
dfiName = field @"deployment" . field @"name"
dfiName = field @"deployment" . field' @"name"
-- | Checks that deployment status is pending.
isPending :: DeploymentStatus -> Bool

View File

@ -0,0 +1,15 @@
{-# OPTIONS_GHC -Wno-orphans #-}
module Data.Map.Ordered.Strict.Extra
(
)
where
import Data.Aeson
import Data.Map.Ordered.Strict
instance (ToJSON k, ToJSON v) => ToJSON (OMap k v) where
toJSON = toJSON . assocs
instance (FromJSON k, FromJSON v, Ord k) => FromJSON (OMap k v) where
parseJSON = fmap fromList . parseJSON

View File

@ -83,6 +83,7 @@ executable frontend
, FunctionalDependencies
, AllowAmbiguousTypes
, OverloadedLabels
, ViewPatterns
build-depends: aeson
, base
, bytestring
@ -104,5 +105,6 @@ executable frontend
, mtl
, semialign
, these
, ordered-containers
hs-source-dirs: src
default-language: Haskell2010

View File

@ -8,15 +8,23 @@
--frontend modules.
module Frontend.Utils where
import Common.Types as CT
import Control.Lens
import Control.Monad
import Control.Monad.Reader
import Data.Functor
import Data.Generics.Labels ()
import qualified Data.List as L
import Data.Map (Map)
import qualified Data.Map as M
import qualified Data.Map.Ordered.Strict as OM
import Data.Maybe (fromMaybe)
import Data.Monoid
import Data.Proxy (Proxy (..))
import Data.Text as T (Text, pack)
import Data.Text as T (Text, null, pack)
import Data.Time
import Data.Time.Clock.POSIX
import Frontend.GHCJS
import GHCJS.DOM
import GHCJS.DOM.Element as DOM
import GHCJS.DOM.EventM (on, target)
@ -24,10 +32,6 @@ import GHCJS.DOM.GlobalEventHandlers as Events (click)
import GHCJS.DOM.Node as DOM
import Reflex.Dom as R
import Common.Types as CT
import Control.Monad.Reader
import Frontend.GHCJS
-- | Wrapper for @Maybe DOM.Element@. It's used by 'elementClick'.
newtype ClickedElement = ClickedElement {unClickedElement :: Maybe DOM.Element}
@ -374,18 +378,13 @@ elDynAttrWithModifyConfig' f elementTag attrs child = do
pure result
-- | Formats posix seconds to date in iso8601.
formatPosixToDate :: Int -> Text
formatPosixToDate =
pack
. formatTime defaultTimeLocale (iso8601DateFormat Nothing)
. intToUTCTime
formatPosixToDate :: FormatTime t => t -> Text
formatPosixToDate = pack . formatTime defaultTimeLocale (iso8601DateFormat Nothing)
-- | Formats posix seconds to date in iso8601 with time.
formatPosixToDateTime :: Int -> Text
formatPosixToDateTime :: FormatTime t => t -> Text
formatPosixToDateTime =
pack
. formatTime defaultTimeLocale (iso8601DateFormat (Just "%H:%M:%S"))
. intToUTCTime
pack . formatTime defaultTimeLocale (iso8601DateFormat (Just "%H:%M:%S"))
-- | Widget displaying the current deployment status.
statusWidget :: MonadWidget t m => Dynamic t PreciseDeploymentStatus -> m ()
@ -487,9 +486,9 @@ errorCommonWidget =
overridesWidget ::
MonadWidget t m =>
-- | List of overrides.
Overrides ->
Overrides l ->
m ()
overridesWidget envs = divClass "listing listing--for-text" $ do
overridesWidget (Overrides (OM.assocs -> envs)) = divClass "listing listing--for-text" $ do
let visible = take 3 envs
envLength = length envs
listing visible
@ -512,10 +511,12 @@ overridesWidget envs = divClass "listing listing--for-text" $ do
blank
where
listing envs' = do
forM_ envs' $ \(Override var val _) ->
forM_ envs' $ \(var, val) ->
divClass "listing__item" $ do
el "b" $ text $ var <> ": "
text val
case val of
ValueAdded v -> text v
ValueDeleted -> el "i" $ text "<deleted>"
-- | @if-then-else@ helper for cases when bool value is wrapped in 'Dynamic'.
ifThenElseDyn ::
@ -572,3 +573,87 @@ kubeDashboardUrl deploymentInfo = do
template <- asks kubernetesDashboardUrlTemplate
let name = unDeploymentName . view (#deployment . #name) <$> deploymentInfo
return $ name <&> (\n -> (<> n) <$> template)
-- | Widget with override fields. This widget supports adding and
-- removing key-value pairs.
envVarsInput ::
forall l t m.
MonadWidget t m =>
-- | Overrides header.
Text ->
-- | Current deployment overrides.
Overrides l ->
-- | Updated deployment overrides.
m (Dynamic t (Overrides l))
envVarsInput overridesHeader (Overrides evs) = do
elClass "section" "deployment__section" $ do
elClass "h3" "deployment__sub-heading" $ text overridesHeader
elClass "div" "deployment__widget" $
elClass "div" "overrides" $ mdo
let initEnvs =
L.foldl'
( \m -> \case
(k, ValueAdded v) -> fst $ insertUniq (k, v) m
(_, ValueDeleted) -> m
)
emptyUniqKeyMap
. OM.assocs
$ evs
toOverrides :: [Override] -> Overrides l
toOverrides = Overrides . OM.fromList . (fmap . fmap) ValueAdded
emptyVar = ("", "")
addEv = clickEv $> Endo (fst . insertUniq emptyVar)
envsDyn <- foldDyn appEndo initEnvs $ leftmost [addEv, updEv]
(_, updEv) <- runEventWriterT $ listWithKey (uniqMap <$> envsDyn) envVarInput
let addingIsEnabled = all ((not . T.null) . fst) . elemsUniq <$> envsDyn
clickEv <-
buttonClassEnabled'
"overrides__add dash dash--add"
"Add an override"
addingIsEnabled
"dash--disabled"
pure $ toOverrides . elemsUniq <$> envsDyn
-- | Widget for entering a key-value pair. The updated overrides list is
-- written to the 'EventWriter'.
envVarInput ::
(EventWriter t (Endo (UniqKeyMap Override)) m, MonadWidget t m) =>
-- | Index of variable in overrides list.
Int ->
-- | Current variable key and value.
Dynamic t Override ->
m ()
envVarInput i epDyn = do
ep <- sample $ current epDyn
divClass "overrides__item" $ do
(keyDyn, _) <-
octopodTextInput' "overrides__key" "key" (Just $ fst ep) never
(valDyn, _) <-
octopodTextInput' "overrides__value" "value" (Just $ snd ep) never
closeEv <- buttonClass "overrides__delete spot spot--cancel" "Delete"
let envEv = updated $ zipDynWith (,) keyDyn valDyn
deleteEv = Endo (deleteUniq i) <$ closeEv
updEv = Endo . updateUniq i . const <$> envEv
tellEvent $ leftmost [deleteEv, updEv]
data UniqKeyMap v = UniqKeyMap (Map Int v) Int
uniqMap :: UniqKeyMap v -> Map Int v
uniqMap (UniqKeyMap m _) = m
insertUniq :: v -> UniqKeyMap v -> (UniqKeyMap v, Int)
insertUniq v (UniqKeyMap m x) = (UniqKeyMap (M.insert x v m) (x + 1), x)
deleteUniq :: Int -> UniqKeyMap v -> UniqKeyMap v
deleteUniq k (UniqKeyMap m x) = UniqKeyMap (M.delete k m) x
updateUniq :: Int -> (v -> v) -> UniqKeyMap v -> UniqKeyMap v
updateUniq k f (UniqKeyMap m x) = UniqKeyMap (M.adjust f k m) x
elemsUniq :: UniqKeyMap v -> [v]
elemsUniq (UniqKeyMap m _) = M.elems m
emptyUniqKeyMap :: UniqKeyMap v
emptyUniqKeyMap = UniqKeyMap mempty 0
type Override = (Text, Text)

View File

@ -22,6 +22,7 @@ import Frontend.Utils (errorCommonWidget, loadingCommonWidget)
import Page.Deployment
import Page.Deployments
main :: IO ()
main = mdo
mainWidgetWithHead'

View File

@ -1,82 +1,89 @@
{-|
Module : Page.Deployment
Description : Deployment page.
This module contains the definition of a deployment page.
-}
-- |
--Module : Page.Deployment
--Description : Deployment page.
--
--This module contains the definition of a deployment page.
module Page.Deployment (deploymentPage) where
import Control.Lens
import Control.Monad
import Data.Coerce
import Data.Generics.Product (field)
import Data.Text as T (Text)
import Obelisk.Route.Frontend
import Reflex.Dom as R
import Servant.Reflex
import Control.Lens
import Control.Monad
import Data.Coerce
import Data.Generics.Product (field)
import Data.Text as T (Text, pack)
import Obelisk.Route.Frontend
import Reflex.Dom as R
import Servant.Reflex
import Common.Types as CT
import Common.Utils
import Control.Monad.Reader
import Data.Align
import Data.Generics.Labels ()
import Frontend.API
import Frontend.GHCJS
import Frontend.Route
import Frontend.Utils
import Page.ClassicPopup
import Page.Elements.Links
import Page.Popup.EditDeployment
import Servant.Reflex.Extra
import Common.Types as CT
import Common.Utils
import Control.Monad.Reader
import Data.Align
import Data.Generics.Labels ()
import qualified Data.Map.Ordered.Strict as OM
import Data.Time
import Frontend.API
import Frontend.GHCJS
import Frontend.Route
import Frontend.Utils
import Page.ClassicPopup
import Page.Elements.Links
import Page.Popup.EditDeployment
import Servant.Reflex.Extra
-- | The root widget of a deployment page. It requests the deployment data.
-- If the request fails it shows an error,
-- otherwise it calls 'deploymentWidget', passing the received data.
deploymentPage
::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
, MonadReader ProjectConfig m
)
=> Event t () -- ^ Event notifying about the need to update data.
-> DeploymentName -- ^ Name of current deployment.
-> m ()
deploymentPage ::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
, MonadReader ProjectConfig m
) =>
-- | Event notifying about the need to update data.
Event t () ->
-- | Name of current deployment.
DeploymentName ->
m ()
deploymentPage updAllEv dname = do
pb <- getPostBuild
respEv <- fullInfoEndpoint (constDyn $ Right dname) pb
let (okEv, errEv) = processResp respEv
widgetHold_ (loadingWidget dname) $ leftmost
[ errorWidget dname <$ errEv
, deploymentWidget updAllEv <$> okEv ]
widgetHold_ (loadingWidget dname) $
leftmost
[ errorWidget dname <$ errEv
, deploymentWidget updAllEv <$> okEv
]
-- | Deployment page widget that takes the initial deployment data.
-- It updates this data every time when the passed event fires.
-- If an update fails, a notification widget appears at the top of the page.
deploymentWidget
::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
, MonadReader ProjectConfig m
)
=> Event t () -- ^ Event notifying about the need to update data.
-> DeploymentFullInfo -- ^ Initial deployment data.
-> m ()
deploymentWidget ::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
, MonadReader ProjectConfig m
) =>
-- | Event notifying about the need to update data.
Event t () ->
-- | Initial deployment data.
DeploymentFullInfo ->
m ()
deploymentWidget updEv dfi = mdo
editEv <- pageWrapper $ mdo
retryEv <- delay 10 errEv
respEv <- fullInfoEndpoint (constDyn $ Right $ dfi ^. dfiName)
$ leftmost [ updEv, retryEv ]
respEv <-
fullInfoEndpoint (constDyn $ Right $ dfi ^. dfiName) $
leftmost [updEv, retryEv]
let (okEv, errEv) = processResp respEv
dfiDyn <- holdDyn dfi okEv
editEv' <- deploymentHead dfiDyn sentEv
pageNotification $ leftmost
[ DPMError "Couldn't update status of deployment" <$ errEv
, DPMClear <$ okEv ]
pageNotification $
leftmost
[ DPMError "Couldn't update status of deployment" <$ errEv
, DPMClear <$ okEv
]
deploymentBody updEv dfiDyn
pure editEv'
sentEv <- editDeploymentPopup editEv never
@ -89,51 +96,62 @@ deploymentWidget updEv dfi = mdo
-- and \"edit deployment\" buttons.
-- If the status is pending (\"Creating\", \"Updating\", etc)
-- then all buttons are inactive.
deploymentHead
:: (MonadWidget t m, MonadReader ProjectConfig m)
=> Dynamic t DeploymentFullInfo
-- ^ Deployment data.
-> Event t Bool
-- ^ Event with a flag showing the current state of the request.
-> m (Event t DeploymentFullInfo)
-- ^ \"Edit\" event.
deploymentHead ::
(MonadWidget t m, MonadReader ProjectConfig m) =>
-- | Deployment data.
Dynamic t DeploymentFullInfo ->
-- | Event with a flag showing the current state of the request.
Event t Bool ->
-- | \"Edit\" event.
m (Event t DeploymentFullInfo)
deploymentHead dfiDyn sentEv =
divClass "page__head" $ do
let dname = dfiDyn <^.> dfiName . coerced
elClass "h1" "page__heading title" $ dynText dname
(editEv, archEv) <- hold2 . dyn $ dfiDyn <&> \dfi -> if isDeploymentArchived dfi
then mdo
let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status"
btnEnabledDyn <- holdDyn btnState $ leftmost [ False <$ btnEv, sentEv ]
btnEv <- aButtonClassEnabled
"page__action button button--secondary button--restore \
\classic-popup-handler"
"Recover from archive"
btnEnabledDyn
void $ restoreEndpoint (Right . coerce <$> dname) btnEv
pure (never, never)
else mdo
let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status"
btnEnabledDyn <- holdDyn btnState $ not <$> sentEv
editEv <- buttonClassEnabled'
"page__action button button--edit popup-handler"
"Edit deployment"
btnEnabledDyn
"button--disabled"
archEv <- buttonClassEnabled'
"page__action button button--secondary button--archive \
\classic-popup-handler"
"Move to archive"
btnEnabledDyn
"button--disabled"
pure (R.tag (current dfiDyn) editEv, archEv)
(editEv, archEv) <-
hold2 . dyn $
dfiDyn <&> \dfi ->
if isDeploymentArchived dfi
then mdo
let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status"
btnEnabledDyn <- holdDyn btnState $ leftmost [False <$ btnEv, sentEv]
btnEv <-
aButtonClassEnabled
"page__action button button--secondary button--restore \
\classic-popup-handler"
"Recover from archive"
btnEnabledDyn
void $ restoreEndpoint (Right . coerce <$> dname) btnEv
pure (never, never)
else mdo
let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status"
btnEnabledDyn <- holdDyn btnState $ not <$> sentEv
editEv <-
buttonClassEnabled'
"page__action button button--edit popup-handler"
"Edit deployment"
btnEnabledDyn
"button--disabled"
archEv <-
buttonClassEnabled'
"page__action button button--secondary button--archive \
\classic-popup-handler"
"Move to archive"
btnEnabledDyn
"button--disabled"
pure (R.tag (current dfiDyn) editEv, archEv)
url' <- kubeDashboardUrl dfiDyn
void . dyn $ url' <&> maybe blank (\url ->
void $ aButtonDynClass'
"page__action button button--secondary button--logs"
"Details"
(pure $ "href" =: url <> "target" =: "_blank")
)
void . dyn $
url'
<&> maybe
blank
( \url ->
void $
aButtonDynClass'
"page__action button button--secondary button--logs"
"Details"
(pure $ "href" =: url <> "target" =: "_blank")
)
delEv <- confirmArchivePopup archEv $ do
text "Are you sure you want to archive the"
el "br" blank
@ -142,10 +160,10 @@ deploymentHead dfiDyn sentEv =
void $ archiveEndpoint (Right . coerce <$> dname) delEv
return editEv
hold2
:: (MonadHold t m, Reflex t)
=> m (Event t (Event t a, Event t b))
-> m (Event t a, Event t b)
hold2 ::
(MonadHold t m, Reflex t) =>
m (Event t (Event t a, Event t b)) ->
m (Event t a, Event t b)
hold2 = (>>= fmap fanThese . switchHold never . fmap (uncurry align))
-- | Div wrappers.
@ -153,15 +171,16 @@ deploymentBodyWrapper :: MonadWidget t m => m a -> m a
deploymentBodyWrapper m = divClass "page__body" $ divClass "deployment" $ m
-- | Body of a deployment page.
deploymentBody
:: MonadWidget t m
=> Event t ()
-- ^ Event notifying about the need to update data.
-> Dynamic t DeploymentFullInfo
-- ^ Deployment data.
-> m ()
deploymentBody ::
MonadWidget t m =>
-- | Event notifying about the need to update data.
Event t () ->
-- | Deployment data.
Dynamic t DeploymentFullInfo ->
m ()
deploymentBody updEv dfiDyn = deploymentBodyWrapper $ do
let nameDyn = dfiDyn <^.> dfiName
cfg = dfiDyn <&> getDeploymentConfig
divClass "deployment__summary" $ do
divClass "deployment__stat" $ do
elClass "b" "deployment__param" $ text "Status"
@ -182,20 +201,16 @@ deploymentBody updEv dfiDyn = deploymentBodyWrapper $ do
elClass "h3" "deployment__sub-heading" $ text "Tag"
divClass "deployment__widget" $ dynText tagDyn
elClass "section" "deployment__section" $ do
let urlsDyn = dfiDyn <^.> field @"metadata"
let urlsDyn = dfiDyn <^.> field @"metadata" . to unDeploymentMetadata
elClass "h3" "deployment__sub-heading" $ text "Links"
divClass "deployment__widget" $
divClass "listing" $
void $ simpleList urlsDyn renderMetadataLink
elClass "section" "deployment__section" $ do
let
envsDyn = dfiDyn <^.> field @"deployment"
. field @"appOverrides" . coerced
let envsDyn = cfg <^.> #appConfig
allEnvsWidget "App overrides" envsDyn
elClass "section" "deployment__section" $ do
let
envsDyn = dfiDyn <^.> field @"deployment"
. field @"deploymentOverrides" . coerced
let envsDyn = cfg <^.> #depConfig
allEnvsWidget "Deployment overrides" envsDyn
elClass "section" "deployment__section" $ do
elClass "h3" "deployment__sub-heading" $ text "Actions"
@ -204,46 +219,49 @@ deploymentBody updEv dfiDyn = deploymentBodyWrapper $ do
actionsTable updEv nameDyn
-- | Widget that shows overrides list. It does not depend on their type.
allEnvsWidget
:: MonadWidget t m
=> Text -- ^ Widget header.
-> Dynamic t Overrides -- ^ Overrides list.
-> m ()
allEnvsWidget ::
MonadWidget t m =>
-- | Widget header.
Text ->
-- | Overrides list.
Dynamic t (Config l) ->
m ()
allEnvsWidget headerText envsDyn = do
elClass "h3" "deployment__sub-heading" $ text headerText
divClass "deployment__widget" $
divClass "listing listing--for-text listing--larger" $
void $ simpleList envsDyn $ \envDyn -> do
let
varDyn = overrideKey <$> envDyn
valDyn = overrideValue <$> envDyn
divClass "listing__item" $ do
el "b" $ do
dynText varDyn
text ": "
dynText valDyn
void $
simpleList (OM.assocs . unConfig <$> envsDyn) $ \envDyn -> do
let varDyn = fst <$> envDyn
valDyn = snd <$> envDyn
divClass "listing__item" $ do
el "b" $ do
dynText varDyn
text ": "
dynText valDyn
-- ^ Widget with a table of actions that can be performed on a deployment.
-- It requests deployment data.
-- If a request fails it shows an error message,
-- otherwise it calls 'actionsTableData', passing the received data.
actionsTable
:: MonadWidget t m
=> Event t ()
-- ^ Event notifying about the need to update data.
-> Dynamic t DeploymentName
-> m ()
actionsTable ::
MonadWidget t m =>
-- | Event notifying about the need to update data.
Event t () ->
Dynamic t DeploymentName ->
m ()
actionsTable updEv nameDyn = do
pb <- getPostBuild
respEv <- infoEndpoint (Right <$> nameDyn) pb
let
okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv
errEv = fmapMaybe reqErrorBody respEv
let okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv
errEv = fmapMaybe reqErrorBody respEv
el "table" $ do
actionsTableHead
widgetHold_ actionsTableLoading $ leftmost
[ actionsTableError <$ errEv
, actionsTableData updEv nameDyn <$> okEv ]
widgetHold_ actionsTableLoading $
leftmost
[ actionsTableError <$ errEv
, actionsTableData updEv nameDyn <$> okEv
]
-- | Header of the actions table.
actionsTableHead :: MonadWidget t m => m ()
@ -268,7 +286,7 @@ actionsTableLoading = do
text "Loading..."
-- | Widget with an error message for the actions table.
actionsTableError:: MonadWidget t m => m ()
actionsTableError :: MonadWidget t m => m ()
actionsTableError = do
el "tbody" $
elClass "tr" "no-table" $
@ -279,73 +297,68 @@ actionsTableError = do
-- | Actions table body.
-- It updates data every time when the supplied event fires.
actionsTableData
:: MonadWidget t m
=> Event t ()
-- ^ Event notifying about the need to update data.
-> Dynamic t DeploymentName
-> [DeploymentLog]
-- ^ Initial logs.
-> m ()
actionsTableData ::
MonadWidget t m =>
-- | Event notifying about the need to update data.
Event t () ->
Dynamic t DeploymentName ->
-- | Initial logs.
[DeploymentLog] ->
m ()
actionsTableData updEv nameDyn initLogs = do
respEv <- infoEndpoint (Right <$> nameDyn) updEv
let
okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv
let okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv
logsDyn <- holdDyn initLogs okEv
el "tbody" $
void $ simpleList logsDyn $ \logDyn -> do
dyn_ $ actinRow <$> logDyn
void $
simpleList logsDyn $ \logDyn -> do
dyn_ $ actinRow <$> logDyn
-- | Data row of the actions table.
actinRow :: MonadWidget t m => DeploymentLog -> m ()
actinRow DeploymentLog{..} = do
actinRow DeploymentLog {..} = do
el "tr" $ do
el "td" $ do
text $ coerce action
let
statusClass = "status " <>
if exitCode == 0 then "status--success" else "status--failure"
text $ actionToText action
let statusClass =
"status "
<> if exitCode == 0 then "status--success" else "status--failure"
divClass statusClass blank
el "td" $ text $ coerce deploymentTag
el "td" $ overridesWidget $ coerce $ deploymentAppOverrides
el "td" $ overridesWidget $ coerce $ deploymentDepOverrides
el "td" $ overridesWidget $ deploymentAppOverrides
el "td" $ overridesWidget $ deploymentDepOverrides
el "td" $ text $ showT $ exitCode
el "td" $ text $ formatPosixToDateTime createdAt
el "td" $ text $ formatDuration duration
-- | Formats posix seconds to date in iso8601 with time.
formatDuration :: FormatTime t => t -> Text
formatDuration = pack . formatTime defaultTimeLocale "%mm %Ss"
-- | Convert the duration of an action from milliseconds
-- to a human readable format.
formatDuration
:: Duration -- ^ Duration in milliseconds.
-> Text
formatDuration (Duration d) = m <> "m " <> s <> "s"
where
m = showT $ d `div` (1000 * 60)
s = showT $ d `div` (1000)
-- | Widget with a button that returns to deployments list page.
backButton
::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m )
=> m ()
backButton ::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
) =>
m ()
backButton = do
let
backRoute = constDyn $ DashboardRoute :/ Nothing
attrs = constDyn $ "class" =: "page__back dash dash--back dash--smaller"
let backRoute = constDyn $ DashboardRoute :/ Nothing
attrs = constDyn $ "class" =: "page__back dash dash--back dash--smaller"
routeLinkDynAttr attrs backRoute $ text "All deployments"
-- | Widget with a loading spinner.
loadingWidget
::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m)
=> DeploymentName
-> m ()
loadingWidget ::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
) =>
DeploymentName ->
m ()
loadingWidget dname = pageWrapper $ do
divClass "page__head" $
elClass "h1" "page__heading title" $ text $ coerce dname
@ -354,14 +367,14 @@ loadingWidget dname = pageWrapper $ do
loadingCommonWidget
-- | Widget with an error placeholder.
errorWidget
::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m)
=> DeploymentName
-> m ()
errorWidget ::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
) =>
DeploymentName ->
m ()
errorWidget dname = pageWrapper $ do
divClass "page__head" $
elClass "h1" "page__heading title" $ text $ coerce dname
@ -370,14 +383,15 @@ errorWidget dname = pageWrapper $ do
errorCommonWidget
-- | Div wrappers.
pageWrapper
::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m)
=> m a
-> m a
pageWrapper m = divClass "page" $ divClass "page__wrap container" $ do
backButton
m
pageWrapper ::
( MonadWidget t m
, RouteToUrl (R Routes) m
, SetRoute t (R Routes) m
, Prerender js t m
) =>
m a ->
m a
pageWrapper m = divClass "page" $
divClass "page__wrap container" $ do
backButton
m

View File

@ -292,7 +292,9 @@ activeDeploymentWidget clickedEv dDyn' = do
el "td" $ do
name
statusWidget $ constDyn status
el "td" $ divClass "listing" $ forM_ metadata (renderMetadataLink . pure)
el "td" $
divClass "listing" $
forM_ (unDeploymentMetadata metadata) (renderMetadataLink . pure)
el "td" tag'
el "td" $
overridesWidget $ deployment ^. field @"appOverrides" . coerced

View File

@ -1,23 +1,26 @@
module Page.Elements.Links
( renderMetadataLink
) where
( renderMetadataLink,
)
where
import Common.Types
import Data.Functor
import Common.Types
import Control.Lens
import qualified Data.Text as T
import Reflex.Dom
import Reflex.Dom
renderMetadataLink
:: (DomBuilder t m, PostBuild t m)
=> Dynamic t DeploymentMetadata -> m ()
renderMetadataLink ::
(DomBuilder t m, PostBuild t m) =>
Dynamic t DeploymentMetadatum ->
m ()
renderMetadataLink metadataD = do
let
attrDyn = metadataD <&> \metadata ->
"class" =: "listing__item external bar bar--larger"
<> "href" =: deploymentMetadataValue metadata
<> "target" =: "_blank"
elDynAttr "a" attrDyn . dynText $ metadataD <&> \case
-- If the name is empty, then use the url
DeploymentMetadata {deploymentMetadataKey = name}
| (not . T.null . T.strip) name -> name
DeploymentMetadata {deploymentMetadataValue = url} -> url
let attrDyn =
metadataD <&> \metadata ->
"class" =: "listing__item external bar bar--larger"
<> "href" =: metadata ^. #link
<> "target" =: "_blank"
elDynAttr "a" attrDyn . dynText $
metadataD <&> \case
-- If the name is empty, then use the url
DeploymentMetadatum {name = name}
| (not . T.null . T.strip) name -> name
DeploymentMetadatum {link = url} -> url

View File

@ -1,195 +1,128 @@
{-|
Module : Page.Popup.EditDeployment
Description : Edit deployment sidebar.
This module contains the definition of the "edit deployment" sidebar.
-}
-- |
--Module : Page.Popup.EditDeployment
--Description : Edit deployment sidebar.
--
--This module contains the definition of the "edit deployment" sidebar.
module Page.Popup.EditDeployment (editDeploymentPopup) where
import Control.Lens (coerced, preview, to, (^.), _2)
import Control.Monad
import Data.Coerce
import Data.Functor
import Data.Generics.Product
import Data.Generics.Sum
import Data.List (deleteFirstsBy)
import qualified Data.List as L
import Data.Map as M
import Data.Monoid
import Control.Lens (coerced, preview, to, (^.), _2)
import Control.Monad
import Data.Coerce
import Data.Functor
import Data.Generics.Product
import Data.Generics.Sum
import Data.Monoid
import qualified Data.Text as T
import Prelude as P
import Reflex.Dom as R
import Reflex.Dom as R hiding (mapMaybe)
import Prelude as P
import Common.Types
import Common.Utils
import Data.Text (Text)
import Frontend.API
import Frontend.Utils
import Servant.Reflex
import Servant.Reflex.Extra
import Common.Types
import Common.Utils
import Data.Text (Text)
import Frontend.API
import Frontend.Utils
import Servant.Reflex
import Servant.Reflex.Extra
-- | The root function for \"edit deployment\" sidebar.
editDeploymentPopup
:: MonadWidget t m
=> Event t DeploymentFullInfo
-- ^ \"Show\" event carrying an editable sidebar.
-> Event t ()
-- ^ \"Close\" event.
-> m (Event t Bool)
-- ^ Event with a flag showing the current state of the request.
editDeploymentPopup ::
MonadWidget t m =>
-- | \"Show\" event carrying an editable sidebar.
Event t DeploymentFullInfo ->
-- | \"Close\" event.
Event t () ->
-- | Event with a flag showing the current state of the request.
m (Event t Bool)
editDeploymentPopup showEv hideEv = sidebar showEv hideEv $ \dfi -> mdo
divClass "popup__body" $ mdo
let dname = dfi ^. dfiName
(closeEv', saveEv) <- editDeploymentPopupHeader dname enabledDyn
(deploymentDyn, validDyn) <- editDeploymentPopupBody dfi respEv
respEv <- updateEndpoint (constDyn $ Right dname)
(Right <$> deploymentDyn) saveEv
sentDyn <- holdDyn False $ leftmost
[ True <$ saveEv
, False <$ respEv ]
let
successEv =
fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv
closeEv = leftmost [ closeEv', successEv ]
enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn
respEv <-
updateEndpoint
(constDyn $ Right dname)
(Right <$> deploymentDyn)
saveEv
sentDyn <-
holdDyn False $
leftmost
[ True <$ saveEv
, False <$ respEv
]
let successEv =
fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv
closeEv = leftmost [closeEv', successEv]
enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn
pure (updated sentDyn, closeEv)
-- | The header of the sidebar contains the deployment name and control buttons:
-- \"Save\" and \"Close\".
editDeploymentPopupHeader
:: MonadWidget t m
=> DeploymentName -- ^ Name of the deployment.
-> Dynamic t Bool -- ^ Form validation state.
-> m (Event t (), Event t ()) -- ^ \"Close\" event and \"Save\" click event.
editDeploymentPopupHeader ::
MonadWidget t m =>
-- | Name of the deployment.
DeploymentName ->
-- | Form validation state.
Dynamic t Bool ->
-- | \"Close\" event and \"Save\" click event.
m (Event t (), Event t ())
editDeploymentPopupHeader dname validDyn =
divClass "popup__head" $ do
closeEv <- buttonClass "popup__close" "Close popup"
elClass "h2" "popup__project" $ text $ "Edit " <> coerce dname
saveEv <- divClass "popup__operations" $
buttonClassEnabled "popup__action button button--save" "Save" validDyn
saveEv <-
divClass "popup__operations" $
buttonClassEnabled "popup__action button button--save" "Save" validDyn
divClass "popup__menu drop drop--actions" blank
pure (closeEv, saveEv)
-- | The body of the sidebar containing the edit form. Contains a tag field and
-- an override field.
editDeploymentPopupBody
:: MonadWidget t m
=> DeploymentFullInfo
-- ^ Full deployment data.
-> Event t (ReqResult tag CommandResponse)
-- ^ \"Edit request\" failure event.
-> m (Dynamic t DeploymentUpdate, Dynamic t Bool)
-- ^ Returns deployment update and validation state.
editDeploymentPopupBody ::
MonadWidget t m =>
-- | Full deployment data.
DeploymentFullInfo ->
-- | \"Edit request\" failure event.
Event t (ReqResult tag CommandResponse) ->
-- | Returns deployment update and validation state.
m (Dynamic t DeploymentUpdate, Dynamic t Bool)
editDeploymentPopupBody dfi errEv = divClass "popup__content" $
divClass "deployment" $ mdo
let
commandResponseEv = fmapMaybe commandResponse errEv
appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv
dfiTag = dfi ^. field @"deployment" . field @"tag" . coerced . to Just
dfiAppVars = dfi ^. field @"deployment" . field @"appOverrides" . coerced
dfiDeploymentVars =
dfi ^. field @"deployment" . field @"deploymentOverrides" . coerced
tagErrEv = getTagError commandResponseEv tagDyn
let commandResponseEv = fmapMaybe commandResponse errEv
appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv
dfiTag = dfi ^. field @"deployment" . field @"tag" . coerced . to Just
dfiAppVars = dfi ^. field @"deployment" . field @"appOverrides" . coerced
dfiDeploymentVars =
dfi ^. field @"deployment" . field @"deploymentOverrides" . coerced
tagErrEv = getTagError commandResponseEv tagDyn
errorHeader appErrEv
(tagDyn, tOkEv) <- octopodTextInput "tag" "Tag" "Tag" dfiTag tagErrEv
appVarsDyn <- envVarsInput "App overrides" dfiAppVars
deploymentVarsDyn <- envVarsInput "Deployment overrides" dfiDeploymentVars
let
oldAppVarDyn = coerce <$> getOldVars dfiAppVars <$> appVarsDyn
newAppVarDyn = coerce <$> getNewVars dfiAppVars <$> appVarsDyn
oldDeploymentVarDyn = coerce <$> getOldVars dfiDeploymentVars <$> deploymentVarsDyn
newDeploymentVarDyn = coerce <$> getNewVars dfiDeploymentVars <$> deploymentVarsDyn
validDyn <- holdDyn True $ updated tOkEv
pure $ (DeploymentUpdate
<$> (DeploymentTag <$> tagDyn)
<*> newAppVarDyn
<*> oldAppVarDyn
<*> newDeploymentVarDyn
<*> oldDeploymentVarDyn, validDyn)
pure
( DeploymentUpdate
<$> (DeploymentTag <$> tagDyn)
<*> appVarsDyn
<*> deploymentVarsDyn
, validDyn
)
where
getTagError crEv tagDyn = let
tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2 )) crEv
tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv'
badTagText = "Tag should not be empty"
badNameEv = badTagText <$ (ffilter (== "") $ updated tagDyn)
in leftmost [tagErrEv, badNameEv]
getOldVars i u = deleteFirstsBy cmpKey i u
getNewVars i u = deleteFirstsBy (==) u i
cmpKey (Override k1 _ v1) (Override k2 _ v2) = k1 == k2 && v1 == v2
getTagError crEv tagDyn =
let tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2)) crEv
tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv'
badTagText = "Tag should not be empty"
badNameEv = badTagText <$ ffilter (== "") (updated tagDyn)
in leftmost [tagErrEv, badNameEv]
-- | The widget used to display errors.
errorHeader
:: MonadWidget t m
=> Event t Text -- ^ Message text.
-> m ()
errorHeader ::
MonadWidget t m =>
-- | Message text.
Event t Text ->
m ()
errorHeader appErrEv = do
widgetHold_ blank $ appErrEv <&> \appErr -> do
divClass "deployment__output notification notification--danger" $ do
el "b" $ text "App error: "
text appErr
-- | Widget with override fields. This widget supports adding and
-- removing key-value pairs.
envVarsInput
:: MonadWidget t m
=> Text -- ^ Overrides header.
-> Overrides -- ^ Current deployment overrides.
-> m (Dynamic t Overrides) -- ^ Updated deployment overrides.
envVarsInput overridesHeader evs = do
elClass "section" "deployment__section" $ do
elClass "h3" "deployment__sub-heading" $ text overridesHeader
elClass "div" "deployment__widget" $
elClass "div" "overrides" $ mdo
let
initEnvs = L.foldl' (\m v -> fst $ insertUniq v m) emptyUniqKeyMap evs
emptyVar = Override "" "" Public
addEv = clickEv $> Endo (fst . insertUniq emptyVar)
envsDyn <- foldDyn appEndo initEnvs $ leftmost [ addEv, updEv ]
(_, updEv) <- runEventWriterT $ listWithKey (uniqMap <$> envsDyn) envVarInput
let addingIsEnabled = all ( (not . T.null) . overrideKey ) . elemsUniq <$> envsDyn
clickEv <- buttonClassEnabled'
"overrides__add dash dash--add" "Add an override" addingIsEnabled
"dash--disabled"
pure $ elemsUniq <$> envsDyn
-- | Widget for entering a key-value pair. The updated overrides list is
-- written to the 'EventWriter'.
envVarInput
:: (EventWriter t (Endo (UniqKeyMap Override)) m, MonadWidget t m)
=> Int -- ^ Index of variable in overrides list.
-> Dynamic t Override -- ^ Current variable key and value.
-> m ()
envVarInput ix epDyn = do
ep <- sample $ current epDyn
divClass "overrides__item" $ do
(keyDyn, _) <-
octopodTextInput' "overrides__key" "key" (Just $ overrideKey ep) never
(valDyn, _) <-
octopodTextInput' "overrides__value" "value" (Just $ overrideValue ep) never
closeEv <- buttonClass "overrides__delete spot spot--cancel" "Delete"
let
envEv = updated $ zipDynWith (\k v -> Override k v Public) keyDyn valDyn
deleteEv = Endo (deleteUniq ix) <$ closeEv
updEv = Endo . updateUniq ix . const <$> envEv
tellEvent $ leftmost [deleteEv, updEv]
data UniqKeyMap v = UniqKeyMap (Map Int v) (Int)
uniqMap :: UniqKeyMap v -> Map Int v
uniqMap (UniqKeyMap m _) = m
insertUniq :: v -> UniqKeyMap v -> (UniqKeyMap v, Int)
insertUniq v (UniqKeyMap m x) = (UniqKeyMap (M.insert x v m) (x + 1), x)
deleteUniq :: Int -> UniqKeyMap v -> UniqKeyMap v
deleteUniq k (UniqKeyMap m x) = UniqKeyMap (M.delete k m) x
updateUniq :: Int -> (v -> v) -> UniqKeyMap v -> UniqKeyMap v
updateUniq k f (UniqKeyMap m x) = UniqKeyMap (M.adjust f k m) x
elemsUniq :: UniqKeyMap v -> [v]
elemsUniq (UniqKeyMap m _) = M.elems m
emptyUniqKeyMap :: UniqKeyMap v
emptyUniqKeyMap = UniqKeyMap mempty 0
widgetHold_ blank $
appErrEv <&> \appErr -> do
divClass "deployment__output notification notification--danger" $ do
el "b" $ text "App error: "
text appErr

View File

@ -1,157 +1,123 @@
{-|
Module : Page.Popup.NewDeployment
Description : New deployment sidebar.
This module contains the definition of \"new deployment\" sidebar.
-}
-- |
--Module : Page.Popup.NewDeployment
--Description : New deployment sidebar.
--
--This module contains the definition of \"new deployment\" sidebar.
module Page.Popup.NewDeployment (newDeploymentPopup) where
import Control.Lens (preview, _1, _2)
import Control.Monad
import Data.Coerce
import Data.Functor
import Data.Generics.Sum
import Data.Map as M
import Data.Monoid
import Data.Text as T (Text, intercalate)
import Prelude as P
import Reflex.Dom as R
import Common.Types
import Common.Validation (isNameValid)
import Frontend.API
import Frontend.Utils
import Servant.Reflex
import Servant.Reflex.Extra
import Control.Lens (preview, _1, _2)
import Control.Monad
import Data.Functor
import Data.Generics.Sum
import Data.Monoid
import Data.Text as T (Text, intercalate)
import Reflex.Dom as R
import Prelude as P
import Common.Types
import Common.Validation (isNameValid)
import Frontend.API
import Frontend.Utils
import Servant.Reflex
import Servant.Reflex.Extra
-- | The root function for \"new deployment\" sidebar.
newDeploymentPopup
:: MonadWidget t m
=> Event t () -- ^ \"Show\" event.
-> Event t () -- ^ \"Close\" event.
-> m ()
newDeploymentPopup showEv hideEv = void $ sidebar showEv hideEv $ const $ mdo
divClass "popup__body" $ mdo
(closeEv', saveEv) <- newDeploymentPopupHeader enabledDyn
(deploymentDyn, validDyn) <- newDeploymentPopupBody respEv
respEv <- createEndpoint (Right <$> deploymentDyn) saveEv
sentDyn <- holdDyn False $ leftmost
[ True <$ saveEv
, False <$ respEv ]
let
successEv =
fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv
closeEv = leftmost [ closeEv', successEv ]
enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn
pure (never, closeEv)
newDeploymentPopup ::
MonadWidget t m =>
-- | \"Show\" event.
Event t () ->
-- | \"Close\" event.
Event t () ->
m ()
newDeploymentPopup showEv hideEv = void $
sidebar showEv hideEv $
const $ mdo
divClass "popup__body" $ mdo
(closeEv', saveEv) <- newDeploymentPopupHeader enabledDyn
(deploymentDyn, validDyn) <- newDeploymentPopupBody respEv
respEv <- createEndpoint (Right <$> deploymentDyn) saveEv
sentDyn <-
holdDyn False $
leftmost
[ True <$ saveEv
, False <$ respEv
]
let successEv =
fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv
closeEv = leftmost [closeEv', successEv]
enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn
pure (never, closeEv)
-- | The header of sidebar contains control buttons: \"Save\" and \"Close\".
newDeploymentPopupHeader
:: MonadWidget t m
=> Dynamic t Bool
-> m (Event t (), Event t ())
newDeploymentPopupHeader ::
MonadWidget t m =>
Dynamic t Bool ->
m (Event t (), Event t ())
newDeploymentPopupHeader enabledDyn =
divClass "popup__head" $ do
closeEv <- buttonClass "popup__close" "Close popup"
elClass "h2" "popup__project" $ text "Create new deployment"
saveEv <- divClass "popup__operations" $
buttonClassEnabled "popup__action button button--save" "Save" enabledDyn
saveEv <-
divClass "popup__operations" $
buttonClassEnabled "popup__action button button--save" "Save" enabledDyn
divClass "popup__menu drop drop--actions" blank
pure (closeEv, saveEv)
-- | The body of the sidebar contains the creation form. It contains: a name field,
-- a tag field and overrides fields. The name field is validated with the regexp:
-- @^[a-z][a-z0-9\\-]{1,16}$@.
newDeploymentPopupBody
:: MonadWidget t m
=> Event t (ReqResult tag CommandResponse)
-- ^ Request failure event.
-> m (Dynamic t Deployment, Dynamic t Bool)
-- ^ Returns new deployment and validation states.
newDeploymentPopupBody ::
MonadWidget t m =>
-- | Request failure event.
Event t (ReqResult tag CommandResponse) ->
-- | Returns new deployment and validation states.
m (Dynamic t Deployment, Dynamic t Bool)
newDeploymentPopupBody errEv = divClass "popup__content" $
divClass "deployment" $ mdo
let
commandResponseEv = fmapMaybe commandResponse errEv
appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv
nameErrEv = getNameError commandResponseEv nameDyn
tagErrEv = getTagError commandResponseEv tagDyn
let commandResponseEv = fmapMaybe commandResponse errEv
appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv
nameErrEv = getNameError commandResponseEv nameDyn
tagErrEv = getTagError commandResponseEv tagDyn
errorHeader appErrEv
(nameDyn, nOkDyn) <- octopodTextInput "tag" "Name" "Name" Nothing nameErrEv
(tagDyn, tOkDyn) <- octopodTextInput "tag" "Tag" "Tag" Nothing tagErrEv
appVarsDyn <- envVarsInput "App overrides"
deploymentVarsDyn <- envVarsInput "Deployment overrides"
appVarsDyn <- envVarsInput "App overrides" mempty
deploymentVarsDyn <- envVarsInput "Deployment overrides" mempty
validDyn <- holdDyn False $ updated $ zipDynWith (&&) nOkDyn tOkDyn
pure $ (Deployment
<$> (DeploymentName <$> nameDyn)
<*> (DeploymentTag <$> tagDyn)
<*> (coerce <$> appVarsDyn)
<*> (coerce <$> deploymentVarsDyn), validDyn)
pure
( Deployment
<$> (DeploymentName <$> nameDyn)
<*> (DeploymentTag <$> tagDyn)
<*> appVarsDyn
<*> deploymentVarsDyn
, validDyn
)
where
getNameError crEv nameDyn = let
nameErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _1 )) crEv
isNameValidDyn = isNameValid . DeploymentName <$> nameDyn
badNameText = "Deployment name length should be longer than 2 characters \
\and under 17 characters and begin with a letter."
badNameEv = badNameText <$ (ffilter not $ updated isNameValidDyn)
nameErrEv = ffilter (/= "") $ T.intercalate ". " <$> nameErrEv'
in leftmost [nameErrEv, badNameEv]
getTagError crEv tagDyn = let
tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2 )) crEv
tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv'
badTagText = "Tag should not be empty"
badNameEv = badTagText <$ (ffilter (== "") $ updated tagDyn)
in leftmost [tagErrEv, badNameEv]
getNameError crEv nameDyn =
let nameErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _1)) crEv
isNameValidDyn = isNameValid . DeploymentName <$> nameDyn
badNameText =
"Deployment name length should be longer than 2 characters \
\and under 17 characters and begin with a letter."
badNameEv = badNameText <$ (ffilter not $ updated isNameValidDyn)
nameErrEv = ffilter (/= "") $ T.intercalate ". " <$> nameErrEv'
in leftmost [nameErrEv, badNameEv]
getTagError crEv tagDyn =
let tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2)) crEv
tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv'
badTagText = "Tag should not be empty"
badNameEv = badTagText <$ (ffilter (== "") $ updated tagDyn)
in leftmost [tagErrEv, badNameEv]
-- | The widget used to display errors.
errorHeader
:: MonadWidget t m
=> Event t Text -- ^ Message text.
-> m ()
errorHeader ::
MonadWidget t m =>
-- | Message text.
Event t Text ->
m ()
errorHeader appErrEv = do
widgetHold_ blank $ appErrEv <&> \appErr -> do
divClass "deployment__output notification notification--danger" $ do
el "b" $ text "App error: "
text appErr
-- | Widget with override fields. This widget supports adding and
-- a removing key-value pairs.
envVarsInput
:: MonadWidget t m
=> Text -- ^ Widget header.
-> m (Dynamic t [Override])
envVarsInput headerText = do
elClass "section" "deployment__section" $ do
elClass "h3" "deployment__sub-heading" $ text headerText
elClass "div" "deployment__widget" $
elClass "div" "overrides" $ mdo
let
emptyVar = Override "" "" Public
addEv = clickEv $> Endo (\envs -> P.length envs =: emptyVar <> envs)
envsDyn <- foldDyn appEndo mempty $ leftmost [ addEv, updEv ]
(_, updEv) <- runEventWriterT $ listWithKey envsDyn envVarInput
let addDisabledDyn = all ( (/= "") . overrideKey ) . elems <$> envsDyn
clickEv <- buttonClassEnabled'
"overrides__add dash dash--add" "Add an override" addDisabledDyn
"dash--disabled"
pure $ elems <$> envsDyn
-- | Widget for a key-value pair. It returns an event carrying an update
-- of overrides list via 'EventWriter'.
envVarInput
:: (EventWriter t (Endo (Map Int Override)) m, MonadWidget t m)
=> Int -- ^ Index of variable in overrides list.
-> Dynamic t Override -- ^ Current variable key and value.
-> m ()
envVarInput ix _ = do
divClass "overrides__item" $ do
(keyDyn, _) <- octopodTextInput' "overrides__key" "key" Nothing never
(valDyn, _) <- octopodTextInput' "overrides__value" "value" Nothing never
closeEv <- buttonClass "overrides__delete spot spot--cancel" "Delete"
let
envEv = updated $ zipDynWith (\k v -> Override k v Public) keyDyn valDyn
deleteEv = Endo (M.delete ix) <$ closeEv
updEv = Endo . flip update ix . const . Just <$> envEv
tellEvent $ leftmost [deleteEv, updEv]
widgetHold_ blank $
appErrEv <&> \appErr -> do
divClass "deployment__output notification notification--danger" $ do
el "b" $ text "App error: "
text appErr