Merge branch 'master' of gitlab.cip.ifi.lmu.de:jost/UniWorX
This commit is contained in:
commit
5bf7c42a66
@ -30,6 +30,9 @@ session-timeout: 7200
|
|||||||
jwt-expiration: 604800
|
jwt-expiration: 604800
|
||||||
jwt-encoding: HS256
|
jwt-encoding: HS256
|
||||||
maximum-content-length: 52428800
|
maximum-content-length: 52428800
|
||||||
|
health-check-interval: "_env:HEALTHCHECK_INTERVAL:600" # or WATCHDOG_USEC/2, whichever is smaller
|
||||||
|
health-check-http: "_env:HEALTHCHECK_HTTP:true"
|
||||||
|
health-check-delay-notify: "_env:HEALTHCHECK_DELAY_NOTIFY:true"
|
||||||
|
|
||||||
log-settings:
|
log-settings:
|
||||||
detailed: "_env:DETAILED_LOGGING:false"
|
detailed: "_env:DETAILED_LOGGING:false"
|
||||||
|
|||||||
@ -702,6 +702,8 @@ MenuInformation: Informationen
|
|||||||
MenuImpressum: Impressum
|
MenuImpressum: Impressum
|
||||||
MenuDataProt: Datenschutz
|
MenuDataProt: Datenschutz
|
||||||
MenuVersion: Versionsgeschichte
|
MenuVersion: Versionsgeschichte
|
||||||
|
MenuInstance: Instanz-Identifikation
|
||||||
|
MenuHealth: Instanz-Zustand
|
||||||
MenuHelp: Hilfe
|
MenuHelp: Hilfe
|
||||||
MenuProfile: Anpassen
|
MenuProfile: Anpassen
|
||||||
MenuLogin: Login
|
MenuLogin: Login
|
||||||
@ -877,4 +879,16 @@ TutorialCreated tutn@TutorialName: Tutorium #{tutn} erfolgreich angelegt
|
|||||||
|
|
||||||
TutorialEditHeading tutn@TutorialName: #{tutn} bearbeiten
|
TutorialEditHeading tutn@TutorialName: #{tutn} bearbeiten
|
||||||
|
|
||||||
MassInputTip: Es können mehrere Werte angegeben werden. Werte müssen mit + zur Liste hinzugefügt werden und können mit - wieder entfernt werden. Die Liste wird zunächst nur lokal in Ihrem Browser gespeichert und muss noch zusammen mit dem Rest des Formulars Gesendet werden.
|
MassInputTip: Es können mehrere Werte angegeben werden. Werte müssen mit + zur Liste hinzugefügt werden und können mit - wieder entfernt werden. Die Liste wird zunächst nur lokal in Ihrem Browser gespeichert und muss noch zusammen mit dem Rest des Formulars Gesendet werden.
|
||||||
|
|
||||||
|
HealthReport: Instanz-Zustand
|
||||||
|
InstanceIdentification: Instanz-Identifikation
|
||||||
|
|
||||||
|
InstanceId: Instanz-Nummer
|
||||||
|
ClusterId: Cluster-Nummer
|
||||||
|
|
||||||
|
HealthMatchingClusterConfig: Cluster-geteilte Konfiguration ist aktuell
|
||||||
|
HealthHTTPReachable: Cluster kann an der erwarteten URL über HTTP erreicht werden
|
||||||
|
HealthLDAPAdmins: Anteil der Administratoren, die im LDAP-Verzeichnis gefunden werden können
|
||||||
|
HealthSMTPConnect: SMTP-Server kann erreicht werden
|
||||||
|
HealthWidgetMemcached: Memcached-Server liefert Widgets korrekt aus
|
||||||
@ -125,6 +125,7 @@ dependencies:
|
|||||||
- lifted-async
|
- lifted-async
|
||||||
- streaming-commons
|
- streaming-commons
|
||||||
- hourglass
|
- hourglass
|
||||||
|
- unix
|
||||||
|
|
||||||
other-extensions:
|
other-extensions:
|
||||||
- GeneralizedNewtypeDeriving
|
- GeneralizedNewtypeDeriving
|
||||||
|
|||||||
2
routes
2
routes
@ -50,6 +50,8 @@
|
|||||||
/admin/test AdminTestR GET POST
|
/admin/test AdminTestR GET POST
|
||||||
/admin/errMsg AdminErrMsgR GET POST
|
/admin/errMsg AdminErrMsgR GET POST
|
||||||
|
|
||||||
|
/health HealthR GET !free
|
||||||
|
/instance InstanceR GET !free
|
||||||
/info InfoR GET !free
|
/info InfoR GET !free
|
||||||
/info/lecturer InfoLecturerR GET !lecturer
|
/info/lecturer InfoLecturerR GET !lecturer
|
||||||
/info/data DataProtR GET !free
|
/info/data DataProtR GET !free
|
||||||
|
|||||||
@ -64,7 +64,7 @@ import qualified Yesod.Core.Types as Yesod (Logger(..))
|
|||||||
|
|
||||||
import qualified Data.HashMap.Strict as HashMap
|
import qualified Data.HashMap.Strict as HashMap
|
||||||
|
|
||||||
import Control.Lens
|
import Utils.Lens
|
||||||
|
|
||||||
import Data.Proxy
|
import Data.Proxy
|
||||||
|
|
||||||
@ -76,6 +76,10 @@ import qualified Database.Memcached.Binary.IO as Memcached
|
|||||||
|
|
||||||
import qualified System.Systemd.Daemon as Systemd
|
import qualified System.Systemd.Daemon as Systemd
|
||||||
import Control.Concurrent.Async.Lifted.Safe (async, waitAnyCancel)
|
import Control.Concurrent.Async.Lifted.Safe (async, waitAnyCancel)
|
||||||
|
import System.Environment (lookupEnv)
|
||||||
|
import System.Posix.Process (getProcessID)
|
||||||
|
|
||||||
|
import Control.Monad.Trans.State (execStateT)
|
||||||
|
|
||||||
-- Import all relevant handler modules here.
|
-- Import all relevant handler modules here.
|
||||||
-- (HPack takes care to add new modules to our cabal file nowadays.)
|
-- (HPack takes care to add new modules to our cabal file nowadays.)
|
||||||
@ -95,6 +99,7 @@ import Handler.Tutorial
|
|||||||
import Handler.Corrections
|
import Handler.Corrections
|
||||||
import Handler.CryptoIDDispatch
|
import Handler.CryptoIDDispatch
|
||||||
import Handler.SystemMessage
|
import Handler.SystemMessage
|
||||||
|
import Handler.Health
|
||||||
|
|
||||||
|
|
||||||
-- This line actually creates our YesodDispatch instance. It is the second half
|
-- This line actually creates our YesodDispatch instance. It is the second half
|
||||||
@ -141,13 +146,14 @@ makeFoundation appSettings'@AppSettings{..} = do
|
|||||||
|
|
||||||
appJobCtl <- liftIO $ newTVarIO Map.empty
|
appJobCtl <- liftIO $ newTVarIO Map.empty
|
||||||
appCronThread <- liftIO newEmptyTMVarIO
|
appCronThread <- liftIO newEmptyTMVarIO
|
||||||
|
appHealthReport <- liftIO $ newTVarIO Nothing
|
||||||
|
|
||||||
-- We need a log function to create a connection pool. We need a connection
|
-- We need a log function to create a connection pool. We need a connection
|
||||||
-- pool to create our foundation. And we need our foundation to get a
|
-- pool to create our foundation. And we need our foundation to get a
|
||||||
-- logging function. To get out of this loop, we initially create a
|
-- logging function. To get out of this loop, we initially create a
|
||||||
-- temporary foundation without a real connection pool, get a log function
|
-- temporary foundation without a real connection pool, get a log function
|
||||||
-- from there, and then create the real foundation.
|
-- from there, and then create the real foundation.
|
||||||
let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet = UniWorX {..}
|
let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID = UniWorX {..}
|
||||||
-- The UniWorX {..} syntax is an example of record wild cards. For more
|
-- The UniWorX {..} syntax is an example of record wild cards. For more
|
||||||
-- information, see:
|
-- information, see:
|
||||||
-- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html
|
-- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html
|
||||||
@ -160,6 +166,7 @@ makeFoundation appSettings'@AppSettings{..} = do
|
|||||||
(error "secretBoxKey forced in tempFoundation")
|
(error "secretBoxKey forced in tempFoundation")
|
||||||
(error "widgetMemcached forced in tempFoundation")
|
(error "widgetMemcached forced in tempFoundation")
|
||||||
(error "JSONWebKeySet forced in tempFoundation")
|
(error "JSONWebKeySet forced in tempFoundation")
|
||||||
|
(error "ClusterID forced in tempFoundation")
|
||||||
|
|
||||||
runAppLoggingT tempFoundation $ do
|
runAppLoggingT tempFoundation $ do
|
||||||
$logInfoS "InstanceID" $ UUID.toText appInstanceID
|
$logInfoS "InstanceID" $ UUID.toText appInstanceID
|
||||||
@ -191,8 +198,9 @@ makeFoundation appSettings'@AppSettings{..} = do
|
|||||||
appSessionKey <- clusterSetting (Proxy :: Proxy 'ClusterClientSessionKey) `runSqlPool` sqlPool
|
appSessionKey <- clusterSetting (Proxy :: Proxy 'ClusterClientSessionKey) `runSqlPool` sqlPool
|
||||||
appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool
|
appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool
|
||||||
appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `runSqlPool` sqlPool
|
appJSONWebKeySet <- clusterSetting (Proxy :: Proxy 'ClusterJSONWebKeySet) `runSqlPool` sqlPool
|
||||||
|
appClusterID <- clusterSetting (Proxy :: Proxy 'ClusterId) `runSqlPool` sqlPool
|
||||||
|
|
||||||
let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet
|
let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached appJSONWebKeySet appClusterID
|
||||||
|
|
||||||
$logDebugS "setup" "Job-Handling"
|
$logDebugS "setup" "Job-Handling"
|
||||||
handleJobs foundation
|
handleJobs foundation
|
||||||
@ -311,8 +319,16 @@ makeLogWare app = do
|
|||||||
warpSettings :: UniWorX -> Settings
|
warpSettings :: UniWorX -> Settings
|
||||||
warpSettings foundation = defaultSettings
|
warpSettings foundation = defaultSettings
|
||||||
& setBeforeMainLoop (runAppLoggingT foundation $ do
|
& setBeforeMainLoop (runAppLoggingT foundation $ do
|
||||||
$logInfoS "setup" "Ready"
|
let notifyReady = do
|
||||||
void $ liftIO Systemd.notifyReady
|
$logInfoS "setup" "Ready"
|
||||||
|
void $ liftIO Systemd.notifyReady
|
||||||
|
if
|
||||||
|
| foundation ^. _appHealthCheckDelayNotify
|
||||||
|
-> void . fork $ do
|
||||||
|
atomically $ readTVar (foundation ^. _appHealthReport) >>= guard . maybe False ((== HealthSuccess) . classifyHealthReport . snd)
|
||||||
|
notifyReady
|
||||||
|
| otherwise
|
||||||
|
-> notifyReady
|
||||||
)
|
)
|
||||||
& setHost (foundation ^. _appHost)
|
& setHost (foundation ^. _appHost)
|
||||||
& setPort (foundation ^. _appPort)
|
& setPort (foundation ^. _appPort)
|
||||||
@ -336,8 +352,20 @@ getApplicationDev = do
|
|||||||
app <- makeApplication foundation
|
app <- makeApplication foundation
|
||||||
return (wsettings, app)
|
return (wsettings, app)
|
||||||
|
|
||||||
getAppDevSettings :: MonadIO m => m AppSettings
|
getAppDevSettings, getAppSettings :: MonadIO m => m AppSettings
|
||||||
getAppDevSettings = liftIO $ loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv
|
getAppDevSettings = liftIO $ adjustSettings =<< loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv
|
||||||
|
getAppSettings = liftIO $ adjustSettings =<< loadYamlSettingsArgs [configSettingsYmlValue] useEnv
|
||||||
|
|
||||||
|
adjustSettings :: MonadIO m => AppSettings -> m AppSettings
|
||||||
|
adjustSettings = execStateT $ do
|
||||||
|
watchdogMicroSec <- liftIO $ (>>= readMay) <$> lookupEnv "WATCHDOG_USEC"
|
||||||
|
watchdogProcess <- liftIO $ (>>= fmap fromInteger . readMay) <$> lookupEnv "WATCHDOG_PID"
|
||||||
|
myProcessID <- liftIO getProcessID
|
||||||
|
case watchdogMicroSec of
|
||||||
|
Just wInterval
|
||||||
|
| maybe True (== myProcessID) watchdogProcess
|
||||||
|
-> _appHealthCheckInterval %= min (fromRational $ (toRational wInterval / 1e6) / 2)
|
||||||
|
_other -> return ()
|
||||||
|
|
||||||
-- | main function for use by yesod devel
|
-- | main function for use by yesod devel
|
||||||
develMain :: IO ()
|
develMain :: IO ()
|
||||||
@ -347,14 +375,7 @@ develMain = runResourceT $
|
|||||||
-- | The @main@ function for an executable running this site.
|
-- | The @main@ function for an executable running this site.
|
||||||
appMain :: MonadResourceBase m => m ()
|
appMain :: MonadResourceBase m => m ()
|
||||||
appMain = runResourceT $ do
|
appMain = runResourceT $ do
|
||||||
-- Get the settings from all relevant sources
|
settings <- getAppSettings
|
||||||
settings <- liftIO $
|
|
||||||
loadYamlSettingsArgs
|
|
||||||
-- fall back to compile-time values, set to [] to require values at runtime
|
|
||||||
[configSettingsYmlValue]
|
|
||||||
|
|
||||||
-- allow environment variables to override
|
|
||||||
useEnv
|
|
||||||
|
|
||||||
-- Generate the foundation from the settings
|
-- Generate the foundation from the settings
|
||||||
foundation <- makeFoundation settings
|
foundation <- makeFoundation settings
|
||||||
|
|||||||
@ -118,12 +118,14 @@ data UniWorX = UniWorX
|
|||||||
, appLogger :: (ReleaseKey, TVar Logger)
|
, appLogger :: (ReleaseKey, TVar Logger)
|
||||||
, appLogSettings :: TVar LogSettings
|
, appLogSettings :: TVar LogSettings
|
||||||
, appCryptoIDKey :: CryptoIDKey
|
, appCryptoIDKey :: CryptoIDKey
|
||||||
|
, appClusterID :: ClusterId
|
||||||
, appInstanceID :: InstanceId
|
, appInstanceID :: InstanceId
|
||||||
, appJobCtl :: TVar (Map ThreadId (TMChan JobCtl))
|
, appJobCtl :: TVar (Map ThreadId (TMChan JobCtl))
|
||||||
, appCronThread :: TMVar (ReleaseKey, ThreadId)
|
, appCronThread :: TMVar (ReleaseKey, ThreadId)
|
||||||
, appSessionKey :: ClientSession.Key
|
, appSessionKey :: ClientSession.Key
|
||||||
, appSecretBoxKey :: SecretBox.Key
|
, appSecretBoxKey :: SecretBox.Key
|
||||||
, appJSONWebKeySet :: Jose.JwkSet
|
, appJSONWebKeySet :: Jose.JwkSet
|
||||||
|
, appHealthReport :: TVar (Maybe (UTCTime, HealthReport))
|
||||||
}
|
}
|
||||||
|
|
||||||
makeLenses_ ''UniWorX
|
makeLenses_ ''UniWorX
|
||||||
@ -1160,6 +1162,8 @@ siteLayout' headingOverride widget = do
|
|||||||
|
|
||||||
isModal <- hasCustomHeader HeaderIsModal
|
isModal <- hasCustomHeader HeaderIsModal
|
||||||
|
|
||||||
|
primaryLanguage <- unsafeHead . Text.splitOn "-" <$> selectLanguage appLanguages
|
||||||
|
|
||||||
mcurrentRoute <- getCurrentRoute
|
mcurrentRoute <- getCurrentRoute
|
||||||
|
|
||||||
-- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance.
|
-- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance.
|
||||||
@ -1346,6 +1350,10 @@ instance YesodBreadcrumbs UniWorX where
|
|||||||
breadcrumb HelpR = return ("Hilfe" , Just HomeR)
|
breadcrumb HelpR = return ("Hilfe" , Just HomeR)
|
||||||
|
|
||||||
|
|
||||||
|
breadcrumb HealthR = return ("Status" , Nothing)
|
||||||
|
breadcrumb InstanceR = return ("Identifikation", Nothing)
|
||||||
|
|
||||||
|
|
||||||
breadcrumb ProfileR = return ("User" , Just HomeR)
|
breadcrumb ProfileR = return ("User" , Just HomeR)
|
||||||
breadcrumb ProfileDataR = return ("Profile" , Just ProfileR)
|
breadcrumb ProfileDataR = return ("Profile" , Just ProfileR)
|
||||||
breadcrumb AuthPredsR = return ("Authentifizierung", Just ProfileR)
|
breadcrumb AuthPredsR = return ("Authentifizierung", Just ProfileR)
|
||||||
@ -1649,6 +1657,26 @@ pageActions (VersionR) = [
|
|||||||
, menuItemAccessCallback' = return True
|
, menuItemAccessCallback' = return True
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
pageActions HealthR = [
|
||||||
|
MenuItem
|
||||||
|
{ menuItemType = PageActionPrime
|
||||||
|
, menuItemLabel = MsgMenuInstance
|
||||||
|
, menuItemIcon = Nothing
|
||||||
|
, menuItemRoute = SomeRoute InstanceR
|
||||||
|
, menuItemModal = False
|
||||||
|
, menuItemAccessCallback' = return True
|
||||||
|
}
|
||||||
|
]
|
||||||
|
pageActions InstanceR = [
|
||||||
|
MenuItem
|
||||||
|
{ menuItemType = PageActionPrime
|
||||||
|
, menuItemLabel = MsgMenuHealth
|
||||||
|
, menuItemIcon = Nothing
|
||||||
|
, menuItemRoute = SomeRoute HealthR
|
||||||
|
, menuItemModal = False
|
||||||
|
, menuItemAccessCallback' = return True
|
||||||
|
}
|
||||||
|
]
|
||||||
pageActions (HelpR) = [
|
pageActions (HelpR) = [
|
||||||
-- MenuItem
|
-- MenuItem
|
||||||
-- { menuItemType = PageActionPrime
|
-- { menuItemType = PageActionPrime
|
||||||
|
|||||||
@ -8,10 +8,19 @@ import Import hiding (embedFile)
|
|||||||
-- runtime dependency, and for efficiency.
|
-- runtime dependency, and for efficiency.
|
||||||
|
|
||||||
getFaviconR :: Handler TypedContent
|
getFaviconR :: Handler TypedContent
|
||||||
getFaviconR = do cacheSeconds $ 60 * 60 * 24 * 30 -- cache for a month
|
getFaviconR = do
|
||||||
return $ TypedContent "image/x-icon"
|
let content = $(embedFile "static/favicon.ico")
|
||||||
$ toContent $(embedFile "static/favicon.ico")
|
|
||||||
|
setEtagHashable content
|
||||||
|
|
||||||
|
return $ TypedContent "image/x-icon"
|
||||||
|
$ toContent content
|
||||||
|
|
||||||
getRobotsR :: Handler TypedContent
|
getRobotsR :: Handler TypedContent
|
||||||
getRobotsR = return $ TypedContent typePlain
|
getRobotsR = do
|
||||||
$ toContent $(embedFile "static/robots.txt")
|
let content = $(embedFile "static/robots.txt")
|
||||||
|
|
||||||
|
setEtagHashable content
|
||||||
|
|
||||||
|
return $ TypedContent typePlain
|
||||||
|
$ toContent content
|
||||||
|
|||||||
81
src/Handler/Health.hs
Normal file
81
src/Handler/Health.hs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
module Handler.Health where
|
||||||
|
|
||||||
|
import Import
|
||||||
|
|
||||||
|
import qualified Data.Aeson.Encode.Pretty as Aeson
|
||||||
|
import qualified Data.Text.Lazy.Builder as Builder
|
||||||
|
|
||||||
|
import Utils.Lens
|
||||||
|
|
||||||
|
import qualified Data.UUID as UUID
|
||||||
|
|
||||||
|
|
||||||
|
getHealthR :: Handler TypedContent
|
||||||
|
getHealthR = do
|
||||||
|
healthReport' <- liftIO . readTVarIO =<< getsYesod appHealthReport
|
||||||
|
let
|
||||||
|
handleMissing = do
|
||||||
|
interval <- getsYesod $ round . (* 1e6) . toRational . view _appHealthCheckInterval
|
||||||
|
reportStore <- getsYesod appHealthReport
|
||||||
|
waitResult <- threadDelay interval `race` atomically (readTVar reportStore >>= guard . is _Just)
|
||||||
|
case waitResult of
|
||||||
|
Left () -> fail "System is not generating HealthReports"
|
||||||
|
Right _ -> redirect HealthR
|
||||||
|
(lastUpdated, healthReport) <- maybe handleMissing return healthReport'
|
||||||
|
interval <- getsYesod $ view _appHealthCheckInterval
|
||||||
|
instanceId <- getsYesod appInstanceID
|
||||||
|
|
||||||
|
setWeakEtagHashable (instanceId, lastUpdated)
|
||||||
|
expiresAt $ interval `addUTCTime` lastUpdated
|
||||||
|
setLastModified lastUpdated
|
||||||
|
|
||||||
|
let status
|
||||||
|
| HealthSuccess <- classifyHealthReport healthReport
|
||||||
|
= ok200
|
||||||
|
| otherwise
|
||||||
|
= internalServerError500
|
||||||
|
sendResponseStatus status <=< selectRep $ do
|
||||||
|
provideRep . siteLayoutMsg MsgHealthReport $ do
|
||||||
|
setTitleI MsgHealthReport
|
||||||
|
let HealthReport{..} = healthReport
|
||||||
|
[whamlet|
|
||||||
|
$newline never
|
||||||
|
<dl .deflist>
|
||||||
|
<dt .deflist__dt>_{MsgHealthMatchingClusterConfig}
|
||||||
|
<dd .deflist__dd>#{boolSymbol healthMatchingClusterConfig}
|
||||||
|
$maybe httpReachable <- healthHTTPReachable
|
||||||
|
<dt .deflist__dt>_{MsgHealthHTTPReachable}
|
||||||
|
<dd .deflist__dd>#{boolSymbol httpReachable}
|
||||||
|
$maybe ldapAdmins <- healthLDAPAdmins
|
||||||
|
<dt .deflist__dt>_{MsgHealthLDAPAdmins}
|
||||||
|
<dd .deflist__dd>#{textPercent ldapAdmins}
|
||||||
|
$maybe smtpConnect <- healthSMTPConnect
|
||||||
|
<dt .deflist__dt>_{MsgHealthSMTPConnect}
|
||||||
|
<dd .deflist__dd>#{boolSymbol smtpConnect}
|
||||||
|
$maybe widgetMemcached <- healthWidgetMemcached
|
||||||
|
<dt .deflist__dt>_{MsgHealthWidgetMemcached}
|
||||||
|
<dd .deflist__dd>#{boolSymbol widgetMemcached}
|
||||||
|
|]
|
||||||
|
provideJson healthReport
|
||||||
|
provideRep . return . Builder.toLazyText $ Aeson.encodePrettyToTextBuilder healthReport
|
||||||
|
|
||||||
|
getInstanceR :: Handler TypedContent
|
||||||
|
getInstanceR = do
|
||||||
|
instanceInfo@(clusterId, instanceId) <- getsYesod $ (,) <$> appClusterID <*> appInstanceID
|
||||||
|
|
||||||
|
setWeakEtagHashable (clusterId, instanceId)
|
||||||
|
|
||||||
|
selectRep $ do
|
||||||
|
provideRep $
|
||||||
|
siteLayoutMsg MsgInstanceIdentification $ do
|
||||||
|
setTitleI MsgInstanceIdentification
|
||||||
|
[whamlet|
|
||||||
|
$newline never
|
||||||
|
<dl .deflist>
|
||||||
|
<dt .deflist__dt>_{MsgClusterId}
|
||||||
|
<dd .deflist__dd style="font-family: monospace">#{UUID.toText clusterId}
|
||||||
|
<dt .deflist__dt>_{MsgInstanceId}
|
||||||
|
<dd .deflist__dd style="font-family: monospace">#{UUID.toText instanceId}
|
||||||
|
|]
|
||||||
|
provideJson instanceInfo
|
||||||
|
provideRep . return $ tshow instanceInfo
|
||||||
@ -37,7 +37,6 @@ import Control.Monad.Trans.Except (throwE, runExceptT)
|
|||||||
import Control.Monad.Writer.Class
|
import Control.Monad.Writer.Class
|
||||||
|
|
||||||
import Data.Scientific (Scientific)
|
import Data.Scientific (Scientific)
|
||||||
import Data.Ratio
|
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
import Data.Either (partitionEithers)
|
import Data.Either (partitionEithers)
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@ import Data.List.NonEmpty.Instances as Import ()
|
|||||||
import Data.NonNull.Instances as Import ()
|
import Data.NonNull.Instances as Import ()
|
||||||
import Data.Text.Encoding.Error as Import(UnicodeException(..))
|
import Data.Text.Encoding.Error as Import(UnicodeException(..))
|
||||||
import Data.Semigroup as Import (Semigroup)
|
import Data.Semigroup as Import (Semigroup)
|
||||||
import Data.Monoid as Import (Last(..), First(..), Any(..), All(..))
|
import Data.Monoid as Import (Last(..), First(..), Any(..), All(..), Sum(..))
|
||||||
import Data.Monoid.Instances as Import ()
|
import Data.Monoid.Instances as Import ()
|
||||||
import Data.Set.Instances as Import ()
|
import Data.Set.Instances as Import ()
|
||||||
import Data.HashMap.Strict.Instances as Import ()
|
import Data.HashMap.Strict.Instances as Import ()
|
||||||
@ -95,6 +95,8 @@ import Time.Types.Instances as Import ()
|
|||||||
|
|
||||||
import Data.CaseInsensitive as Import (CI, FoldCase(..), foldedCase)
|
import Data.CaseInsensitive as Import (CI, FoldCase(..), foldedCase)
|
||||||
|
|
||||||
|
import Data.Ratio as Import ((%))
|
||||||
|
|
||||||
|
|
||||||
import Control.Monad.Trans.RWS (RWST)
|
import Control.Monad.Trans.RWS (RWST)
|
||||||
|
|
||||||
|
|||||||
19
src/Jobs.hs
19
src/Jobs.hs
@ -51,6 +51,8 @@ import Data.Time.Zones
|
|||||||
|
|
||||||
import Control.Concurrent.STM (retry)
|
import Control.Concurrent.STM (retry)
|
||||||
|
|
||||||
|
import qualified System.Systemd.Daemon as Systemd
|
||||||
|
|
||||||
|
|
||||||
import Jobs.Handler.SendNotification
|
import Jobs.Handler.SendNotification
|
||||||
import Jobs.Handler.SendTestEmail
|
import Jobs.Handler.SendTestEmail
|
||||||
@ -62,6 +64,8 @@ import Jobs.Handler.SendCourseCommunication
|
|||||||
import Jobs.Handler.LecturerInvitation
|
import Jobs.Handler.LecturerInvitation
|
||||||
import Jobs.Handler.CorrectorInvitation
|
import Jobs.Handler.CorrectorInvitation
|
||||||
|
|
||||||
|
import Jobs.HealthReport
|
||||||
|
|
||||||
|
|
||||||
data JobQueueException = JInvalid QueuedJobId QueuedJob
|
data JobQueueException = JInvalid QueuedJobId QueuedJob
|
||||||
| JLocked QueuedJobId InstanceId UTCTime
|
| JLocked QueuedJobId InstanceId UTCTime
|
||||||
@ -280,6 +284,21 @@ handleJobs' wNum = C.mapM_ $ \jctl -> do
|
|||||||
-- logDebugS logIdent $ tshow newCTab
|
-- logDebugS logIdent $ tshow newCTab
|
||||||
mapReaderT (liftIO . atomically) $
|
mapReaderT (liftIO . atomically) $
|
||||||
lift . void . flip swapTMVar newCTab =<< asks jobCrontab
|
lift . void . flip swapTMVar newCTab =<< asks jobCrontab
|
||||||
|
handleCmd JobCtlGenerateHealthReport = do
|
||||||
|
hrStorage <- getsYesod appHealthReport
|
||||||
|
newReport@(classifyHealthReport -> newStatus) <- lift generateHealthReport
|
||||||
|
|
||||||
|
$logInfoS "HealthReport" $ toPathPiece newStatus
|
||||||
|
unless (newStatus == HealthSuccess) $ do
|
||||||
|
$logErrorS "HealthReport" $ tshow newReport
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
now <- getCurrentTime
|
||||||
|
atomically . writeTVar hrStorage $ Just (now, newReport)
|
||||||
|
|
||||||
|
void . Systemd.notifyStatus . unpack $ toPathPiece newStatus
|
||||||
|
when (newStatus == HealthSuccess) $
|
||||||
|
void Systemd.notifyWatchdog
|
||||||
|
|
||||||
jLocked :: QueuedJobId -> (QueuedJob -> Handler a) -> Handler a
|
jLocked :: QueuedJobId -> (QueuedJob -> Handler a) -> Handler a
|
||||||
jLocked jId act = do
|
jLocked jId act = do
|
||||||
|
|||||||
@ -44,6 +44,15 @@ determineCrontab = execWriterT $ do
|
|||||||
, cronNotAfter = Right CronNotScheduled
|
, cronNotAfter = Right CronNotScheduled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tell $ HashMap.singleton
|
||||||
|
JobCtlGenerateHealthReport
|
||||||
|
Cron
|
||||||
|
{ cronInitial = CronAsap
|
||||||
|
, cronRepeat = CronRepeatScheduled CronAsap
|
||||||
|
, cronRateLimit = appHealthCheckInterval
|
||||||
|
, cronNotAfter = Right CronNotScheduled
|
||||||
|
}
|
||||||
|
|
||||||
let
|
let
|
||||||
sheetJobs (Entity nSheet Sheet{..}) = do
|
sheetJobs (Entity nSheet Sheet{..}) = do
|
||||||
tell $ HashMap.singleton
|
tell $ HashMap.singleton
|
||||||
|
|||||||
142
src/Jobs/HealthReport.hs
Normal file
142
src/Jobs/HealthReport.hs
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
{-# LANGUAGE AllowAmbiguousTypes #-}
|
||||||
|
|
||||||
|
module Jobs.HealthReport
|
||||||
|
( generateHealthReport
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Import
|
||||||
|
|
||||||
|
import Data.List (genericLength)
|
||||||
|
|
||||||
|
import qualified Data.Aeson as Aeson
|
||||||
|
import Data.Proxy (Proxy(..))
|
||||||
|
|
||||||
|
import qualified Data.ByteArray as ByteArray
|
||||||
|
|
||||||
|
import Utils.Lens
|
||||||
|
|
||||||
|
import Network.HTTP.Simple (httpJSON, httpLBS)
|
||||||
|
import qualified Network.HTTP.Simple as HTTP
|
||||||
|
|
||||||
|
import qualified Database.Esqueleto as E
|
||||||
|
|
||||||
|
import Auth.LDAP
|
||||||
|
|
||||||
|
import qualified Data.CaseInsensitive as CI
|
||||||
|
|
||||||
|
import qualified Network.HaskellNet.SMTP as SMTP
|
||||||
|
import Data.Pool (withResource)
|
||||||
|
|
||||||
|
|
||||||
|
generateHealthReport :: Handler HealthReport
|
||||||
|
generateHealthReport
|
||||||
|
= runConcurrently $ HealthReport
|
||||||
|
<$> Concurrently matchingClusterConfig
|
||||||
|
<*> Concurrently httpReachable
|
||||||
|
<*> Concurrently ldapAdmins
|
||||||
|
<*> Concurrently smtpConnect
|
||||||
|
<*> Concurrently widgetMemcached
|
||||||
|
|
||||||
|
matchingClusterConfig :: Handler Bool
|
||||||
|
-- ^ Can the cluster configuration be read from the database and does it match our configuration?
|
||||||
|
matchingClusterConfig = runDB $ and <$> forM universeF clusterSettingMatches
|
||||||
|
where
|
||||||
|
clusterSettingMatches ClusterCryptoIDKey = do
|
||||||
|
ourSetting <- getsYesod appCryptoIDKey
|
||||||
|
dbSetting <- clusterSetting @'ClusterCryptoIDKey
|
||||||
|
return $ ((==) `on` fmap (ByteArray.convert :: CryptoIDKey -> ByteString)) (Just ourSetting) dbSetting
|
||||||
|
clusterSettingMatches ClusterClientSessionKey = do
|
||||||
|
ourSetting <- getsYesod appSessionKey
|
||||||
|
dbSetting <- clusterSetting @'ClusterClientSessionKey
|
||||||
|
return $ Just ourSetting == dbSetting
|
||||||
|
clusterSettingMatches ClusterSecretBoxKey = do
|
||||||
|
ourSetting <- getsYesod appSecretBoxKey
|
||||||
|
dbSetting <- clusterSetting @'ClusterSecretBoxKey
|
||||||
|
return $ Just ourSetting == dbSetting
|
||||||
|
clusterSettingMatches ClusterJSONWebKeySet = do
|
||||||
|
ourSetting <- getsYesod appJSONWebKeySet
|
||||||
|
dbSetting <- clusterSetting @'ClusterJSONWebKeySet
|
||||||
|
return $ Just ourSetting == dbSetting
|
||||||
|
clusterSettingMatches ClusterId = do
|
||||||
|
ourSetting <- getsYesod appClusterID
|
||||||
|
dbSetting <- clusterSetting @'ClusterId
|
||||||
|
return $ Just ourSetting == dbSetting
|
||||||
|
|
||||||
|
|
||||||
|
clusterSetting :: forall key.
|
||||||
|
( ClusterSetting key
|
||||||
|
)
|
||||||
|
=> DB (Maybe (ClusterSettingValue key))
|
||||||
|
clusterSetting = do
|
||||||
|
current' <- get . ClusterConfigKey $ knownClusterSetting (Proxy @key)
|
||||||
|
case Aeson.fromJSON . clusterConfigValue <$> current' of
|
||||||
|
Just (Aeson.Success c) -> return $ Just c
|
||||||
|
_other -> return Nothing
|
||||||
|
|
||||||
|
|
||||||
|
httpReachable :: Handler (Maybe Bool)
|
||||||
|
httpReachable = do
|
||||||
|
staticAppRoot <- getsYesod $ view _appRoot
|
||||||
|
doHTTP <- getsYesod $ view _appHealthCheckHTTP
|
||||||
|
for (staticAppRoot <* guard doHTTP) $ \_textAppRoot -> do
|
||||||
|
url <- getUrlRender <*> pure InstanceR
|
||||||
|
baseRequest <- HTTP.parseRequest $ unpack url
|
||||||
|
httpManager <- getsYesod appHttpManager
|
||||||
|
let httpRequest = baseRequest
|
||||||
|
& HTTP.setRequestManager httpManager
|
||||||
|
(clusterId, _ :: InstanceId) <- responseBody <$> httpJSON httpRequest
|
||||||
|
getsYesod $ (== clusterId) . appClusterID
|
||||||
|
|
||||||
|
|
||||||
|
ldapAdmins :: Handler (Maybe Rational)
|
||||||
|
ldapAdmins = do
|
||||||
|
ldapPool' <- getsYesod appLdapPool
|
||||||
|
ldapConf' <- getsYesod $ view _appLdapConf
|
||||||
|
ldapAdminUsers <- fmap (map E.unValue) . runDB . E.select . E.from $ \(user `E.InnerJoin` lecturer) -> E.distinctOnOrderBy [E.asc $ user E.^. UserId] $ do
|
||||||
|
E.on $ user E.^. UserId E.==. lecturer E.^. LecturerUser
|
||||||
|
E.where_ $ user E.^. UserAuthentication E.==. E.val AuthLDAP
|
||||||
|
return $ user E.^. UserIdent
|
||||||
|
case (,) <$> ldapPool' <*> ldapConf' of
|
||||||
|
Just (ldapPool, ldapConf)
|
||||||
|
| not $ null ldapAdminUsers
|
||||||
|
-> do
|
||||||
|
let numAdmins = genericLength ldapAdminUsers
|
||||||
|
hCampusExc :: CampusUserException -> Handler (Sum Integer)
|
||||||
|
hCampusExc _ = return $ Sum 0
|
||||||
|
Sum numResolved <- fmap fold . forM ldapAdminUsers $
|
||||||
|
\(CI.original -> adminIdent) -> handle hCampusExc $ Sum 1 <$ campusUser ldapConf ldapPool (Creds "LDAP" adminIdent [])
|
||||||
|
return . Just $ numResolved % numAdmins
|
||||||
|
_other -> return Nothing
|
||||||
|
|
||||||
|
|
||||||
|
smtpConnect :: Handler (Maybe Bool)
|
||||||
|
smtpConnect = do
|
||||||
|
smtpPool <- getsYesod appSmtpPool
|
||||||
|
for smtpPool . flip withResource $ \smtpConn -> do
|
||||||
|
response@(rCode, _) <- liftIO $ SMTP.sendCommand smtpConn SMTP.NOOP
|
||||||
|
case rCode of
|
||||||
|
250 -> return True
|
||||||
|
_ -> do
|
||||||
|
$logErrorS "Mail" $ "NOOP failed: " <> tshow response
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
widgetMemcached :: Handler (Maybe Bool)
|
||||||
|
widgetMemcached = do
|
||||||
|
memcachedConn <- getsYesod appWidgetMemcached
|
||||||
|
for memcachedConn $ \_memcachedConn' -> do
|
||||||
|
let ext = "bin"
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
content <- pack . take 256 <$> liftIO getRandoms
|
||||||
|
staticLink <- addStaticContent ext mimeType content
|
||||||
|
doHTTP <- getsYesod $ view _appHealthCheckHTTP
|
||||||
|
case staticLink of
|
||||||
|
_ | not doHTTP -> return True
|
||||||
|
Just (Left url) -> do
|
||||||
|
baseRequest <- HTTP.parseRequest $ unpack url
|
||||||
|
httpManager <- getsYesod appHttpManager
|
||||||
|
let httpRequest = baseRequest
|
||||||
|
& HTTP.setRequestManager httpManager
|
||||||
|
(== content) . responseBody <$> httpLBS httpRequest
|
||||||
|
_other -> return False
|
||||||
|
|
||||||
@ -69,6 +69,7 @@ data JobCtl = JobCtlFlush
|
|||||||
| JobCtlPerform QueuedJobId
|
| JobCtlPerform QueuedJobId
|
||||||
| JobCtlDetermineCrontab
|
| JobCtlDetermineCrontab
|
||||||
| JobCtlQueue Job
|
| JobCtlQueue Job
|
||||||
|
| JobCtlGenerateHealthReport
|
||||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||||
|
|
||||||
instance Hashable JobCtl
|
instance Hashable JobCtl
|
||||||
|
|||||||
@ -95,7 +95,7 @@ createLdapPool host port stripes timeoutConn (round . (* 1e6) -> timeoutAct) lim
|
|||||||
setup <- newEmptyTMVarIO
|
setup <- newEmptyTMVarIO
|
||||||
|
|
||||||
void . fork . flip runLoggingT logFunc $ do
|
void . fork . flip runLoggingT logFunc $ do
|
||||||
$logDebugS "LdapExecutor" "Starting"
|
$logInfoS "LdapExecutor" "Starting"
|
||||||
res <- liftIO . Ldap.with host port $ flip runLoggingT logFunc . go (Just setup)
|
res <- liftIO . Ldap.with host port $ flip runLoggingT logFunc . go (Just setup)
|
||||||
case res of
|
case res of
|
||||||
Left exc -> do
|
Left exc -> do
|
||||||
|
|||||||
@ -86,6 +86,10 @@ import qualified Data.Binary as Binary
|
|||||||
|
|
||||||
import Time.Types (WeekDay(..))
|
import Time.Types (WeekDay(..))
|
||||||
import Data.Time.LocalTime (LocalTime, TimeOfDay)
|
import Data.Time.LocalTime (LocalTime, TimeOfDay)
|
||||||
|
|
||||||
|
import Data.Semigroup (Min(..))
|
||||||
|
import Control.Monad.Trans.Writer (execWriter)
|
||||||
|
import Control.Monad.Writer.Class (MonadWriter(..))
|
||||||
|
|
||||||
|
|
||||||
instance PathPiece UUID where
|
instance PathPiece UUID where
|
||||||
@ -922,6 +926,55 @@ deriveJSON defaultOptions
|
|||||||
derivePersistFieldJSON ''Occurences
|
derivePersistFieldJSON ''Occurences
|
||||||
|
|
||||||
|
|
||||||
|
data HealthReport = HealthReport
|
||||||
|
{ healthMatchingClusterConfig :: Bool
|
||||||
|
-- ^ Is the database-stored configuration we're running under still up to date?
|
||||||
|
, healthHTTPReachable :: Maybe Bool
|
||||||
|
-- ^ Can we reach a uni2work-instance with the same `ClusterId` under our configured `approot` via HTTP?
|
||||||
|
--
|
||||||
|
-- Can be `Nothing` if we don't have a static configuration setting `appRoot` or if check is disabled in settings
|
||||||
|
, healthLDAPAdmins :: Maybe Rational
|
||||||
|
-- ^ Proportion of school admins that could be found in LDAP
|
||||||
|
--
|
||||||
|
-- Is `Nothing` if LDAP is not configured or no users are school admins
|
||||||
|
, healthSMTPConnect :: Maybe Bool
|
||||||
|
-- ^ Can we connect to the SMTP server and say @NOOP@?
|
||||||
|
, healthWidgetMemcached :: Maybe Bool
|
||||||
|
-- ^ Can we store values in memcached and retrieve them via HTTP?
|
||||||
|
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||||
|
|
||||||
|
deriveJSON defaultOptions
|
||||||
|
{ fieldLabelModifier = camelToPathPiece' 1
|
||||||
|
, omitNothingFields = True
|
||||||
|
} ''HealthReport
|
||||||
|
|
||||||
|
-- | `HealthReport` classified (`classifyHealthReport`) by badness
|
||||||
|
--
|
||||||
|
-- > a < b = a `worseThan` b
|
||||||
|
--
|
||||||
|
-- Currently all consumers of this type check for @(== HealthSuccess)@; this
|
||||||
|
-- needs to be adjusted on a case-by-case basis if new constructors are added
|
||||||
|
data HealthStatus = HealthFailure | HealthSuccess
|
||||||
|
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
|
||||||
|
|
||||||
|
instance Universe HealthStatus
|
||||||
|
instance Finite HealthStatus
|
||||||
|
|
||||||
|
deriveJSON defaultOptions
|
||||||
|
{ constructorTagModifier = camelToPathPiece' 1
|
||||||
|
} ''HealthStatus
|
||||||
|
nullaryPathPiece ''HealthStatus $ camelToPathPiece' 1
|
||||||
|
|
||||||
|
classifyHealthReport :: HealthReport -> HealthStatus
|
||||||
|
-- ^ Classify `HealthReport` by badness
|
||||||
|
classifyHealthReport HealthReport{..} = getMin . execWriter $ do -- Construction with `Writer (Min HealthStatus) a` returns worst `HealthStatus` passed to `tell` at any point
|
||||||
|
unless healthMatchingClusterConfig . tell $ Min HealthFailure
|
||||||
|
unless (fromMaybe True healthHTTPReachable) . tell $ Min HealthFailure
|
||||||
|
unless (maybe True (> 0) healthLDAPAdmins) . tell $ Min HealthFailure
|
||||||
|
unless (fromMaybe True healthSMTPConnect) . tell $ Min HealthFailure
|
||||||
|
unless (fromMaybe True healthWidgetMemcached) . tell $ Min HealthFailure
|
||||||
|
|
||||||
|
|
||||||
-- Type synonyms
|
-- Type synonyms
|
||||||
|
|
||||||
type Email = Text
|
type Email = Text
|
||||||
@ -936,5 +989,6 @@ type TutorialName = CI Text
|
|||||||
|
|
||||||
type PWHashAlgorithm = ByteString -> PWStore.Salt -> Int -> ByteString
|
type PWHashAlgorithm = ByteString -> PWStore.Salt -> Int -> ByteString
|
||||||
type InstanceId = UUID
|
type InstanceId = UUID
|
||||||
|
type ClusterId = UUID
|
||||||
type TokenId = UUID
|
type TokenId = UUID
|
||||||
type TermCandidateIncidence = UUID
|
type TermCandidateIncidence = UUID
|
||||||
|
|||||||
@ -48,9 +48,6 @@ import qualified Ldap.Client as Ldap
|
|||||||
import Utils hiding (MessageStatus(..))
|
import Utils hiding (MessageStatus(..))
|
||||||
import Control.Lens
|
import Control.Lens
|
||||||
|
|
||||||
import Data.Maybe (fromJust)
|
|
||||||
import qualified Data.Char as Char
|
|
||||||
|
|
||||||
import qualified Network.HaskellNet.Auth as HaskellNet (UserName, Password, AuthType(..))
|
import qualified Network.HaskellNet.Auth as HaskellNet (UserName, Password, AuthType(..))
|
||||||
import qualified Network.Socket as HaskellNet (PortNumber(..), HostName)
|
import qualified Network.Socket as HaskellNet (PortNumber(..), HostName)
|
||||||
import qualified Network
|
import qualified Network
|
||||||
@ -111,6 +108,10 @@ data AppSettings = AppSettings
|
|||||||
, appMaximumContentLength :: Maybe Word64
|
, appMaximumContentLength :: Maybe Word64
|
||||||
, appJwtExpiration :: Maybe NominalDiffTime
|
, appJwtExpiration :: Maybe NominalDiffTime
|
||||||
, appJwtEncoding :: JwtEncoding
|
, appJwtEncoding :: JwtEncoding
|
||||||
|
|
||||||
|
, appHealthCheckInterval :: NominalDiffTime
|
||||||
|
, appHealthCheckHTTP :: Bool
|
||||||
|
, appHealthCheckDelayNotify :: Bool
|
||||||
|
|
||||||
, appInitialLogSettings :: LogSettings
|
, appInitialLogSettings :: LogSettings
|
||||||
|
|
||||||
@ -278,7 +279,7 @@ deriveFromJSON
|
|||||||
|
|
||||||
deriveJSON
|
deriveJSON
|
||||||
defaultOptions
|
defaultOptions
|
||||||
{ constructorTagModifier = over (ix 1) Char.toLower . fromJust . stripPrefix "Level"
|
{ constructorTagModifier = camelToPathPiece' 1
|
||||||
, sumEncoding = UntaggedValue
|
, sumEncoding = UntaggedValue
|
||||||
}
|
}
|
||||||
''LogLevel
|
''LogLevel
|
||||||
@ -378,6 +379,10 @@ instance FromJSON AppSettings where
|
|||||||
appJwtExpiration <- o .:? "jwt-expiration"
|
appJwtExpiration <- o .:? "jwt-expiration"
|
||||||
appJwtEncoding <- o .: "jwt-encoding"
|
appJwtEncoding <- o .: "jwt-encoding"
|
||||||
|
|
||||||
|
appHealthCheckInterval <- o .: "health-check-interval"
|
||||||
|
appHealthCheckHTTP <- o .: "health-check-http"
|
||||||
|
appHealthCheckDelayNotify <- o .: "health-check-delay-notify"
|
||||||
|
|
||||||
appSessionTimeout <- o .: "session-timeout"
|
appSessionTimeout <- o .: "session-timeout"
|
||||||
|
|
||||||
appMaximumContentLength <- o .: "maximum-content-length"
|
appMaximumContentLength <- o .: "maximum-content-length"
|
||||||
|
|||||||
@ -36,12 +36,16 @@ import qualified Jose.Jwa as Jose
|
|||||||
import qualified Jose.Jwk as Jose
|
import qualified Jose.Jwk as Jose
|
||||||
import qualified Jose.Jwt as Jose
|
import qualified Jose.Jwt as Jose
|
||||||
|
|
||||||
|
import Data.UUID (UUID)
|
||||||
|
import Control.Monad.Random.Class (MonadRandom(..))
|
||||||
|
|
||||||
|
|
||||||
data ClusterSettingsKey
|
data ClusterSettingsKey
|
||||||
= ClusterCryptoIDKey
|
= ClusterCryptoIDKey
|
||||||
| ClusterClientSessionKey
|
| ClusterClientSessionKey
|
||||||
| ClusterSecretBoxKey
|
| ClusterSecretBoxKey
|
||||||
| ClusterJSONWebKeySet
|
| ClusterJSONWebKeySet
|
||||||
|
| ClusterId
|
||||||
deriving (Eq, Ord, Enum, Bounded, Show, Read)
|
deriving (Eq, Ord, Enum, Bounded, Show, Read)
|
||||||
|
|
||||||
instance Universe ClusterSettingsKey
|
instance Universe ClusterSettingsKey
|
||||||
@ -134,3 +138,9 @@ instance ClusterSetting 'ClusterJSONWebKeySet where
|
|||||||
jwkSig <- Jose.generateSymmetricKey 32 (Jose.UTCKeyId now) Jose.Sig (Just $ Jose.Signed Jose.HS256)
|
jwkSig <- Jose.generateSymmetricKey 32 (Jose.UTCKeyId now) Jose.Sig (Just $ Jose.Signed Jose.HS256)
|
||||||
return $ Jose.JwkSet [jwkSig]
|
return $ Jose.JwkSet [jwkSig]
|
||||||
knownClusterSetting _ = ClusterJSONWebKeySet
|
knownClusterSetting _ = ClusterJSONWebKeySet
|
||||||
|
|
||||||
|
|
||||||
|
instance ClusterSetting 'ClusterId where
|
||||||
|
type ClusterSettingValue 'ClusterId = UUID
|
||||||
|
initClusterSetting _ = liftIO getRandom
|
||||||
|
knownClusterSetting _ = ClusterId
|
||||||
|
|||||||
35
src/Utils.hs
35
src/Utils.hs
@ -17,6 +17,7 @@ import qualified Data.CaseInsensitive as CI
|
|||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
import qualified Data.Text as Text
|
import qualified Data.Text as Text
|
||||||
|
import qualified Data.Text.Encoding as Text
|
||||||
|
|
||||||
import Utils.DB as Utils
|
import Utils.DB as Utils
|
||||||
import Utils.TH as Utils
|
import Utils.TH as Utils
|
||||||
@ -72,6 +73,10 @@ import Data.Ratio ((%))
|
|||||||
|
|
||||||
import qualified Data.Binary as Binary
|
import qualified Data.Binary as Binary
|
||||||
|
|
||||||
|
import Network.Wai (requestMethod)
|
||||||
|
|
||||||
|
import Data.Time.Clock
|
||||||
|
|
||||||
{-# ANN choice ("HLint: ignore Use asum" :: String) #-}
|
{-# ANN choice ("HLint: ignore Use asum" :: String) #-}
|
||||||
|
|
||||||
|
|
||||||
@ -152,6 +157,10 @@ isNew :: Bool -> Markup
|
|||||||
isNew True = [shamlet|<i .fas .fa-seedling>|] -- was exclamation
|
isNew True = [shamlet|<i .fas .fa-seedling>|] -- was exclamation
|
||||||
isNew False = mempty
|
isNew False = mempty
|
||||||
|
|
||||||
|
boolSymbol :: Bool -> Markup
|
||||||
|
boolSymbol True = [shamlet|<i .fas .fa-check>|]
|
||||||
|
boolSymbol False = [shamlet|<i .fas .fa-times>|]
|
||||||
|
|
||||||
|
|
||||||
---------------------
|
---------------------
|
||||||
-- Text and String --
|
-- Text and String --
|
||||||
@ -653,7 +662,7 @@ instance Finite CustomHeader
|
|||||||
nullaryPathPiece ''CustomHeader (intercalate "-" . drop 1 . splitCamel)
|
nullaryPathPiece ''CustomHeader (intercalate "-" . drop 1 . splitCamel)
|
||||||
|
|
||||||
lookupCustomHeader :: (MonadHandler m, PathPiece result) => CustomHeader -> m (Maybe result)
|
lookupCustomHeader :: (MonadHandler m, PathPiece result) => CustomHeader -> m (Maybe result)
|
||||||
lookupCustomHeader ident = (>>= fromPathPiece . decodeUtf8) <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
|
lookupCustomHeader ident = (=<<) (fromPathPiece <=< either (const Nothing) Just . Text.decodeUtf8') <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
|
||||||
|
|
||||||
hasCustomHeader :: MonadHandler m => CustomHeader -> m Bool
|
hasCustomHeader :: MonadHandler m => CustomHeader -> m Bool
|
||||||
hasCustomHeader ident = isJust <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
|
hasCustomHeader ident = isJust <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
|
||||||
@ -752,3 +761,27 @@ cachedHere :: Q Exp
|
|||||||
cachedHere = do
|
cachedHere = do
|
||||||
loc <- location
|
loc <- location
|
||||||
[e| cachedBy (toStrict $ Binary.encode loc) |]
|
[e| cachedBy (toStrict $ Binary.encode loc) |]
|
||||||
|
|
||||||
|
hashToText :: Hashable a => a -> Text
|
||||||
|
hashToText = decodeUtf8 . Base64.encode . toStrict . Binary.encode . hash
|
||||||
|
|
||||||
|
setEtagHashable, setWeakEtagHashable :: (MonadHandler m, Hashable a) => a -> m ()
|
||||||
|
setEtagHashable = setEtag . hashToText
|
||||||
|
setWeakEtagHashable = setEtag . hashToText
|
||||||
|
|
||||||
|
setLastModified :: (MonadHandler m, MonadLogger m) => UTCTime -> m ()
|
||||||
|
setLastModified lastModified = do
|
||||||
|
rMethod <- requestMethod <$> waiRequest
|
||||||
|
|
||||||
|
when (rMethod `elem` safeMethods) $ do
|
||||||
|
ifModifiedSince <- (=<<) (parseTimeM True defaultTimeLocale "%a, %d %b %Y %X %Z" . unpack <=< either (const Nothing) Just . Text.decodeUtf8') <$> lookupHeader "If-Modified-Since"
|
||||||
|
$logDebugS "LastModified" $ tshow (lastModified, ifModifiedSince)
|
||||||
|
when (maybe False ((lastModified <=) . addUTCTime precision) ifModifiedSince)
|
||||||
|
notModified
|
||||||
|
|
||||||
|
addHeader "Last-Modified" $ formatRFC1123 lastModified
|
||||||
|
where
|
||||||
|
precision :: NominalDiffTime
|
||||||
|
precision = 1
|
||||||
|
|
||||||
|
safeMethods = [ methodGet, methodHead, methodOptions ]
|
||||||
|
|||||||
@ -106,11 +106,15 @@
|
|||||||
return init();
|
return init();
|
||||||
};
|
};
|
||||||
|
|
||||||
formUtilities.push({
|
// skipping reactiveButtonUtil (for now)
|
||||||
name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
|
// the button did not properly re-enable after filling out a form for some safari users.
|
||||||
selector: REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR,
|
// if maybe in the future there is going to be a proper way of (asynchronously) and
|
||||||
setup: reactiveSubmitButtonUtil,
|
// meaningfully validating forms this can be re-activated by commenting in the next few lines
|
||||||
});
|
// formUtilities.push({
|
||||||
|
// name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
|
||||||
|
// selector: REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR,
|
||||||
|
// setup: reactiveSubmitButtonUtil,
|
||||||
|
// });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,94 +1,93 @@
|
|||||||
$newline never
|
$newline never
|
||||||
<div .container>
|
<dl .deflist>
|
||||||
<dl .deflist>
|
<dt .deflist__dt>Fakultät/Institut
|
||||||
<dt .deflist__dt>Fakultät/Institut
|
<dd .deflist__dd>
|
||||||
|
<div>
|
||||||
|
#{schoolName}
|
||||||
|
|
||||||
|
$maybe descr <- courseDescription course
|
||||||
|
<dt .deflist__dt>_{MsgCourseDescription}
|
||||||
<dd .deflist__dd>
|
<dd .deflist__dd>
|
||||||
<div>
|
<div>
|
||||||
#{schoolName}
|
#{descr}
|
||||||
|
|
||||||
$maybe descr <- courseDescription course
|
$with numlecs <- length lecturers
|
||||||
<dt .deflist__dt>_{MsgCourseDescription}
|
$if numlecs /= 0
|
||||||
|
$if numlecs > 1
|
||||||
|
<dt .deflist__dt>_{MsgLecturersFor}
|
||||||
|
$else
|
||||||
|
<dt .deflist__dt>_{MsgLecturerFor}
|
||||||
<dd .deflist__dd>
|
<dd .deflist__dd>
|
||||||
<div>
|
<div>
|
||||||
#{descr}
|
<ul .list--inline .list--comma-separated>
|
||||||
|
$forall lect <- lecturers
|
||||||
$with numlecs <- length lecturers
|
<li>^{nameEmailWidget' lect}
|
||||||
$if numlecs /= 0
|
$with numassi <- length assistants
|
||||||
$if numlecs > 1
|
$if numassi /= 0
|
||||||
<dt .deflist__dt>_{MsgLecturersFor}
|
$if numassi > 1
|
||||||
$else
|
<dt .deflist__dt>_{MsgAssistantsFor}
|
||||||
<dt .deflist__dt>_{MsgLecturerFor}
|
$else
|
||||||
<dd .deflist__dd>
|
<dt .deflist__dt>_{MsgAssistantFor}
|
||||||
<div>
|
|
||||||
<ul .list--inline .list--comma-separated>
|
|
||||||
$forall lect <- lecturers
|
|
||||||
<li>^{nameEmailWidget' lect}
|
|
||||||
$with numassi <- length assistants
|
|
||||||
$if numassi /= 0
|
|
||||||
$if numassi > 1
|
|
||||||
<dt .deflist__dt>_{MsgAssistantsFor}
|
|
||||||
$else
|
|
||||||
<dt .deflist__dt>_{MsgAssistantFor}
|
|
||||||
<dd .deflist__dd>
|
|
||||||
<div>
|
|
||||||
<ul .list--inline .list--comma-separated>
|
|
||||||
$forall assi <- assistants
|
|
||||||
<li>^{nameEmailWidget' assi}
|
|
||||||
$with numcorrector <- length correctors
|
|
||||||
$if numcorrector /= 0
|
|
||||||
<dt .deflist__dt>_{MsgCorrectorsFor numcorrector}
|
|
||||||
<dd .deflist__dd>
|
|
||||||
<div>
|
|
||||||
<ul .list--inline .list--comma-separated>
|
|
||||||
$forall corrector <- correctors
|
|
||||||
<li>^{nameEmailWidget' corrector}
|
|
||||||
|
|
||||||
$maybe link <- courseLinkExternal course
|
|
||||||
<dt .deflist__dt>Website
|
|
||||||
<dd .deflist__dd>
|
<dd .deflist__dd>
|
||||||
<div>
|
<div>
|
||||||
<a href=#{link} target="_blank" rel="noopener" title="Website des Kurses">#{link}
|
<ul .list--inline .list--comma-separated>
|
||||||
|
$forall assi <- assistants
|
||||||
|
<li>^{nameEmailWidget' assi}
|
||||||
|
$with numcorrector <- length correctors
|
||||||
|
$if numcorrector /= 0
|
||||||
|
<dt .deflist__dt>_{MsgCorrectorsFor numcorrector}
|
||||||
|
<dd .deflist__dd>
|
||||||
|
<div>
|
||||||
|
<ul .list--inline .list--comma-separated>
|
||||||
|
$forall corrector <- correctors
|
||||||
|
<li>^{nameEmailWidget' corrector}
|
||||||
|
|
||||||
|
$maybe link <- courseLinkExternal course
|
||||||
|
<dt .deflist__dt>Website
|
||||||
|
<dd .deflist__dd>
|
||||||
|
<div>
|
||||||
|
<a href=#{link} target="_blank" rel="noopener" title="Website des Kurses">#{link}
|
||||||
$# $if NTop (Just 0) < NTop (courseCapacity course)
|
$# $if NTop (Just 0) < NTop (courseCapacity course)
|
||||||
<dt .deflist__dt>Teilnehmer
|
<dt .deflist__dt>Teilnehmer
|
||||||
<dd .deflist__dd>
|
<dd .deflist__dd>
|
||||||
<div>
|
<div>
|
||||||
#{participants}
|
#{participants}
|
||||||
$maybe capacity <- courseCapacity course
|
$maybe capacity <- courseCapacity course
|
||||||
\ von #{capacity}
|
\ von #{capacity}
|
||||||
$maybe regFrom <- mRegFrom
|
$maybe regFrom <- mRegFrom
|
||||||
<dt .deflist__dt>Anmeldezeitraum
|
<dt .deflist__dt>Anmeldezeitraum
|
||||||
<dd .deflist__dd>
|
|
||||||
<div>
|
|
||||||
Ab #{regFrom}
|
|
||||||
$maybe regTo <- mRegTo
|
|
||||||
\ bis #{regTo}
|
|
||||||
$maybe dereg <- mDereg
|
|
||||||
<div>
|
|
||||||
\ <em>Achtung:</em>
|
|
||||||
\ Abmeldung nur bis #{dereg} erlaubt.
|
|
||||||
$if registrationOpen || isJust mRegAt
|
|
||||||
<dt .deflist__dt>
|
|
||||||
<dd .deflist__dd>
|
|
||||||
<div .course__registration>
|
|
||||||
$if registrationOpen
|
|
||||||
$# regForm is defined through templates/widgets/registerForm
|
|
||||||
^{regForm}
|
|
||||||
$maybe date <- mRegAt
|
|
||||||
_{MsgRegisteredSince date}
|
|
||||||
<dt .deflist__dt>
|
|
||||||
Material
|
|
||||||
<dd .deflist__dd>
|
<dd .deflist__dd>
|
||||||
<div>
|
<div>
|
||||||
$if courseMaterialFree course
|
Ab #{regFrom}
|
||||||
Das Kursmaterial ist ohne Anmeldung frei zugänglich.
|
$maybe regTo <- mRegTo
|
||||||
$else
|
\ bis #{regTo}
|
||||||
Eine Anmeldung zum Kurs ist Voraussetzung zum Zugang zu Kursmaterial
|
$maybe dereg <- mDereg
|
||||||
(z.B. Übungsblätter).
|
<div>
|
||||||
$if hasTutorials
|
\ <em>Achtung:</em>
|
||||||
<dt .deflist__dt>_{MsgCourseTutorials}
|
\ Abmeldung nur bis #{dereg} erlaubt.
|
||||||
<dd .deflist__dd>
|
$if registrationOpen || isJust mRegAt
|
||||||
^{tutorialTable}
|
<dt .deflist__dt>
|
||||||
|
<dd .deflist__dd>
|
||||||
|
<div .course__registration>
|
||||||
|
$if registrationOpen
|
||||||
|
$# regForm is defined through templates/widgets/registerForm
|
||||||
|
^{regForm}
|
||||||
|
$maybe date <- mRegAt
|
||||||
|
_{MsgRegisteredSince date}
|
||||||
|
<dt .deflist__dt>
|
||||||
|
Material
|
||||||
|
<dd .deflist__dd>
|
||||||
|
<div>
|
||||||
|
$if courseMaterialFree course
|
||||||
|
Das Kursmaterial ist ohne Anmeldung frei zugänglich.
|
||||||
|
$else
|
||||||
|
Eine Anmeldung zum Kurs ist Voraussetzung zum Zugang zu Kursmaterial
|
||||||
|
(z.B. Übungsblätter).
|
||||||
|
$if hasTutorials
|
||||||
|
<dt .deflist__dt>_{MsgCourseTutorials}
|
||||||
|
<dd .deflist__dd>
|
||||||
|
^{tutorialTable}
|
||||||
|
|
||||||
|
|
||||||
$# <div .container>
|
$# <div .container>
|
||||||
$# <div .tab-group>
|
$# <div .tab-group>
|
||||||
|
|||||||
@ -1,20 +1,28 @@
|
|||||||
$newline never
|
$newline never
|
||||||
\<!doctype html>
|
\<!doctype html>
|
||||||
\<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en"> <![endif]-->
|
<html lang=#{primaryLanguage}>
|
||||||
\<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en"> <![endif]-->
|
|
||||||
\<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en"> <![endif]-->
|
|
||||||
\<!--[if gt IE 8]><!-->
|
|
||||||
<html class="no-js" lang="en"> <!--<![endif]-->
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>#{pageTitle pc}
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
|
||||||
|
$case currentTheme
|
||||||
|
$of ThemeDefault
|
||||||
|
<meta name="theme-color" content="#0a9342">
|
||||||
|
$of ThemeLavender
|
||||||
|
<meta name="theme-color" content="#584c9c">
|
||||||
|
$of ThemeNeutralBlue
|
||||||
|
<meta name="theme-color" content="#3e606f">
|
||||||
|
$of ThemeAberdeenReds
|
||||||
|
<meta name="theme-color" content="#820333">
|
||||||
|
$of ThemeMossGreen
|
||||||
|
<meta name="theme-color" content="#5c996b">
|
||||||
|
$of ThemeSkyLove
|
||||||
|
<meta name="theme-color" content="#87abe5">
|
||||||
|
|
||||||
|
$# title-tag is required even if it is empty
|
||||||
|
<title>#{pageTitle pc}
|
||||||
|
|
||||||
^{pageHead pc}
|
^{pageHead pc}
|
||||||
|
|
||||||
<body .no-js .theme--#{toPathPiece currentTheme} :isAuth:.logged-in>
|
<body .theme--#{toPathPiece currentTheme} :isAuth:.logged-in>
|
||||||
<!-- removes no-js class from body if client supports javascript -->
|
|
||||||
<script>
|
|
||||||
document.body.classList.remove('no-js');
|
|
||||||
|
|
||||||
^{pageBody pc}
|
^{pageBody pc}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
$if not isModal
|
$if not isModal
|
||||||
<!-- secondary navigation at the side -->
|
<!-- secondary navigation at the side -->
|
||||||
^{asidenav}
|
^{asidenav}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
<section>
|
<section>
|
||||||
<h2>_{MsgHomeOpenCourses}
|
<h2>_{MsgHomeOpenCourses}
|
||||||
^{courseTable}
|
^{courseTable}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
<section>
|
<section>
|
||||||
<h2>_{MsgHomeUpcomingSheets}
|
<h2>_{MsgHomeUpcomingSheets}
|
||||||
^{sheetTable}
|
^{sheetTable}
|
||||||
|
|||||||
@ -3,10 +3,10 @@ $newline never
|
|||||||
$maybe flag <- sortableKey
|
$maybe flag <- sortableKey
|
||||||
$case directions
|
$case directions
|
||||||
$of [SortAsc]
|
$of [SortAsc]
|
||||||
<a .table__th-link href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortDesc : piSorting'))}>
|
<a .table__th-link rel=nofollow href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortDesc : piSorting'))}>
|
||||||
^{widget}
|
^{widget}
|
||||||
$of _
|
$of _
|
||||||
<a .table__th-link href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortAsc : piSorting'))}>
|
<a .table__th-link rel=nofollow href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortAsc : piSorting'))}>
|
||||||
^{widget}
|
^{widget}
|
||||||
$nothing
|
$nothing
|
||||||
^{widget}
|
^{widget}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
|
$newline never
|
||||||
^{pageBody tbl}
|
^{pageBody tbl}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
$maybe flag <- sortableKey
|
$maybe flag <- sortableKey
|
||||||
$case directions
|
$case directions
|
||||||
$of [SortAsc]
|
$of [SortAsc]
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
<div .container>
|
$newline never
|
||||||
^{table}
|
^{table}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
<div #alerts-1 .alerts uw-alerts>
|
<div #alerts-1 .alerts uw-alerts>
|
||||||
<div .alerts__toggler>
|
<div .alerts__toggler>
|
||||||
$forall (status, msg) <- mmsgs
|
$forall (status, msg) <- mmsgs
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
<footer .footer>
|
<footer .footer>
|
||||||
<div .footer-links>
|
<div .footer-links>
|
||||||
$forall (MenuItem{menuItemType, menuItemRoute = _, menuItemIcon = _, menuItemLabel, menuItemModal = _}, menuIdent, route) <- menuTypes
|
$forall (MenuItem{menuItemType, menuItemRoute = _, menuItemIcon = _, menuItemLabel, menuItemModal = _}, menuIdent, route) <- menuTypes
|
||||||
|
|||||||
@ -14,3 +14,11 @@
|
|||||||
background-color: var(--color-grey-light);
|
background-color: var(--color-grey-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-links * {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
|
||||||
|
&:last {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
$newline never
|
||||||
<a .navbar__link-wrapper href=#{route} ##{menuIdent}>
|
<a .navbar__link-wrapper href=#{route} ##{menuIdent}>
|
||||||
<i .fas.fa-#{fromMaybe "none" menuItemIcon}>
|
<i .fas.fa-#{fromMaybe "none" menuItemIcon}>
|
||||||
<div .navbar__link-label>_{SomeMessage menuItemLabel}
|
<div .navbar__link-label>_{SomeMessage menuItemLabel}
|
||||||
|
|||||||
Reference in New Issue
Block a user