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-encoding: HS256
|
||||
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:
|
||||
detailed: "_env:DETAILED_LOGGING:false"
|
||||
|
||||
@ -702,6 +702,8 @@ MenuInformation: Informationen
|
||||
MenuImpressum: Impressum
|
||||
MenuDataProt: Datenschutz
|
||||
MenuVersion: Versionsgeschichte
|
||||
MenuInstance: Instanz-Identifikation
|
||||
MenuHealth: Instanz-Zustand
|
||||
MenuHelp: Hilfe
|
||||
MenuProfile: Anpassen
|
||||
MenuLogin: Login
|
||||
@ -877,4 +879,16 @@ TutorialCreated tutn@TutorialName: Tutorium #{tutn} erfolgreich angelegt
|
||||
|
||||
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
|
||||
- streaming-commons
|
||||
- hourglass
|
||||
- unix
|
||||
|
||||
other-extensions:
|
||||
- GeneralizedNewtypeDeriving
|
||||
|
||||
2
routes
2
routes
@ -50,6 +50,8 @@
|
||||
/admin/test AdminTestR GET POST
|
||||
/admin/errMsg AdminErrMsgR GET POST
|
||||
|
||||
/health HealthR GET !free
|
||||
/instance InstanceR GET !free
|
||||
/info InfoR GET !free
|
||||
/info/lecturer InfoLecturerR GET !lecturer
|
||||
/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 Control.Lens
|
||||
import Utils.Lens
|
||||
|
||||
import Data.Proxy
|
||||
|
||||
@ -76,6 +76,10 @@ import qualified Database.Memcached.Binary.IO as Memcached
|
||||
|
||||
import qualified System.Systemd.Daemon as Systemd
|
||||
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.
|
||||
-- (HPack takes care to add new modules to our cabal file nowadays.)
|
||||
@ -95,6 +99,7 @@ import Handler.Tutorial
|
||||
import Handler.Corrections
|
||||
import Handler.CryptoIDDispatch
|
||||
import Handler.SystemMessage
|
||||
import Handler.Health
|
||||
|
||||
|
||||
-- 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
|
||||
appCronThread <- liftIO newEmptyTMVarIO
|
||||
appHealthReport <- liftIO $ newTVarIO Nothing
|
||||
|
||||
-- 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
|
||||
-- logging function. To get out of this loop, we initially create a
|
||||
-- temporary foundation without a real connection pool, get a log function
|
||||
-- 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
|
||||
-- information, see:
|
||||
-- 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 "widgetMemcached forced in tempFoundation")
|
||||
(error "JSONWebKeySet forced in tempFoundation")
|
||||
(error "ClusterID forced in tempFoundation")
|
||||
|
||||
runAppLoggingT tempFoundation $ do
|
||||
$logInfoS "InstanceID" $ UUID.toText appInstanceID
|
||||
@ -191,8 +198,9 @@ makeFoundation appSettings'@AppSettings{..} = do
|
||||
appSessionKey <- clusterSetting (Proxy :: Proxy 'ClusterClientSessionKey) `runSqlPool` sqlPool
|
||||
appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `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"
|
||||
handleJobs foundation
|
||||
@ -311,8 +319,16 @@ makeLogWare app = do
|
||||
warpSettings :: UniWorX -> Settings
|
||||
warpSettings foundation = defaultSettings
|
||||
& setBeforeMainLoop (runAppLoggingT foundation $ do
|
||||
$logInfoS "setup" "Ready"
|
||||
void $ liftIO Systemd.notifyReady
|
||||
let notifyReady = do
|
||||
$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)
|
||||
& setPort (foundation ^. _appPort)
|
||||
@ -336,8 +352,20 @@ getApplicationDev = do
|
||||
app <- makeApplication foundation
|
||||
return (wsettings, app)
|
||||
|
||||
getAppDevSettings :: MonadIO m => m AppSettings
|
||||
getAppDevSettings = liftIO $ loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv
|
||||
getAppDevSettings, getAppSettings :: MonadIO m => m AppSettings
|
||||
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
|
||||
develMain :: IO ()
|
||||
@ -347,14 +375,7 @@ develMain = runResourceT $
|
||||
-- | The @main@ function for an executable running this site.
|
||||
appMain :: MonadResourceBase m => m ()
|
||||
appMain = runResourceT $ do
|
||||
-- Get the settings from all relevant sources
|
||||
settings <- liftIO $
|
||||
loadYamlSettingsArgs
|
||||
-- fall back to compile-time values, set to [] to require values at runtime
|
||||
[configSettingsYmlValue]
|
||||
|
||||
-- allow environment variables to override
|
||||
useEnv
|
||||
settings <- getAppSettings
|
||||
|
||||
-- Generate the foundation from the settings
|
||||
foundation <- makeFoundation settings
|
||||
|
||||
@ -118,12 +118,14 @@ data UniWorX = UniWorX
|
||||
, appLogger :: (ReleaseKey, TVar Logger)
|
||||
, appLogSettings :: TVar LogSettings
|
||||
, appCryptoIDKey :: CryptoIDKey
|
||||
, appClusterID :: ClusterId
|
||||
, appInstanceID :: InstanceId
|
||||
, appJobCtl :: TVar (Map ThreadId (TMChan JobCtl))
|
||||
, appCronThread :: TMVar (ReleaseKey, ThreadId)
|
||||
, appSessionKey :: ClientSession.Key
|
||||
, appSecretBoxKey :: SecretBox.Key
|
||||
, appJSONWebKeySet :: Jose.JwkSet
|
||||
, appHealthReport :: TVar (Maybe (UTCTime, HealthReport))
|
||||
}
|
||||
|
||||
makeLenses_ ''UniWorX
|
||||
@ -1160,6 +1162,8 @@ siteLayout' headingOverride widget = do
|
||||
|
||||
isModal <- hasCustomHeader HeaderIsModal
|
||||
|
||||
primaryLanguage <- unsafeHead . Text.splitOn "-" <$> selectLanguage appLanguages
|
||||
|
||||
mcurrentRoute <- getCurrentRoute
|
||||
|
||||
-- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance.
|
||||
@ -1346,6 +1350,10 @@ instance YesodBreadcrumbs UniWorX where
|
||||
breadcrumb HelpR = return ("Hilfe" , Just HomeR)
|
||||
|
||||
|
||||
breadcrumb HealthR = return ("Status" , Nothing)
|
||||
breadcrumb InstanceR = return ("Identifikation", Nothing)
|
||||
|
||||
|
||||
breadcrumb ProfileR = return ("User" , Just HomeR)
|
||||
breadcrumb ProfileDataR = return ("Profile" , Just ProfileR)
|
||||
breadcrumb AuthPredsR = return ("Authentifizierung", Just ProfileR)
|
||||
@ -1649,6 +1657,26 @@ pageActions (VersionR) = [
|
||||
, 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) = [
|
||||
-- MenuItem
|
||||
-- { menuItemType = PageActionPrime
|
||||
|
||||
@ -8,10 +8,19 @@ import Import hiding (embedFile)
|
||||
-- runtime dependency, and for efficiency.
|
||||
|
||||
getFaviconR :: Handler TypedContent
|
||||
getFaviconR = do cacheSeconds $ 60 * 60 * 24 * 30 -- cache for a month
|
||||
return $ TypedContent "image/x-icon"
|
||||
$ toContent $(embedFile "static/favicon.ico")
|
||||
getFaviconR = do
|
||||
let content = $(embedFile "static/favicon.ico")
|
||||
|
||||
setEtagHashable content
|
||||
|
||||
return $ TypedContent "image/x-icon"
|
||||
$ toContent content
|
||||
|
||||
getRobotsR :: Handler TypedContent
|
||||
getRobotsR = return $ TypedContent typePlain
|
||||
$ toContent $(embedFile "static/robots.txt")
|
||||
getRobotsR = do
|
||||
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 Data.Scientific (Scientific)
|
||||
import Data.Ratio
|
||||
import Text.Read (readMaybe)
|
||||
import Data.Either (partitionEithers)
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ import Data.List.NonEmpty.Instances as Import ()
|
||||
import Data.NonNull.Instances as Import ()
|
||||
import Data.Text.Encoding.Error as Import(UnicodeException(..))
|
||||
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.Set.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.Ratio as Import ((%))
|
||||
|
||||
|
||||
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 qualified System.Systemd.Daemon as Systemd
|
||||
|
||||
|
||||
import Jobs.Handler.SendNotification
|
||||
import Jobs.Handler.SendTestEmail
|
||||
@ -62,6 +64,8 @@ import Jobs.Handler.SendCourseCommunication
|
||||
import Jobs.Handler.LecturerInvitation
|
||||
import Jobs.Handler.CorrectorInvitation
|
||||
|
||||
import Jobs.HealthReport
|
||||
|
||||
|
||||
data JobQueueException = JInvalid QueuedJobId QueuedJob
|
||||
| JLocked QueuedJobId InstanceId UTCTime
|
||||
@ -280,6 +284,21 @@ handleJobs' wNum = C.mapM_ $ \jctl -> do
|
||||
-- logDebugS logIdent $ tshow newCTab
|
||||
mapReaderT (liftIO . atomically) $
|
||||
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 jId act = do
|
||||
|
||||
@ -44,6 +44,15 @@ determineCrontab = execWriterT $ do
|
||||
, cronNotAfter = Right CronNotScheduled
|
||||
}
|
||||
|
||||
tell $ HashMap.singleton
|
||||
JobCtlGenerateHealthReport
|
||||
Cron
|
||||
{ cronInitial = CronAsap
|
||||
, cronRepeat = CronRepeatScheduled CronAsap
|
||||
, cronRateLimit = appHealthCheckInterval
|
||||
, cronNotAfter = Right CronNotScheduled
|
||||
}
|
||||
|
||||
let
|
||||
sheetJobs (Entity nSheet Sheet{..}) = do
|
||||
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
|
||||
| JobCtlDetermineCrontab
|
||||
| JobCtlQueue Job
|
||||
| JobCtlGenerateHealthReport
|
||||
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||
|
||||
instance Hashable JobCtl
|
||||
|
||||
@ -95,7 +95,7 @@ createLdapPool host port stripes timeoutConn (round . (* 1e6) -> timeoutAct) lim
|
||||
setup <- newEmptyTMVarIO
|
||||
|
||||
void . fork . flip runLoggingT logFunc $ do
|
||||
$logDebugS "LdapExecutor" "Starting"
|
||||
$logInfoS "LdapExecutor" "Starting"
|
||||
res <- liftIO . Ldap.with host port $ flip runLoggingT logFunc . go (Just setup)
|
||||
case res of
|
||||
Left exc -> do
|
||||
|
||||
@ -86,6 +86,10 @@ import qualified Data.Binary as Binary
|
||||
|
||||
import Time.Types (WeekDay(..))
|
||||
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
|
||||
@ -922,6 +926,55 @@ deriveJSON defaultOptions
|
||||
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 Email = Text
|
||||
@ -936,5 +989,6 @@ type TutorialName = CI Text
|
||||
|
||||
type PWHashAlgorithm = ByteString -> PWStore.Salt -> Int -> ByteString
|
||||
type InstanceId = UUID
|
||||
type ClusterId = UUID
|
||||
type TokenId = UUID
|
||||
type TermCandidateIncidence = UUID
|
||||
|
||||
@ -48,9 +48,6 @@ import qualified Ldap.Client as Ldap
|
||||
import Utils hiding (MessageStatus(..))
|
||||
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.Socket as HaskellNet (PortNumber(..), HostName)
|
||||
import qualified Network
|
||||
@ -111,6 +108,10 @@ data AppSettings = AppSettings
|
||||
, appMaximumContentLength :: Maybe Word64
|
||||
, appJwtExpiration :: Maybe NominalDiffTime
|
||||
, appJwtEncoding :: JwtEncoding
|
||||
|
||||
, appHealthCheckInterval :: NominalDiffTime
|
||||
, appHealthCheckHTTP :: Bool
|
||||
, appHealthCheckDelayNotify :: Bool
|
||||
|
||||
, appInitialLogSettings :: LogSettings
|
||||
|
||||
@ -278,7 +279,7 @@ deriveFromJSON
|
||||
|
||||
deriveJSON
|
||||
defaultOptions
|
||||
{ constructorTagModifier = over (ix 1) Char.toLower . fromJust . stripPrefix "Level"
|
||||
{ constructorTagModifier = camelToPathPiece' 1
|
||||
, sumEncoding = UntaggedValue
|
||||
}
|
||||
''LogLevel
|
||||
@ -378,6 +379,10 @@ instance FromJSON AppSettings where
|
||||
appJwtExpiration <- o .:? "jwt-expiration"
|
||||
appJwtEncoding <- o .: "jwt-encoding"
|
||||
|
||||
appHealthCheckInterval <- o .: "health-check-interval"
|
||||
appHealthCheckHTTP <- o .: "health-check-http"
|
||||
appHealthCheckDelayNotify <- o .: "health-check-delay-notify"
|
||||
|
||||
appSessionTimeout <- o .: "session-timeout"
|
||||
|
||||
appMaximumContentLength <- o .: "maximum-content-length"
|
||||
|
||||
@ -36,12 +36,16 @@ import qualified Jose.Jwa as Jose
|
||||
import qualified Jose.Jwk as Jose
|
||||
import qualified Jose.Jwt as Jose
|
||||
|
||||
import Data.UUID (UUID)
|
||||
import Control.Monad.Random.Class (MonadRandom(..))
|
||||
|
||||
|
||||
data ClusterSettingsKey
|
||||
= ClusterCryptoIDKey
|
||||
| ClusterClientSessionKey
|
||||
| ClusterSecretBoxKey
|
||||
| ClusterJSONWebKeySet
|
||||
| ClusterId
|
||||
deriving (Eq, Ord, Enum, Bounded, Show, Read)
|
||||
|
||||
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)
|
||||
return $ Jose.JwkSet [jwkSig]
|
||||
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.Lazy as LBS
|
||||
import qualified Data.Text as Text
|
||||
import qualified Data.Text.Encoding as Text
|
||||
|
||||
import Utils.DB as Utils
|
||||
import Utils.TH as Utils
|
||||
@ -72,6 +73,10 @@ import Data.Ratio ((%))
|
||||
|
||||
import qualified Data.Binary as Binary
|
||||
|
||||
import Network.Wai (requestMethod)
|
||||
|
||||
import Data.Time.Clock
|
||||
|
||||
{-# ANN choice ("HLint: ignore Use asum" :: String) #-}
|
||||
|
||||
|
||||
@ -152,6 +157,10 @@ isNew :: Bool -> Markup
|
||||
isNew True = [shamlet|<i .fas .fa-seedling>|] -- was exclamation
|
||||
isNew False = mempty
|
||||
|
||||
boolSymbol :: Bool -> Markup
|
||||
boolSymbol True = [shamlet|<i .fas .fa-check>|]
|
||||
boolSymbol False = [shamlet|<i .fas .fa-times>|]
|
||||
|
||||
|
||||
---------------------
|
||||
-- Text and String --
|
||||
@ -653,7 +662,7 @@ instance Finite CustomHeader
|
||||
nullaryPathPiece ''CustomHeader (intercalate "-" . drop 1 . splitCamel)
|
||||
|
||||
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 ident = isJust <$> lookupHeader (CI.mk . encodeUtf8 $ toPathPiece ident)
|
||||
@ -752,3 +761,27 @@ cachedHere :: Q Exp
|
||||
cachedHere = do
|
||||
loc <- location
|
||||
[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();
|
||||
};
|
||||
|
||||
formUtilities.push({
|
||||
name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
|
||||
selector: REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR,
|
||||
setup: reactiveSubmitButtonUtil,
|
||||
});
|
||||
// skipping reactiveButtonUtil (for now)
|
||||
// the button did not properly re-enable after filling out a form for some safari users.
|
||||
// if maybe in the future there is going to be a proper way of (asynchronously) and
|
||||
// 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
|
||||
<div .container>
|
||||
<dl .deflist>
|
||||
<dt .deflist__dt>Fakultät/Institut
|
||||
<dl .deflist>
|
||||
<dt .deflist__dt>Fakultät/Institut
|
||||
<dd .deflist__dd>
|
||||
<div>
|
||||
#{schoolName}
|
||||
|
||||
$maybe descr <- courseDescription course
|
||||
<dt .deflist__dt>_{MsgCourseDescription}
|
||||
<dd .deflist__dd>
|
||||
<div>
|
||||
#{schoolName}
|
||||
#{descr}
|
||||
|
||||
$maybe descr <- courseDescription course
|
||||
<dt .deflist__dt>_{MsgCourseDescription}
|
||||
$with numlecs <- length lecturers
|
||||
$if numlecs /= 0
|
||||
$if numlecs > 1
|
||||
<dt .deflist__dt>_{MsgLecturersFor}
|
||||
$else
|
||||
<dt .deflist__dt>_{MsgLecturerFor}
|
||||
<dd .deflist__dd>
|
||||
<div>
|
||||
#{descr}
|
||||
|
||||
$with numlecs <- length lecturers
|
||||
$if numlecs /= 0
|
||||
$if numlecs > 1
|
||||
<dt .deflist__dt>_{MsgLecturersFor}
|
||||
$else
|
||||
<dt .deflist__dt>_{MsgLecturerFor}
|
||||
<dd .deflist__dd>
|
||||
<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
|
||||
<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>
|
||||
<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)
|
||||
<dt .deflist__dt>Teilnehmer
|
||||
<dd .deflist__dd>
|
||||
<div>
|
||||
#{participants}
|
||||
$maybe capacity <- courseCapacity course
|
||||
\ von #{capacity}
|
||||
$maybe regFrom <- mRegFrom
|
||||
<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
|
||||
<dt .deflist__dt>Teilnehmer
|
||||
<dd .deflist__dd>
|
||||
<div>
|
||||
#{participants}
|
||||
$maybe capacity <- courseCapacity course
|
||||
\ von #{capacity}
|
||||
$maybe regFrom <- mRegFrom
|
||||
<dt .deflist__dt>Anmeldezeitraum
|
||||
<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}
|
||||
|
||||
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>
|
||||
<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 .tab-group>
|
||||
|
||||
@ -1,20 +1,28 @@
|
||||
$newline never
|
||||
\<!doctype html>
|
||||
\<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en"> <![endif]-->
|
||||
\<!--[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]-->
|
||||
<html lang=#{primaryLanguage}>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>#{pageTitle pc}
|
||||
<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}
|
||||
|
||||
<body .no-js .theme--#{toPathPiece currentTheme} :isAuth:.logged-in>
|
||||
<!-- removes no-js class from body if client supports javascript -->
|
||||
<script>
|
||||
document.body.classList.remove('no-js');
|
||||
|
||||
<body .theme--#{toPathPiece currentTheme} :isAuth:.logged-in>
|
||||
^{pageBody pc}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
$newline never
|
||||
$if not isModal
|
||||
<!-- secondary navigation at the side -->
|
||||
^{asidenav}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
$newline never
|
||||
<section>
|
||||
<h2>_{MsgHomeOpenCourses}
|
||||
^{courseTable}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
$newline never
|
||||
<section>
|
||||
<h2>_{MsgHomeUpcomingSheets}
|
||||
^{sheetTable}
|
||||
|
||||
@ -3,10 +3,10 @@ $newline never
|
||||
$maybe flag <- sortableKey
|
||||
$case directions
|
||||
$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}
|
||||
$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}
|
||||
$nothing
|
||||
^{widget}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
$newline never
|
||||
^{pageBody tbl}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
$newline never
|
||||
$maybe flag <- sortableKey
|
||||
$case directions
|
||||
$of [SortAsc]
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
<div .container>
|
||||
^{table}
|
||||
$newline never
|
||||
^{table}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
$newline never
|
||||
<div #alerts-1 .alerts uw-alerts>
|
||||
<div .alerts__toggler>
|
||||
$forall (status, msg) <- mmsgs
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
$newline never
|
||||
<footer .footer>
|
||||
<div .footer-links>
|
||||
$forall (MenuItem{menuItemType, menuItemRoute = _, menuItemIcon = _, menuItemLabel, menuItemModal = _}, menuIdent, route) <- menuTypes
|
||||
|
||||
@ -14,3 +14,11 @@
|
||||
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}>
|
||||
<i .fas.fa-#{fromMaybe "none" menuItemIcon}>
|
||||
<div .navbar__link-label>_{SomeMessage menuItemLabel}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user