diff --git a/IHP/Job/Dashboard.hs b/IHP/Job/Dashboard.hs
new file mode 100644
--- /dev/null
+++ b/IHP/Job/Dashboard.hs
@@ -0,0 +1,426 @@
+{-# 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 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.Hasql.FromRow (FromRowHasql)
+import IHP.Job.Dashboard.Utils
+import qualified Hasql.Implicits.Encoders
+import qualified Hasql.Decoders as Decoders
+import qualified Hasql.DynamicStatements.Snippet as Snippet
+
+-- | 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)
+    , FromRowHasql job
+    , Show (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
+    , Hasql.Implicits.Encoders.DefaultParamEncoder (Id' (GetTableName 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, ?request :: Request) => IO SomeView
+
+    includedJobTables :: [Text]
+
+    -- | Renders the index page, which is the view returned from 'makeDashboard'.
+    indexPage :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
+
+    listJob :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Text -> IO ()
+    listJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => 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, ?respond :: Respond, ?request :: Request) => Text -> UUID -> IO ()
+    viewJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => 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, ?respond :: Respond, ?request :: Request) => Text -> IO ()
+    newJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Bool -> IO ()
+
+    -- | Deletes a job from the database.
+    deleteJob :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Text -> UUID -> IO ()
+    deleteJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Bool -> IO ()
+
+    retryJob :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Text -> UUID -> IO ()
+    retryJob' :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => 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|
+        <script>
+            function initPopover() {
+                $('[data-bs-toggle="popover"]').popover({ trigger: 'hover click' })
+            }
+            $(document).on('ready turbolinks:load', initPopover);
+            $(initPopover);
+        </script>
+        <style>
+        .popover-body {
+            background-color: #01313f;
+            color: rgb(147, 161, 161);
+            font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace;
+            font-size: 11px;
+        }
+        </style>
+    |]
+
+    includedJobTables = []
+
+    indexPage = do
+        tableNames <- getAllTableNames
+        tables <- mapM buildBaseJobTable tableNames
+        render $ SomeView tables
+        where
+            getAllTableNames = sqlQueryHasql getHasqlPool
+                (Snippet.sql "SELECT table_name::text FROM information_schema.tables WHERE table_name LIKE '%_jobs'")
+                (Decoders.rowList (Decoders.column (Decoders.nonNullable Decoders.text)))
+
+    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 = do
+                let tableName = param "tableName"
+                sqlExecHasql getHasqlPool (Snippet.sql "INSERT INTO " <> sqlIdentifier tableName <> Snippet.sql " 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 = sqlExecHasql getHasqlPool
+                (Snippet.sql "DELETE FROM " <> sqlIdentifier table <> Snippet.sql " WHERE id = " <> Snippet.param 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 = sqlExecHasql getHasqlPool
+                (Snippet.sql "UPDATE " <> sqlIdentifier table <> Snippet.sql " SET status = 'job_status_retry' WHERE id = " <> Snippet.param 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"
+        sqlExecHasql getHasqlPool
+            (Snippet.sql "UPDATE " <> sqlIdentifier table <> Snippet.sql " SET status = 'job_status_retry' WHERE id = " <> Snippet.param 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
+
+jobStatusDecoder :: Decoders.Value JobStatus
+jobStatusDecoder = Decoders.enum (Just "public") "job_status" \case
+    "job_status_not_started" -> Just JobStatusNotStarted
+    "job_status_running" -> Just JobStatusRunning
+    "job_status_failed" -> Just JobStatusFailed
+    "job_status_timed_out" -> Just JobStatusTimedOut
+    "job_status_succeeded" -> Just JobStatusSucceeded
+    "job_status_retry" -> Just JobStatusRetry
+    _ -> Nothing
+
+baseJobDecoder :: Decoders.Row BaseJob
+baseJobDecoder = BaseJob
+    <$> Decoders.column (Decoders.nonNullable Decoders.text)        -- table
+    <*> Decoders.column (Decoders.nonNullable Decoders.uuid)        -- id
+    <*> Decoders.column (Decoders.nonNullable jobStatusDecoder)     -- status
+    <*> Decoders.column (Decoders.nonNullable Decoders.timestamptz) -- updatedAt
+    <*> Decoders.column (Decoders.nonNullable Decoders.timestamptz) -- createdAt
+    <*> Decoders.column (Decoders.nullable Decoders.text)           -- lastError
+
+getNotIncludedTableNames :: (?modelContext :: ModelContext) => [Text] -> IO [Text]
+getNotIncludedTableNames includedNames = sqlQueryHasql getHasqlPool
+    (Snippet.sql "SELECT table_name::text FROM information_schema.tables WHERE table_name LIKE '%_jobs' AND NOT (table_name = ANY(" <> Snippet.param includedNames <> Snippet.sql "))")
+    (Decoders.rowList (Decoders.column (Decoders.nonNullable Decoders.text)))
+
+buildBaseJobTable :: (?modelContext :: ModelContext, ?context :: ControllerContext, ?request :: Request) => Text -> IO SomeView
+buildBaseJobTable tableName = do
+    baseJobs <- sqlQueryHasql getHasqlPool
+        (Snippet.sql "SELECT " <> Snippet.param tableName <> Snippet.sql ", id, status, updated_at, created_at, last_error FROM "
+            <> sqlIdentifier tableName <> Snippet.sql " ORDER BY created_at DESC LIMIT 10")
+        (Decoders.rowList baseJobDecoder)
+    baseJobs
+        |> renderBaseJobTable tableName
+        |> HtmlView
+        |> SomeView
+        |> pure
+
+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 = sqlQueryHasql getHasqlPool
+    (Snippet.sql "SELECT " <> Snippet.param table <> Snippet.sql ", id, status, updated_at, created_at, last_error FROM "
+        <> sqlIdentifier table <> Snippet.sql " WHERE id = " <> Snippet.param id)
+    (Decoders.singleRow baseJobDecoder)
+
+queryBaseJobsFromTablePaginated :: (?modelContext :: ModelContext) => Text -> Int -> Int -> IO [BaseJob]
+queryBaseJobsFromTablePaginated table page pageSize = sqlQueryHasql getHasqlPool
+    (Snippet.sql "SELECT " <> Snippet.param table <> Snippet.sql ", id, status, updated_at, created_at, last_error FROM "
+        <> sqlIdentifier table <> Snippet.sql " OFFSET " <> Snippet.param (page * pageSize) <> Snippet.sql " LIMIT " <> Snippet.param pageSize)
+    (Decoders.rowList baseJobDecoder)
+
+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."
diff --git a/IHP/Job/Dashboard/Auth.hs b/IHP/Job/Dashboard/Auth.hs
new file mode 100644
--- /dev/null
+++ b/IHP/Job/Dashboard/Auth.hs
@@ -0,0 +1,52 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+
+{-|
+Module: IHP.Job.Dashboard.Auth
+Description:  Authentication for Job dashboard
+-}
+module IHP.Job.Dashboard.Auth (
+    AuthenticationMethod(..),
+    NoAuth(..),
+    BasicAuth(..),
+    BasicAuthStatic(..),
+) where
+
+import IHP.Prelude
+import IHP.ControllerPrelude
+import qualified IHP.EnvVar as EnvVar
+
+-- | Defines one method, 'authenticate', called before every action. Use to authenticate user.
+--
+-- Three implementations are provided:
+-- - 'NoAuth' : No authentication
+-- - 'BasicAuth' : HTTP Basic Auth using environment variables
+-- - 'BasicAuthStatic' : HTTP Basic Auth using static values
+--
+-- Define your own implementation to use custom authentication for production.
+class AuthenticationMethod a where
+    authenticate :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?request :: Request) => IO ()
+
+-- | Don't use any authentication for jobs.
+data NoAuth
+
+-- | Authenticate using HTTP Basic Authentication by looking up username/password values
+-- in environment variables given as type-level strings.
+data BasicAuth (userEnv :: Symbol) (passEnv :: Symbol)
+
+-- | Authenticate using HTTP Basic Authentication using username/password given as type level strings.
+-- Meant for development only!
+data BasicAuthStatic (user :: Symbol) (pass :: Symbol)
+
+instance AuthenticationMethod NoAuth where
+    authenticate = pure ()
+
+instance (KnownSymbol userEnv, KnownSymbol passEnv) => AuthenticationMethod (BasicAuth userEnv passEnv) where
+    authenticate = do
+        creds <- (,) <$> EnvVar.envOrNothing (symbolToByteString @userEnv) <*> EnvVar.envOrNothing (symbolToByteString @passEnv)
+        case creds of
+            (Just user, Just pass) -> basicAuth user pass "jobs"
+            _ -> error "Did not find HTTP Basic Auth credentials for Jobs Dashboard."
+
+instance (KnownSymbol user, KnownSymbol pass) => AuthenticationMethod (BasicAuthStatic user pass) where
+    authenticate = basicAuth (cs $ symbolVal $ Proxy @user) (cs $ symbolVal $ Proxy @pass) "jobs"
+
diff --git a/IHP/Job/Dashboard/Types.hs b/IHP/Job/Dashboard/Types.hs
new file mode 100644
--- /dev/null
+++ b/IHP/Job/Dashboard/Types.hs
@@ -0,0 +1,106 @@
+{-|
+Module: IHP.Job.Dashboard.Types
+Description:  Types for Job dashboard
+-}
+{-# LANGUAGE AllowAmbiguousTypes #-}
+module IHP.Job.Dashboard.Types (
+    BaseJob(..),
+    JobsDashboardController(..),
+    TableViewable(..),
+    IncludeWrapper(..),
+) where
+
+import IHP.Prelude
+import IHP.ControllerPrelude
+import IHP.ViewPrelude (Html)
+import IHP.RouterPrelude hiding (get, tshow, error, map, putStrLn, elem)
+data BaseJob = BaseJob {
+    table :: Text
+  , id :: UUID
+  , status :: JobStatus
+  , updatedAt :: UTCTime
+  , createdAt :: UTCTime
+  , lastError :: Maybe Text
+} deriving (Show)
+
+class TableViewable a where
+    -- | Human readable title displayed on the table
+    tableTitle :: Text
+
+    -- | Database table backing the view
+    modelTableName :: Text
+
+    tableHeaders :: [Text]
+    renderTableRow :: a -> Html
+
+    -- | Link used in the table to send user to new job form
+    newJobLink :: Html
+
+    -- | Gets records for displaying in the dashboard index page
+    getIndex :: (?context :: ControllerContext, ?modelContext :: ModelContext) => IO [a]
+
+    -- | Gets paginated records for displaying in the list page
+    getPage :: (?context :: ControllerContext, ?modelContext :: ModelContext) => Int -> Int -> IO [a]
+
+
+-- | Often, jobs are related to some model type. These relations are modeled through the type system.
+-- For example, the type 'Include "userId" UpdateUserJob' models an 'UpdateUserJob' type that can access
+-- the 'User' it belongs to through the 'userId' field.
+-- For some reason, GHC doesn't allow us to create implementations of type family applications, so the following doesn't work:
+--
+-- > instance DisplayableJob (Include "userId" UpdateUserJob) where
+--
+-- However, if we wrap this in a concrete type, it works fine. That's what this wrapper is for.
+-- To get the same behavior as above, just define
+--
+-- > instance DisplayableJob (IncludeWrapper "userId" UpdateUserJob) where
+--
+-- and wrap the values as so:
+--
+-- > jobsWithUsers <- query @UpdateUserJob
+-- >    |> fetch
+-- >    >>= mapM (fetchRelated #userId)
+-- >    >>= pure . map (IncludeWrapper @"userId" @UpdateUserJob)
+newtype IncludeWrapper (id :: Symbol) job = IncludeWrapper (Include id job)
+
+-- | Defines controller actions for acting on a dashboard made of some list of types.
+-- 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.
+data JobsDashboardController authType (jobs :: [Type])
+    = ListJobsAction
+    | ListJobAction { jobTableName :: Text, page :: Int }
+    -- These actions are used for 'pathTo'. Need  to pass the parameters explicity to know how to build the path
+    | ViewJobAction { jobTableName :: Text, jobId :: UUID }
+    | CreateJobAction { jobTableName :: Text }
+    | DeleteJobAction { jobTableName :: Text, jobId :: UUID }
+    | RetryJobAction { jobTableName :: Text, jobId :: UUID }
+
+    -- These actions are used for interal routing. Parameters are extracted dynamically in the action based on types
+    | ListJobAction'
+    | ViewJobAction'
+    | CreateJobAction'
+    | DeleteJobAction'
+    | RetryJobAction'
+    deriving (Show, Eq, Data)
+
+
+instance HasPath (JobsDashboardController authType jobs) where
+    pathTo ListJobsAction = "/jobs/ListJobs"
+    pathTo ListJobAction   { .. } = "/jobs/ListJob?tableName=" <> jobTableName <> "&page=" <> tshow page
+    pathTo ViewJobAction   { .. } = "/jobs/ViewJob?tableName=" <> jobTableName <> "&id=" <> tshow jobId
+    pathTo CreateJobAction { .. } = "/jobs/CreateJob?tableName=" <> jobTableName
+    pathTo DeleteJobAction { .. } = "/jobs/DeleteJob?tableName=" <> jobTableName <> "&id=" <> tshow jobId
+    pathTo RetryJobAction  { .. } = "/jobs/RetryJob?tableName=" <> jobTableName <> "&id=" <> tshow jobId
+    pathTo _ = error "pathTo for internal JobsDashboard functions not supported. Use non-backtick action and pass necessary parameters to use pathTo."
+
+instance CanRoute (JobsDashboardController authType jobs) where
+    parseRoute' = do
+        (string "/jobs" <* endOfInput >> pure ListJobsAction)
+        <|> (string "/jobs/" <* endOfInput >> pure ListJobsAction)
+        <|> (string "/jobs/ListJobs" <* endOfInput >> pure ListJobsAction)
+        <|> (string "/jobs/ListJob" <* endOfInput >> pure ListJobAction')
+        <|> (string "/jobs/ViewJob" <* endOfInput >> pure ViewJobAction')
+        <|> (string "/jobs/CreateJob" <* endOfInput >> pure CreateJobAction')
+        <|> (string "/jobs/DeleteJob" <* endOfInput >> pure DeleteJobAction')
+        <|> (string "/jobs/RetryJob" <* endOfInput >> pure RetryJobAction')
+
diff --git a/IHP/Job/Dashboard/Utils.hs b/IHP/Job/Dashboard/Utils.hs
new file mode 100644
--- /dev/null
+++ b/IHP/Job/Dashboard/Utils.hs
@@ -0,0 +1,29 @@
+module IHP.Job.Dashboard.Utils where
+
+import IHP.Prelude
+import IHP.ModelSupport
+import qualified Hasql.Decoders as Decoders
+import qualified Hasql.DynamicStatements.Snippet as Snippet
+import qualified Hasql.Pool as HasqlPool
+import qualified Data.Text as Text
+
+-- | Safely quote a SQL identifier (table name) by escaping double quotes.
+sqlIdentifier :: Text -> Snippet.Snippet
+sqlIdentifier name = Snippet.sql ("\"" <> Text.replace "\"" "\"\"" name <> "\"")
+
+-- | Get the hasql pool from the model context.
+getHasqlPool :: (?modelContext :: ModelContext) => HasqlPool.Pool
+getHasqlPool = ?modelContext.hasqlPool
+
+numberOfPagesForTable :: (?modelContext::ModelContext) => Text -> Int -> IO Int
+numberOfPagesForTable table pageSize = do
+    totalRecords <- totalRecordsForTable table
+    pure $ case totalRecords `quotRem` pageSize of
+        (pages, 0) -> pages
+        (pages, _) -> pages + 1
+
+totalRecordsForTable :: (?modelContext :: ModelContext) => Text -> IO Int
+totalRecordsForTable table =
+    fromIntegral <$> sqlQueryHasql getHasqlPool
+        (Snippet.sql "SELECT COUNT(*) FROM " <> sqlIdentifier table)
+        (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.int8)))
diff --git a/IHP/Job/Dashboard/View.hs b/IHP/Job/Dashboard/View.hs
new file mode 100644
--- /dev/null
+++ b/IHP/Job/Dashboard/View.hs
@@ -0,0 +1,305 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE GADTs #-}
+
+{-|
+Module: IHP.Job.Dashboard.View
+Description:  Views for Job dashboard
+-}
+module IHP.Job.Dashboard.View where
+
+import IHP.Prelude
+import IHP.ViewPrelude (JobStatus(..), ControllerContext, Html, View, hsx, html, timeAgo)
+import qualified Data.List as List
+import IHP.Job.Dashboard.Types
+import IHP.Job.Dashboard.Utils
+import IHP.Pagination.Types
+import IHP.Pagination.ViewFunctions
+
+-- | Provides a type-erased view. This allows us to specify a view as a return type without needed
+-- to know exactly what type the view will be, which in turn allows for custom implmentations of
+-- almost all the view functions in this module. Go GADTs!
+data SomeView where
+    SomeView :: forall a. (View a) => a -> SomeView
+
+-- | Since the only constructor for 'SomeView' requires that it is passed a 'View', we can use
+-- that to implement a 'View' instance for 'SomeView'
+instance View SomeView where
+    html (SomeView a) = let ?view = a in IHP.ViewPrelude.html a
+
+-- | Define how to render a list of views as a view. Just concatenate them together!
+instance (View a) => View [a] where
+    html [] = [hsx||]
+    html (x:xs) =
+        -- need to nest let's here in order to satisfy the implicit ?view parameter for 'html'.
+        -- ?view needs to be the type of the view being rendered, so set it before each render
+        -- here we render single view
+        let ?view = x in
+            let current = IHP.ViewPrelude.html x in
+                -- now rendering a list view
+                let ?view = xs in
+                    let rest = IHP.ViewPrelude.html xs in
+                        [hsx|{current}{rest}|]
+
+-- | A view containing no data. Used occasionally as a default implementation for some functions.
+data EmptyView = EmptyView
+instance View EmptyView where
+    html _ = [hsx||]
+
+-- | A view constructed from some HTML.
+newtype HtmlView = HtmlView Html
+instance View HtmlView where
+    html (HtmlView html) = [hsx|{html}|]
+
+renderStatus job = case job.status of
+    JobStatusNotStarted -> [hsx|<span class="badge text-bg-secondary">Not Started</span>|]
+    JobStatusRunning -> [hsx|<span class="badge text-bg-primary">Running</span>|]
+    JobStatusFailed -> [hsx|<span class="badge text-bg-danger" title="Last Error" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="left" data-bs-content={fromMaybe "" (job.lastError)}>Failed</span>|]
+    JobStatusSucceeded -> [hsx|<span class="badge text-bg-success">Succeeded</span>|]
+    JobStatusRetry -> [hsx|<span class="badge text-bg-warning" title="Last Error" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="left" data-bs-content={fromMaybe "" (job.lastError)}>Retry</span>|]
+    JobStatusTimedOut -> [hsx|<span class="badge text-bg-danger" >Timed Out</span>|]
+
+-- BASE JOB VIEW HELPERS --------------------------------
+
+renderBaseJobTable :: Text -> [BaseJob] -> Html
+renderBaseJobTable table rows =
+    let
+        headers :: [Text] = ["ID", "Updated At", "Status", "", ""]
+        humanTitle = table |> columnNameToFieldLabel
+    in [hsx|
+    <div>
+        <div class="d-flex justify-content-between align-items-center">
+            <h3>{humanTitle}</h3>
+            {renderNewBaseJobLink table}
+        </div>
+        <table class="table table-sm table-hover">
+            <thead>
+                <tr>
+                    {forEach headers renderHeader}
+                </tr>
+            </thead>
+
+            <tbody>
+                {forEach rows renderBaseJobTableRow}
+            </tbody>
+        </table>
+        <a href={ListJobAction table 1} class="link-primary">See all {humanTitle}</a>
+        <hr />
+    </div>
+|]
+    where renderHeader field = [hsx|<th>{field}</th>|]
+
+renderBaseJobTablePaginated :: Text -> [BaseJob] -> Pagination -> Html
+renderBaseJobTablePaginated table jobs pagination =
+    let
+        headers :: [Text] = ["ID", "Updated At", "Status", "", ""]
+        lastJobIndex = (List.length jobs) - 1
+    in
+        [hsx|
+            <div>
+                <div class="d-flex justify-content-between align-items-center">
+                    <h3>{table |> columnNameToFieldLabel}</h3>
+                    {renderNewBaseJobLink table}
+                </div>
+                <table class="table table-sm table-hover">
+                    <thead>
+                        <tr>
+                            {forEach headers renderHeader}
+                        </tr>
+                    </thead>
+
+                    <tbody>
+                        {forEach jobs renderBaseJobTableRow}
+                    </tbody>
+                </table>
+            </div>
+            {renderPagination pagination}
+        |]
+    where
+        renderHeader field = [hsx|<th>{field}</th>|]
+
+renderBaseJobTableRow :: BaseJob -> Html
+renderBaseJobTableRow job = [hsx|
+        <tr>
+            <td>{job.id}</td>
+            <td>{job.updatedAt |> timeAgo}</td>
+            <td>{renderStatus job}</td>
+            <td><a href={ViewJobAction (job.table) (job.id)} class="text-primary">Show</a></td>
+            <td>
+                <form action={RetryJobAction (job.table) (job.id)} method="POST">
+                    <button type="submit" style={retryButtonStyle} class="btn btn-link text-secondary">Retry</button>
+                </form>
+            </td>
+        </tr>
+    |]
+
+-- | Link included in table to create a new job.
+renderNewBaseJobLink :: Text -> Html
+renderNewBaseJobLink table =
+    let
+        link = "/jobs/CreateJob?tableName=" <> table
+    in [hsx|
+        <form action={link}>
+            <button type="submit" class="btn btn-primary btn-sm">+ New Job</button>
+        </form>
+    |]
+
+renderNewBaseJobForm :: Text -> Html
+renderNewBaseJobForm table = [hsx|
+    <br>
+        <h5>New Job: {table}</h5>
+    <br>
+    <form action="/jobs/CreateJob" method="POST">
+        <input type="hidden" id="tableName" name="tableName" value={table}>
+        <button type="submit" class="btn btn-primary">New Job</button>
+    </form>
+|]
+
+renderBaseJobDetailView :: BaseJob -> Html
+renderBaseJobDetailView job = let table = job.table in [hsx|
+    <br>
+        <h5>Viewing Job {job.id} in {table |> columnNameToFieldLabel}</h5>
+    <br>
+    <table class="table">
+        <tbody>
+            <tr>
+                <th>Updated At</th>
+                <td>{job.updatedAt |> timeAgo} ({job.updatedAt})</td>
+            </tr>
+            <tr>
+                <th>Created At</th>
+                <td>{job.createdAt |> timeAgo} ({job.createdAt})</td>
+            </tr>
+            <tr>
+                <th>Status</th>
+                <td>{renderStatus job}</td>
+            </tr>
+            <tr>
+                <th>Last Error</th>
+                <td>{fromMaybe "No error" (job.lastError)}</td>
+            </tr>
+        </tbody>
+    </table>
+
+    <div class="d-flex flex-row">
+        <form class="me-2" action="/jobs/DeleteJob" method="POST">
+            <input type="hidden" id="tableName" name="tableName" value={table}>
+            <input type="hidden" id="id" name="id" value={tshow $ job.id}>
+            <button type="submit" class="btn btn-danger">Delete</button>
+        </form>
+        <form action="/jobs/RetryJob" method="POST">
+            <input type="hidden" id="tableName" name="tableName" value={table}>
+            <input type="hidden" id="id" name="id" value={tshow $ job.id}>
+            <button type="submit" class="btn btn-primary">Run again</button>
+        </form>
+    </div>
+|]
+------------------------------------------------------------------
+
+-- TABLE VIEWABLE view helpers -----------------------------------
+makeDashboardSectionFromTableViewable :: forall a. (TableViewable a
+    , ?context :: ControllerContext
+    , ?modelContext :: ModelContext) => IO SomeView
+makeDashboardSectionFromTableViewable = do
+    indexRows <- getIndex @a
+    pure $ SomeView $ HtmlView $ renderTableViewableTable indexRows
+
+renderTableViewableTable :: forall a. TableViewable a => [a] -> Html
+renderTableViewableTable rows = let
+        headers = tableHeaders @a
+        title = tableTitle @a
+        link = newJobLink @a
+        renderRow = renderTableRow @a
+        table = modelTableName @a
+    in [hsx|
+    <div>
+        <div class="d-flex justify-content-between align-items-center">
+            <h3>{title}</h3>
+            {link}
+        </div>
+        <table class="table table-sm table-hover">
+            <thead>
+                <tr>
+                    {forEach headers renderHeader}
+                </tr>
+            </thead>
+
+            <tbody>
+                {forEach rows renderRow}
+            </tbody>
+        </table>
+        <a href={ListJobAction table 1} class="link-primary">See all {title}</a>
+        <hr />
+    </div>
+|]
+    where renderHeader field = [hsx|<th>{field}</th>|]
+
+
+
+makeListPageFromTableViewable :: forall a. (TableViewable a, ?context :: ControllerContext, ?modelContext :: ModelContext) => Int -> Int -> IO SomeView
+makeListPageFromTableViewable page pageSize = do
+    pageData <- getPage @a (page - 1) pageSize
+    numPages <- numberOfPagesForTable (modelTableName @a) pageSize
+    pure $ SomeView $ HtmlView $ renderTableViewableTablePaginated pageData page numPages
+
+renderTableViewableTablePaginated :: forall a. TableViewable a => [a] -> Int -> Int -> Html
+renderTableViewableTablePaginated jobs page totalPages =
+    let
+        title = tableTitle @a
+        table = modelTableName @a
+        headers = tableHeaders @a
+        lastJobIndex = (List.length jobs) - 1
+        newLink = newJobLink @a
+    in
+        [hsx|
+            <div>
+                <div class="d-flex justify-content-between align-items-center">
+                    <h3>{title}</h3>
+                    {newLink}
+                </div>
+                <table class="table table-sm table-hover">
+                    <thead>
+                        <tr>
+                            {forEach headers renderHeader}
+                        </tr>
+                    </thead>
+
+                    <tbody>
+                        {forEach jobs renderTableRow}
+                    </tbody>
+                </table>
+            </div>
+            <nav aria-label="Page navigation example">
+                <ul class="pagination justify-content-end">
+                    {renderPrev}
+                    {when (totalPages /= 1) renderDest}
+                    {renderNext}
+                </ul>
+            </nav>
+        |]
+    where
+        renderHeader field = [hsx|<th>{field}</th>|]
+        renderDest = let table = modelTableName @a in [hsx|<li class="page-item active"><a class="page-link" href={ListJobAction table page}>{page}</a></li>|]
+        renderPrev
+            | page == 1 = [hsx||]
+            | otherwise = let table = modelTableName @a in [hsx|
+                <li class="page-item">
+                    <a class="page-link" href={ListJobAction table (page - 1)} aria-label="Previous">
+                        <span aria-hidden="true">&laquo;</span>
+                        <span class="visually-hidden">Previous</span>
+                    </a>
+                </li>
+        |]
+        renderNext
+            | page == totalPages || totalPages == 0 = [hsx||]
+            | otherwise = let table = modelTableName @a in [hsx|
+                <li class="page-item">
+                    <a class="page-link" href={ListJobAction table (page + 1)} aria-label="Next">
+                        <span aria-hidden="true">&raquo;</span>
+                        <span class="visually-hidden">Next</span>
+                    </a>
+                </li>
+            |]
+------------------------------------------------------------
+
+retryButtonStyle :: Text
+retryButtonStyle = "outline: none !important; padding: 0; border: 0; vertical-align: baseline;"
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 digitally induced GmbH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,10 @@
+# Changelog for `ihp-job-dashboard`
+
+## v1.5.0
+
+- Migrate from postgresql-simple to hasql
+- Migrate Bootstrap 4 to Bootstrap 5 (CSS classes and data attributes)
+
+## v1.4.0
+
+- Initial release as a standalone package, extracted from ihp
diff --git a/ihp-job-dashboard.cabal b/ihp-job-dashboard.cabal
new file mode 100644
--- /dev/null
+++ b/ihp-job-dashboard.cabal
@@ -0,0 +1,67 @@
+cabal-version:       2.2
+name:                ihp-job-dashboard
+version:             1.5.0
+synopsis:            Dashboard for IHP job runners
+description:         Web-based dashboard for monitoring IHP background job runners
+license:             MIT
+license-file:        LICENSE
+author:              digitally induced GmbH
+maintainer:          hello@digitallyinduced.com
+homepage:            https://ihp.digitallyinduced.com/
+bug-reports:         https://github.com/digitallyinduced/ihp/issues
+copyright:           (c) digitally induced GmbH
+category:            Web, IHP
+stability:           Stable
+tested-with:         GHC == 9.8.4
+build-type:          Simple
+extra-source-files:  changelog.md
+
+library
+    default-language: GHC2021
+    ghc-options: -Werror=incomplete-patterns -Werror=unused-imports -Werror=missing-fields
+    build-depends:
+            base >= 4.17.0 && < 4.22
+            , wai
+            , mtl
+            , blaze-html
+            , blaze-markup
+            , ihp
+            , ihp-hsx
+            , http-types
+            , wai-request-params
+            , hasql-implicits
+            , hasql
+            , hasql-dynamic-statements
+            , hasql-pool
+            , text
+    default-extensions:
+        OverloadedStrings
+        , NoImplicitPrelude
+        , ImplicitParams
+        , DisambiguateRecordFields
+        , DuplicateRecordFields
+        , OverloadedLabels
+        , DataKinds
+        , QuasiQuotes
+        , TypeFamilies
+        , PackageImports
+        , RecordWildCards
+        , DefaultSignatures
+        , FunctionalDependencies
+        , PartialTypeSignatures
+        , BlockArguments
+        , LambdaCase
+        , TemplateHaskell
+        , OverloadedRecordDot
+        , DeepSubsumption
+    hs-source-dirs: .
+    exposed-modules:
+          IHP.Job.Dashboard
+        , IHP.Job.Dashboard.Types
+        , IHP.Job.Dashboard.Auth
+        , IHP.Job.Dashboard.View
+        , IHP.Job.Dashboard.Utils
+
+source-repository head
+    type:     git
+    location: https://github.com/digitallyinduced/ihp
