{-# LANGUAGE AllowAmbiguousTypes #-}
{-|
Module: IHP.Job.Dashboard
Description: Auto-generate a dashboard for job types
This module allows IHP applications to generate a dashboard for interacting with job types.
To start, first define a type for the dashboard:
> type MyDashboard = JobsDashboardController NoAuth '[]
And include the following in the 'controllers' list of a FrontController:
> parseRoute @MyDashboard
This generates a dashboard with listings for all tables which have names ending with "_jobs".
All views are fully customizable. For more info, see the documentation for 'DisplayableJob'.
If you implement custom behavior for a job type, add it to the list in the Dashboard type:
> type MyDashboard = JobsDashboardController NoAuth '[EmailUserJob, UpdateRecordJob]
-}
module IHP.Job.Dashboard (
module IHP.Job.Dashboard.View,
module IHP.Job.Dashboard.Auth,
module IHP.Job.Dashboard.Types,
JobsDashboard(..),
DisplayableJob(..),
JobsDashboardController(..),
getTableName,
) where
import IHP.Prelude
import IHP.ModelSupport
import IHP.ControllerPrelude
import Unsafe.Coerce
import IHP.Job.Queue ()
import IHP.Pagination.Types
import qualified Database.PostgreSQL.Simple.Types as PG
import qualified Database.PostgreSQL.Simple.FromField as PG
import qualified Database.PostgreSQL.Simple.ToField as PG
import Network.Wai (requestMethod)
import Network.HTTP.Types.Method (methodPost)
import IHP.Job.Dashboard.Types
import IHP.Job.Dashboard.View
import IHP.Job.Dashboard.Auth
import IHP.Job.Dashboard.Utils
-- | The crazy list of type constraints for this class defines everything needed for a generic "Job".
-- All jobs created through the IHP dev IDE will automatically satisfy these constraints and thus be able to
-- be used as a 'DisplayableJob'.
-- To customize the dashboard behavior for each job, you should provide a custom implementation of 'DisplayableJob'
-- for your job type. Your custom implementations will then be used instead of the defaults.
class ( job ~ GetModelByTableName (GetTableName job)
, FilterPrimaryKey (GetTableName job)
, FromRow job
, Show (PrimaryKey (GetTableName job))
, PG.FromField (PrimaryKey (GetTableName job))
, PG.ToField (PrimaryKey (GetTableName job))
, KnownSymbol (GetTableName job)
, HasField "id" job (Id job)
, HasField "status" job JobStatus
, HasField "updatedAt" job UTCTime
, HasField "createdAt" job UTCTime
, HasField "lastError" job (Maybe Text)
, CanUpdate job
, CanCreate job
, Record job
, Show job
, Eq job
, Table job
, Typeable job) => DisplayableJob job where
-- | How this job's section should be displayed in the dashboard. By default it's displayed as a table,
-- but this can be any arbitrary view! Make some cool graphs :)
makeDashboardSection :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO SomeView
makePageView :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Int -> Int -> IO SomeView
-- | The content of the page that will be displayed for a detail view of this job.
-- By default, the ID, Status, Created/Updated at times, and last error are displayed.
-- Can be defined as any arbitrary view.
makeDetailView :: (?context :: ControllerContext, ?modelContext :: ModelContext) => job -> IO SomeView
makeDetailView job = do
pure $ SomeView $ HtmlView $ renderBaseJobDetailView (buildBaseJob job)
-- | The content of the page that will be displayed for the "new job" form of this job.
-- By default, only the submit button is rendered. For additonal form data, define your own implementation.
-- Can be defined as any arbitrary view, but it should be a form.
makeNewJobView :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO SomeView
makeNewJobView = pure $ SomeView $ HtmlView $ renderNewBaseJobForm $ tableName @job
-- | The action run to create and insert a new value of this job into the database.
-- By default, create an empty record and insert it.
-- To add more data, define your own implementation.
createNewJob :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO ()
createNewJob = do
newRecord @job |> create
pure ()
-- | Defines implementations for actions for acting on a dashboard made of some list of types.
-- This is included to allow these actions to recurse on the types, isn't possible in an IHP Controller
-- action implementation.
--
-- Later functions and typeclasses introduce constraints on the types in this list,
-- so you'll get a compile error if you try and include a type that is not a job.
class JobsDashboard (jobs :: [Type]) where
-- | Creates the entire dashboard by recursing on the type list and calling 'makeDashboardSection' on each type.
makeDashboard :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO SomeView
includedJobTables :: [Text]
-- | Renders the index page, which is the view returned from 'makeDashboard'.
indexPage :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO ()
listJob :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Text -> IO ()
listJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Bool -> IO ()
-- | Renders the detail view page. Rescurses on the type list to find a type with the
-- same table name as the "tableName" query parameter.
viewJob :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Text -> UUID -> IO ()
viewJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Bool -> IO ()
-- | If performed in a POST request, creates a new job depending on the "tableName" query parameter.
-- If performed in a GET request, renders the new job from depending on said parameter.
newJob :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Text -> IO ()
newJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Bool -> IO ()
-- | Deletes a job from the database.
deleteJob :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Text -> UUID -> IO ()
deleteJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Bool -> IO ()
retryJob :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Text -> UUID -> IO ()
retryJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO ()
-- If no types are passed, try to get all tables dynamically and render them as BaseJobs
instance JobsDashboard '[] where
-- | Invoked at the end of recursion
makeDashboard = pure $ SomeView $ HtmlView [hsx|
|]
includedJobTables = []
indexPage = do
tableNames <- getAllTableNames
tables <- mapM buildBaseJobTable tableNames
render $ SomeView tables
where
getAllTableNames = map extractText <$> sqlQuery
"SELECT table_name FROM information_schema.tables WHERE table_name LIKE '%_jobs'" ()
listJob = error "listJob: Requested job type not in JobsDashboard Type"
listJob' _ = do
let table = param "tableName"
options = defaultPaginationOptions
page = paramOrDefault 1 "page"
pageSize = paramOrDefault (maxItems options) "maxItems"
totalItems <- totalRecordsForTable table
jobs <- queryBaseJobsFromTablePaginated table (page - 1) pageSize
let pagination = Pagination { currentPage = page, totalItems, pageSize, window = windowSize options }
render $ HtmlView $ renderBaseJobTablePaginated table jobs pagination
viewJob = error "viewJob: Requested job type not in JobsDashboard Type"
viewJob' _ = do
baseJob <- queryBaseJob (param "tableName") (param "id")
render $ HtmlView $ renderBaseJobDetailView baseJob
newJob = error "newJob: Requested job type not in JobsDashboard Type"
newJob' _ = do
if requestMethod request == methodPost
then do
insertJob
setSuccessMessage (columnNameToFieldLabel (param "tableName") <> " job started.")
redirectTo ListJobsAction
else render $ HtmlView $ renderNewBaseJobForm (param "tableName")
where insertJob = sqlExec (PG.Query $ "INSERT into " <> param "tableName" <> " DEFAULT VALUES") ()
deleteJob = error "deleteJob: Requested job type not in JobsDashboard Type"
deleteJob' _ = do
let id :: UUID = param "id"
table :: Text = param "tableName"
delete id table
setSuccessMessage (columnNameToFieldLabel table <> " record deleted.")
redirectTo ListJobsAction
where delete id table = sqlExec (PG.Query $ cs $ "DELETE FROM " <> table <> " WHERE id = ?") (Only id)
retryJob = error "retryJob: Requested job type not in JobsDashboard Type"
retryJob' = do
let id :: UUID = param "id"
table :: Text = param "tableName"
retryJobById table id
setSuccessMessage (columnNameToFieldLabel table <> " record marked as 'retry'.")
redirectTo ListJobsAction
where retryJobById table id = sqlExec ("UPDATE ? SET status = 'job_status_retry' WHERE id = ?") (PG.Identifier table, id)
-- | Defines the default implementation for a dashboard of a list of job types.
-- We know the current job is a 'DisplayableJob', and we can recurse on the rest of the list to build the rest of the dashboard.
-- You probably don't want to provide custom implementations for these. Read the documentation for each of the functions if
-- you'd like to know how to customize the behavior. They mostly rely on the functions from 'DisplayableJob'.
instance {-# OVERLAPPABLE #-} (DisplayableJob job, JobsDashboard rest) => JobsDashboard (job:rest) where
-- | Recusively create a list of views that are concatenated together as 'SomeView's to build the dashboard.
-- To customize, override 'makeDashboardSection' for each job.
makeDashboard = do
section <- makeDashboardSection @job
restSections <- SomeView <$> makeDashboard @rest
pure $ SomeView (section : [restSections])
-- | Recursively build list of included table names
includedJobTables = tableName @job : includedJobTables @rest
-- | Build the dashboard and render it.
indexPage = do
dashboardIncluded <- makeDashboard @(job:rest)
notIncluded <- getNotIncludedTableNames (includedJobTables @(job:rest))
baseJobTables <- mapM buildBaseJobTable notIncluded
render $ dashboardIncluded : baseJobTables
listJob table = do
let page = fromMaybe 1 $ param "page"
page <- makePageView @job page 25
render page
listJob' isFirstTime = do
let table = param "tableName"
when isFirstTime $ do
notIncluded <- getNotIncludedTableNames (includedJobTables @(job:rest))
when (table `elem` notIncluded) (listJob' @'[] False)
if tableName @job == table
then listJob @(job:rest) table
else listJob' @rest False
-- | View the detail page for the job with a given uuid.
viewJob _ uuid = do
let id :: Id job = unsafeCoerce uuid
j <- fetch id
view <- makeDetailView @job j
render view
-- | For a given "tableName" parameter, try and recurse over the list of types
-- in order to find a type with the some table name as the parameter.
-- If one is found, attempt to construct an ID from the "id" parameter,
-- and render a page using the type's implementation of 'makeDetailView'.
-- If you want to customize the page, override that function instead.
viewJob' isFirstTime = do
let table = param "tableName"
when isFirstTime $ do
notIncluded <- getNotIncludedTableNames (includedJobTables @(job:rest))
when (table `elem` notIncluded) (viewJob' @'[] False)
if tableName @job == table
then viewJob @(job:rest) table (param "id")
else viewJob' @rest False
-- For POST, create a new job using the job's implementation of 'createNewJob'.
-- To include other request data and parameters, override that function, not this one.
-- If it's a GET request, render a new job form with the job's implementation of 'makeNewJobView'.
-- For customizing this form, override 'makeNewJobView'.
newJob tableName = do
if requestMethod request == methodPost
then do
createNewJob @job
setSuccessMessage (columnNameToFieldLabel tableName <> " job started.")
redirectTo ListJobsAction
else do
view <- makeNewJobView @job
render view
-- | For a given "tableName" parameter, try and recurse over the list of types
-- in order to find a type with the some table name as the parameter.
-- If such a type is found, call newJob.
newJob' isFirstTime = do
let table = param "tableName"
when isFirstTime $ do
notIncluded <- getNotIncludedTableNames (includedJobTables @(job:rest))
when (table `elem` notIncluded) (newJob' @'[] False)
if tableName @job == table
then newJob @(job:rest) table
else newJob' @rest False
-- | Delete job in 'table' with ID 'uuid'.
deleteJob table uuid = do
let id :: Id job = unsafeCoerce uuid
deleteRecordById @job id
setSuccessMessage (columnNameToFieldLabel table <> " record deleted.")
redirectTo ListJobsAction
-- | For a given "tableName" parameter, try and recurse over the list of types
-- in order to find a type with the some table name as the parameter.
-- If one is found, delete the record with the given id.
deleteJob' isFirstTime = do
let table = param "tableName"
when isFirstTime $ do
notIncluded <- getNotIncludedTableNames (includedJobTables @(job:rest))
when (table `elem` notIncluded) (deleteJob' @'[] False)
if tableName @job == table
then deleteJob @(job:rest) table (param "id")
else deleteJob' @rest False
retryJob table uuid = do
let id :: UUID = param "id"
table :: Text = param "tableName"
retryJobById table id = sqlExec ("UPDATE ? SET status = 'job_status_retry' WHERE id = ?") (PG.Identifier table, id)
retryJobById table id
setSuccessMessage (columnNameToFieldLabel table <> " record marked as 'retry'.")
redirectTo ListJobsAction
retryJob' = do
let table = param "tableName"
if tableName @job == table
then retryJob @(job:rest) table (param "id")
else retryJob' @rest
extractText = \(Only t) -> t
getNotIncludedTableNames includedNames = map extractText <$> sqlQuery
"SELECT table_name FROM information_schema.tables WHERE table_name LIKE '%_jobs' AND table_name NOT IN ?"
(Only $ In $ includedNames)
buildBaseJobTable :: (?modelContext :: ModelContext, ?context :: ControllerContext) => Text -> IO SomeView
buildBaseJobTable tableName = do
baseJobs <- sqlQuery (PG.Query $ cs $ queryString) (Only tableName)
baseJobs
|> renderBaseJobTable tableName
|> HtmlView
|> SomeView
|> pure
where
queryString = "SELECT ?, id, status, updated_at, created_at, last_error FROM "
<> tableName
<> " ORDER BY created_at DESC LIMIT 10"
buildBaseJob :: forall job. (DisplayableJob job) => job -> BaseJob
buildBaseJob job = BaseJob
(tableName @job)
(unsafeCoerce $ job.id) -- model Id type -> UUID. Pls don't use integer IDs for your jobs :)
(job.status)
(job.updatedAt)
(job.createdAt)
(job.lastError)
-- | We can't always access the type of our job in order to use type application syntax for 'tableName'.
-- This is just a convinence function for those cases.
getTableName :: forall job. (DisplayableJob job) => job -> Text
getTableName _ = tableName @job
-- | Get the job with in the given table with the given ID as a 'BaseJob'.
queryBaseJob :: (?modelContext :: ModelContext) => Text -> UUID -> IO BaseJob
queryBaseJob table id = do
(job : _) <- sqlQuery
(PG.Query $ cs $ "select ?, id, status, updated_at, created_at, last_error from " <> table <> " where id = ?")
[table, tshow id]
pure job
queryBaseJobsFromTablePaginated :: (?modelContext :: ModelContext) => Text -> Int -> Int -> IO [BaseJob]
queryBaseJobsFromTablePaginated table page pageSize =
sqlQuery
(PG.Query $ cs $ "select ?, id, status, updated_at, created_at, last_error from " <> table <> " OFFSET " <> tshow (page * pageSize) <> " LIMIT " <> tshow pageSize)
(Only table)
instance (JobsDashboard jobs, AuthenticationMethod authType) => Controller (JobsDashboardController authType jobs) where
beforeAction = authenticate @authType
action ListJobsAction = autoRefresh $ indexPage @jobs
action ListJobAction' = autoRefresh $ listJob' @jobs True
action ViewJobAction' = autoRefresh $ viewJob' @jobs True
action CreateJobAction' = newJob' @jobs True
action DeleteJobAction' = deleteJob' @jobs True
action RetryJobAction' = retryJob' @jobs
action _ = error "Cannot call this action directly. Call the backtick function with no parameters instead."