├── Sources ├── App │ ├── Controllers │ │ ├── .gitkeep │ │ ├── JobsController.swift │ │ └── DashboardController.swift │ ├── routes.swift │ ├── Views │ │ ├── JobViewContext.swift │ │ ├── JobsViewContext.swift │ │ └── DashboardViewContext.swift │ └── configure.swift └── Run │ └── main.swift ├── ExampleApp ├── .dockerignore ├── .gitignore ├── Sources │ └── App │ │ ├── routes.swift │ │ ├── Models │ │ └── FooJob.swift │ │ ├── entrypoint.swift │ │ └── configure.swift ├── docker-compose.yml ├── .vscode │ └── launch.json ├── Package.swift └── Package.resolved ├── .dockerignore ├── Resources ├── styles.css └── Views │ ├── jobs.leaf │ ├── job.leaf │ └── dashboard.leaf ├── .gitignore ├── tailwind.config.js ├── Tests └── AppTests │ └── AppTests.swift ├── .github └── workflows │ └── build-push-docker.yml ├── .vscode └── launch.json ├── docker-compose.yml ├── Package.swift ├── Dockerfile ├── README.md ├── Package.resolved └── Public └── build └── tailwind.css /Sources/App/Controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ExampleApp/.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | ExampleApp/ 4 | -------------------------------------------------------------------------------- /Resources/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | DerivedData/ 6 | .DS_Store 7 | db.sqlite 8 | .swiftpm 9 | tailwindcss 10 | -------------------------------------------------------------------------------- /ExampleApp/.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | DerivedData/ 6 | .DS_Store 7 | db.sqlite 8 | .swiftpm 9 | .env 10 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './Resources/Views/**/*.leaf', 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [], 10 | } 11 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | import QueuesDatabaseHooks 4 | 5 | func routes(_ app: Application) throws { 6 | try app.routes.register(collection: DashboardController()) 7 | try app.routes.register(collection: JobsController()) 8 | } 9 | -------------------------------------------------------------------------------- /Sources/App/Views/JobViewContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Jimmy McDermott on 10/11/20. 6 | // 7 | 8 | import Foundation 9 | import QueuesDatabaseHooks 10 | 11 | struct JobViewContext: Codable { 12 | let job: QueueDatabaseEntry 13 | } 14 | -------------------------------------------------------------------------------- /ExampleApp/Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | func routes(_ app: Application) throws { 5 | app.get { req async throws -> String in 6 | try await req.queue.dispatch(FooJob.self, Bar(message: "Hello, world!")) 7 | return "It works! Scheduled FooJob." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Vapor 3 | 4 | var env = try Environment.detect() 5 | try LoggingSystem.bootstrap(from: &env) 6 | let app = Application(env, env.isRelease ? .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1)) : .createNew) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | try app.run() 10 | -------------------------------------------------------------------------------- /Sources/App/Views/JobsViewContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Jimmy McDermott on 10/11/20. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import QueuesDatabaseHooks 11 | 12 | struct JobsViewContext: Codable { 13 | let hours: Int 14 | let filter: Int 15 | let jobs: [QueueDatabaseEntry] 16 | } 17 | -------------------------------------------------------------------------------- /ExampleApp/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | volumes: 4 | db_data: 5 | 6 | services: 7 | db: 8 | image: postgres:15-alpine 9 | volumes: 10 | - db_data:/var/lib/postgresql/data/pgdata 11 | environment: 12 | PGDATA: /var/lib/postgresql/data/pgdata 13 | POSTGRES_USER: vapor_username 14 | POSTGRES_PASSWORD: vapor_password 15 | POSTGRES_DB: vapor_database 16 | ports: 17 | - '5432:5432' 18 | -------------------------------------------------------------------------------- /Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import XCTVapor 3 | 4 | final class AppTests: XCTestCase { 5 | func testHelloWorld() throws { 6 | let app = Application(.testing) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | 10 | try app.test(.GET, "hello", afterResponse: { res in 11 | XCTAssertEqual(res.status, .ok) 12 | XCTAssertEqual(res.body.string, "Hello, world!") 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ExampleApp/Sources/App/Models/FooJob.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Queues 3 | 4 | struct Bar: Codable { 5 | let message: String 6 | } 7 | 8 | struct FooJob: AsyncJob { 9 | typealias Payload = Bar 10 | 11 | func dequeue(_ context: QueueContext, _ payload: Bar) async throws { 12 | context.application.logger.info("\(payload.message)") 13 | } 14 | 15 | func error(_ context: QueueContext, _ error: Error, _ payload: Bar) async throws { 16 | context.application.logger.error("\(error)") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Views/DashboardViewContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Jimmy McDermott on 10/11/20. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import QueuesDatabaseHooks 11 | 12 | struct DashboardViewContext: Codable { 13 | let currentJobData: CurrentJobsStatusResponse 14 | let completedJobData: CompletedJobStatusResponse 15 | let timingData: JobsTimingResponse 16 | let hours: Int 17 | let throughputValues: [GraphData] 18 | let executionTimeValues: [GraphData] 19 | 20 | struct GraphData: Codable { 21 | let key: String 22 | let value: Double 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/build-push-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and push Docker image 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | tags: 7 | - '*' 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Login to GitHub Container Registry 17 | uses: docker/login-action@v2 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.actor }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build Docker image 23 | run: docker build . --file Dockerfile --tag ghcr.io/brokenhandsio/queues-dash:${{ github.ref_name }} --tag ghcr.io/brokenhandsio/queues-dash:latest 24 | - name: Push Docker image 25 | run: docker push ghcr.io/brokenhandsio/queues-dash --all-tags 26 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "lldb", 5 | "request": "launch", 6 | "sourceLanguages": [ 7 | "swift" 8 | ], 9 | "name": "Debug Run", 10 | "program": "${workspaceFolder:queues-dash}/.build/debug/Run", 11 | "args": ["--log", "debug"], 12 | "cwd": "${workspaceFolder:queues-dash}", 13 | "preLaunchTask": "swift: Build Debug Run" 14 | }, 15 | { 16 | "type": "lldb", 17 | "request": "launch", 18 | "sourceLanguages": [ 19 | "swift" 20 | ], 21 | "name": "Release Run", 22 | "program": "${workspaceFolder:queues-dash}/.build/release/Run", 23 | "args": [], 24 | "cwd": "${workspaceFolder:queues-dash}", 25 | "preLaunchTask": "swift: Build Release Run" 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /ExampleApp/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "lldb", 5 | "request": "launch", 6 | "sourceLanguages": [ 7 | "swift" 8 | ], 9 | "name": "Debug App", 10 | "program": "${workspaceFolder:ExampleApp}/.build/debug/App", 11 | "args": ["serve", "--port", "8081"], 12 | "cwd": "${workspaceFolder:ExampleApp}", 13 | "preLaunchTask": "swift: Build Debug App" 14 | }, 15 | { 16 | "type": "lldb", 17 | "request": "launch", 18 | "sourceLanguages": [ 19 | "swift" 20 | ], 21 | "name": "Release App", 22 | "program": "${workspaceFolder:ExampleApp}/.build/release/App", 23 | "args": [], 24 | "cwd": "${workspaceFolder:ExampleApp}", 25 | "preLaunchTask": "swift: Build Release App" 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for Vapor 2 | # 3 | # Install Docker on your system to run and test 4 | # your Vapor app in a production-like environment. 5 | # 6 | # Note: This file is intended for testing and does not 7 | # implement best practices for a production deployment. 8 | # 9 | # Learn more: https://docs.docker.com/compose/reference/ 10 | # 11 | # Build images: docker-compose build 12 | # Start app: docker-compose up app 13 | # Stop all: docker-compose down 14 | # 15 | version: '3.7' 16 | 17 | x-shared_environment: &shared_environment 18 | LOG_LEVEL: ${LOG_LEVEL:-debug} 19 | 20 | services: 21 | app: 22 | image: queues-dash:latest 23 | build: 24 | context: . 25 | environment: 26 | <<: *shared_environment 27 | ports: 28 | - '8080:8080' 29 | # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. 30 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 31 | -------------------------------------------------------------------------------- /Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | import FluentPostgresDriver 4 | import Fluent 5 | import FluentKit 6 | 7 | // configures your application 8 | public func configure(_ app: Application) throws { 9 | app.views.use(.leaf) 10 | app.leaf.cache.isEnabled = app.environment.isRelease 11 | app.leaf.tags["isEven"] = IsEvenTag() 12 | app.leaf.tags["dateFormat"] = DateFormatTag() 13 | app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) 14 | 15 | app.databases.use(.postgres(configuration: SQLPostgresConfiguration( 16 | hostname: Environment.get("DATABASE_HOST") ?? "localhost", 17 | port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber, 18 | username: Environment.get("DATABASE_USERNAME") ?? "vapor_username", 19 | password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password", 20 | database: Environment.get("DATABASE_NAME") ?? "vapor_database", 21 | tls: .disable) 22 | ), as: .psql) 23 | 24 | // register routes 25 | try routes(app) 26 | } 27 | -------------------------------------------------------------------------------- /ExampleApp/Sources/App/entrypoint.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Dispatch 3 | import Logging 4 | 5 | /// This extension is temporary and can be removed once Vapor gets this support. 6 | private extension Vapor.Application { 7 | static let baseExecutionQueue = DispatchQueue(label: "vapor.codes.entrypoint") 8 | 9 | func runFromAsyncMainEntrypoint() async throws { 10 | try await withCheckedThrowingContinuation { continuation in 11 | Vapor.Application.baseExecutionQueue.async { [self] in 12 | do { 13 | try self.run() 14 | continuation.resume() 15 | } catch { 16 | continuation.resume(throwing: error) 17 | } 18 | } 19 | } 20 | } 21 | } 22 | 23 | @main 24 | enum Entrypoint { 25 | static func main() async throws { 26 | var env = try Environment.detect() 27 | try LoggingSystem.bootstrap(from: &env) 28 | 29 | let app = Application(env) 30 | defer { app.shutdown() } 31 | 32 | try await configure(app) 33 | try await app.runFromAsyncMainEntrypoint() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ExampleApp/Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import NIOSSL 2 | import Fluent 3 | import FluentPostgresDriver 4 | import Vapor 5 | import QueuesFluentDriver 6 | import QueuesDatabaseHooks 7 | 8 | // configures your application 9 | public func configure(_ app: Application) async throws { 10 | app.databases.use(.postgres(configuration: SQLPostgresConfiguration( 11 | hostname: Environment.get("DATABASE_HOST") ?? "localhost", 12 | port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber, 13 | username: Environment.get("DATABASE_USERNAME") ?? "vapor_username", 14 | password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password", 15 | database: Environment.get("DATABASE_NAME") ?? "vapor_database", 16 | tls: .prefer(try .init(configuration: .clientDefault))) 17 | ), as: .psql) 18 | 19 | app.migrations.add(JobMetadataMigrate()) 20 | app.migrations.add(QueueDatabaseEntryMigration()) 21 | 22 | try await app.autoMigrate() 23 | 24 | // register jobs 25 | app.queues.use(.fluent()) 26 | app.queues.add(FooJob()) 27 | 28 | app.queues.add(QueuesDatabaseNotificationHook.default(db: app.db)) 29 | 30 | try app.queues.startInProcessJobs(on: .default) 31 | 32 | // register routes 33 | try routes(app) 34 | } 35 | -------------------------------------------------------------------------------- /ExampleApp/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "queues-example", 6 | platforms: [ 7 | .macOS(.v12) 8 | ], 9 | dependencies: [ 10 | // 💧 A server-side Swift web framework. 11 | .package(url: "https://github.com/vapor/vapor.git", from: "4.76.0"), 12 | .package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"), 13 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"), 14 | .package(url: "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git", from: "3.0.0-beta1"), 15 | .package(url: "https://github.com/vapor-community/queues-database-hooks.git", from: "0.3.0") 16 | ], 17 | targets: [ 18 | .executableTarget( 19 | name: "App", 20 | dependencies: [ 21 | .product(name: "Fluent", package: "fluent"), 22 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), 23 | .product(name: "Vapor", package: "vapor"), 24 | .product(name: "QueuesFluentDriver", package: "vapor-queues-fluent-driver"), 25 | .product(name: "QueuesDatabaseHooks", package: "queues-database-hooks") 26 | ], 27 | swiftSettings: [ 28 | // Enable better optimizations when building in Release configuration. Despite the use of 29 | // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release 30 | // builds. See for details. 31 | .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) 32 | ] 33 | ) 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.8 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "queues-dash", 6 | platforms: [ 7 | .macOS(.v12) 8 | ], 9 | dependencies: [ 10 | // 💧 A server-side Swift web framework. 11 | .package(url: "https://github.com/vapor/vapor.git", from: "4.76.0"), 12 | .package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"), 13 | .package(url: "https://github.com/vapor/leaf", from: "4.2.0"), 14 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"), 15 | .package(url: "https://github.com/brokenhandsio/queues-database-hooks", from: "0.4.0") 16 | ], 17 | targets: [ 18 | .target( 19 | name: "App", 20 | dependencies: [ 21 | .product(name: "Vapor", package: "vapor"), 22 | .product(name: "QueuesDatabaseHooks", package: "queues-database-hooks"), 23 | .product(name: "Leaf", package: "leaf"), 24 | .product(name: "Fluent", package: "fluent"), 25 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver") 26 | ], 27 | swiftSettings: [ 28 | // Enable better optimizations when building in Release configuration. Despite the use of 29 | // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release 30 | // builds. See for details. 31 | .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) 32 | ] 33 | ), 34 | .executableTarget(name: "Run", dependencies: [.target(name: "App")]), 35 | .testTarget(name: "AppTests", dependencies: [ 36 | .target(name: "App"), 37 | .product(name: "XCTVapor", package: "vapor"), 38 | ]) 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /Sources/App/Controllers/JobsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Jimmy McDermott on 10/11/20. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import QueuesDatabaseHooks 11 | import Fluent 12 | import Leaf 13 | 14 | struct JobsController: RouteCollection { 15 | func boot(routes: RoutesBuilder) throws { 16 | routes.get("jobs", use: jobs) 17 | routes.get("job", ":id", use: job) 18 | } 19 | 20 | func jobs(req: Request) throws -> EventLoopFuture { 21 | let hours = (try? req.query.get(Int.self, at: "hours")) ?? 1 22 | let filter = (try? req.query.get(Int.self, at: "filter")) ?? -1 23 | guard let pastDate = Calendar.current.date(byAdding: .hour, value: hours * -1, to: Date()) else { 24 | throw Abort(.badRequest, reason: "Could not formulate date") 25 | } 26 | 27 | let query = QueueDatabaseEntry 28 | .query(on: req.db) 29 | .filter(\.$queuedAt >= pastDate) 30 | .sort(\.$status, .ascending) 31 | .sort(\.$queuedAt, .descending) 32 | 33 | if filter != -1 { 34 | guard let status = QueueDatabaseEntry.Status(rawValue: filter) else { throw Abort(.notFound) } 35 | query.filter(\.$status == status) 36 | } 37 | 38 | return query.all().flatMap { jobs in 39 | return req.view.render("jobs", JobsViewContext(hours: hours, filter: filter, jobs: jobs)) 40 | } 41 | } 42 | 43 | func job(req: Request) throws -> EventLoopFuture { 44 | let parameter = try req.parameters.require("id", as: UUID.self) 45 | return QueueDatabaseEntry.find(parameter, on: req.db).unwrap(or: Abort(.notFound)).flatMap { job in 46 | return req.view.render("job", JobViewContext(job: job)) 47 | } 48 | } 49 | } 50 | 51 | /// Determines if the input is event 52 | struct IsEvenTag: LeafTag { 53 | func render(_ ctx: LeafContext) throws -> LeafData { 54 | guard let number = ctx.parameters.first?.int else { 55 | throw "Unable to number value" 56 | } 57 | 58 | return .init(booleanLiteral: number % 2 == 0) 59 | } 60 | } 61 | 62 | struct DateFormatTag: LeafTag { 63 | func render(_ ctx: LeafContext) throws -> LeafData { 64 | guard let number = ctx.parameters.first?.double else { 65 | throw "Unable to number value" 66 | } 67 | 68 | let date = Date(timeIntervalSince1970: number) 69 | let df = DateFormatter() 70 | df.timeStyle = .short 71 | df.dateStyle = .short 72 | 73 | return .init(stringLiteral: df.string(from: date)) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:5.8-jammy as build 5 | 6 | # Install OS updates and, if needed, sqlite3 7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 8 | && apt-get -q update \ 9 | && apt-get -q dist-upgrade -y\ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Set up a build area 13 | WORKDIR /build 14 | 15 | # First just resolve dependencies. 16 | # This creates a cached layer that can be reused 17 | # as long as your Package.swift/Package.resolved 18 | # files do not change. 19 | COPY ./Package.* ./ 20 | RUN swift package resolve 21 | 22 | # Copy entire repo into container 23 | COPY . . 24 | 25 | # Build everything, with optimizations 26 | RUN swift build -c release --static-swift-stdlib 27 | 28 | # Switch to the staging area 29 | WORKDIR /staging 30 | 31 | # Copy main executable to staging area 32 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./ 33 | 34 | # Copy resources bundled by SPM to staging area 35 | RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; 36 | 37 | # Copy any resources from the public directory and views directory if the directories exist 38 | # Ensure that by default, neither the directory nor any of its contents are writable. 39 | RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true 40 | RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true 41 | 42 | # ================================ 43 | # Run image 44 | # ================================ 45 | FROM ubuntu:jammy 46 | 47 | # Make sure all system packages are up to date, and install only essential packages. 48 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 49 | && apt-get -q update \ 50 | && apt-get -q dist-upgrade -y \ 51 | && apt-get -q install -y \ 52 | ca-certificates \ 53 | tzdata \ 54 | # If your app or its dependencies import FoundationNetworking, also install `libcurl4`. 55 | # libcurl4 \ 56 | # If your app or its dependencies import FoundationXML, also install `libxml2`. 57 | # libxml2 \ 58 | && rm -r /var/lib/apt/lists/* 59 | 60 | # Create a vapor user and group with /app as its home directory 61 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor 62 | 63 | # Switch to the new home directory 64 | WORKDIR /app 65 | 66 | # Copy built executable and any staged resources from builder 67 | COPY --from=build --chown=vapor:vapor /staging /app 68 | 69 | # Ensure all further commands run as the vapor user 70 | USER vapor:vapor 71 | 72 | # Let Docker bind to port 8080 73 | EXPOSE 8080 74 | 75 | # Start the Vapor service when the image is run, default to listening on 8080 in production environment 76 | ENTRYPOINT ["./Run"] 77 | CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 78 | -------------------------------------------------------------------------------- /Sources/App/Controllers/DashboardController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Jimmy McDermott on 10/11/20. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Fluent 11 | import QueuesDatabaseHooks 12 | import SQLKit 13 | 14 | struct DashboardController: RouteCollection { 15 | func boot(routes: RoutesBuilder) throws { 16 | routes.get(use: dashboard) 17 | } 18 | 19 | func dashboard(req: Request) async throws -> View { 20 | let hours = (try? req.query.get(Int.self, at: "hours")) ?? 1 21 | let currentJobs = try await QueueDatabaseEntry.getStatusOfCurrentJobs(db: req.db).get() 22 | let completedJobs = try await QueueDatabaseEntry.getCompletedJobsForTimePeriod(db: req.db, hours: hours) 23 | .get() 24 | let timing = try await QueueDatabaseEntry.getTimingDataForJobs(db: req.db, hours: hours).get() 25 | let (throughput, execution) = try await getGraphData(req: req).get() 26 | 27 | return try await req.view.render( 28 | "dashboard", 29 | DashboardViewContext( 30 | currentJobData: currentJobs, 31 | completedJobData: completedJobs, 32 | timingData: timing, 33 | hours: hours, 34 | throughputValues: throughput, 35 | executionTimeValues: execution 36 | ) 37 | ) 38 | } 39 | 40 | private func getGraphData( 41 | req: Request 42 | ) -> EventLoopFuture<(throughput: [DashboardViewContext.GraphData], execution: [DashboardViewContext.GraphData])> { 43 | let executionQuery: SQLQueryString = """ 44 | SELECT 45 | avg(EXTRACT(EPOCH FROM ("completedAt" - "dequeuedAt"))) as "value", 46 | TO_CHAR("completedAt", 'HH:00') as "key" 47 | FROM 48 | _queue_job_completions 49 | WHERE 50 | "completedAt" IS NOT NULL 51 | AND "completedAt" >= NOW() - INTERVAL '24' HOUR 52 | GROUP BY 53 | TO_CHAR("completedAt", 'HH:00') 54 | """ 55 | 56 | let throughputQuery: SQLQueryString = """ 57 | SELECT 58 | count(*) * 1.0 as "value", 59 | TO_CHAR("completedAt", 'HH:00') as "key" 60 | FROM 61 | _queue_job_completions 62 | WHERE 63 | "completedAt" IS NOT NULL 64 | AND "completedAt" >= NOW() - INTERVAL '24' HOUR 65 | GROUP BY 66 | TO_CHAR("completedAt", 'HH:00') 67 | """ 68 | 69 | guard let sqlDb = req.db as? SQLDatabase else { 70 | return req.eventLoop.future(error: Abort(.internalServerError)) 71 | } 72 | return sqlDb 73 | .raw(executionQuery) 74 | .all(decoding: DashboardViewContext.GraphData.self) 75 | .and(sqlDb.raw(throughputQuery).all(decoding: DashboardViewContext.GraphData.self)) 76 | .map { execution, throughput in 77 | return (throughput, execution) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Queues Dashboard 2 | The Transeo Queues Dashboard wraps around the data from the `queues-database-hooks` package to show information about queue jobs in a dashboard. Here's what it looks like: 3 | 4 | 5 | | Dashboard | Jobs List | Job View | 6 | | -- | -- | -- | 7 | |![Screenshot 1](https://i.imgur.com/mDnk52c.png)|![Screenshot 2](https://i.imgur.com/PXx3I9P.png)|![Screenshot 3](https://i.imgur.com/FyTMQdX.png)| 8 | 9 | ## Why? 10 | We use Queues extensively at Transeo (we authored the Vapor package!) and they power all of the most critical parts of our infrastructure. However, we didn't have a great way to get immediate visibility into the health of our queues and if anything was getting stuck. We found ourselves firing up our VPN, connecting the Redis box, and then running commands directly in Redis to figure out what was up. 11 | 12 | That led us to initially create https://github.com/gotranseo/queues-progress, a little CLI that shows you information about which jobs are hanging out in your processing queue. While that gave us great visibility into the current jobs being run, it didn't do anything to show us the status of the historical jobs. In fact, there was no mechanism for us to even keep data about historical jobs other than our logs. 13 | 14 | This dashboard combines the best of our make-shift solutions. Building upon the new [job event delegate](https://github.com/vapor/queues/releases/tag/1.5.0) feature we wrote the [database tracking package](http://github.com/vapor-community/queues-database-hooks) that stores all of the historical information about jobs. 15 | 16 | If you layer all of these tools together, this `queues-dash` package can sit on top of your data and observe + surface insights. 17 | 18 | ## Getting Started 19 | The `queues-dash` is a standalone project that does not get added as a library to an existing application. It comes with its own Leaf views and database logic. All you need to do is clone the repository, set the `DATABASE_URL` env var, and run the application. 20 | 21 | The database that is passed into the project needs to have the `_queue_job_completions` table migrated already via the [QueuesDatabaseHooks](http://github.com/vapor-community/queues-database-hooks) package. Follow the instructions in that repo for more information. 22 | 23 | **Note:** One of the limitations right now is that the application only supports `MySQL` - we're looking for ways to make this more configurable in the future. 24 | 25 | ## Configuring 26 | There aren't any configuration options available because there's nothing to configure - the dashboard is intentionally opinionated. However, we specifically built this as an application and not a library/package because once you've cloned the repo you can change anything you need to fit your usecase. If you want to change the frontend colors (The frontend is built with Tailwind UI) or the way data is displayed, go for it! Even better, submit a PR and we'll take a look to see if it fits with the vision of the project. 27 | 28 | ## Using in Production 29 | The `queues-dash` application does not include any authentication out of the box. If you'd like to protect the dashboard, we suggest running it behind some kind of firewall or service that can limit access to your employees. 30 | 31 | ## Current Shortcomings 32 | This is an early stage project so we know not everything is here yet... here's a list of what we've captured on our end for the future: 33 | 34 | 1. ~~Support all SQL databases~~ Currentl tested with Postgres only 35 | 2. Retry failed jobs right from the dashboard 36 | 3. More metrics and graphs about statuses 37 | 4. Alerting when specific jobs fails 38 | 5. Seeing data on a per-job basis (i.e. "show me the failure rate for my `EmailJob`") 39 | 40 | ## Development 41 | 42 | Queues Dashboard uses [Tailwind CSS](https://tailwindcss.com/). If you want to change the appearance of the dashboard 43 | during development do not touch the `tailwind.css` file. Instead: 44 | 45 | 1. Grab the Tailwind CSS standalone executable for your platform from the [latest release](https://github.com/tailwindlabs/tailwindcss/releases/latest) on GitHub, making sure to give it executable permissions: 46 | ```bash 47 | # Example for macOS arm64 48 | curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64 49 | chmod +x tailwindcss-macos-arm64 50 | mv tailwindcss-macos-arm64 tailwindcss 51 | ``` 52 | 53 | 2. To regenerate `tailwind.css` file: 54 | ```bash 55 | ./tailwindcss -i Resources/styles.css -o Public/build/tailwind.css 56 | ``` 57 | > Optionally append `--watch` to continuosly regenerate the file as you edit the `.leaf` templates. -------------------------------------------------------------------------------- /ExampleApp/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "async-http-client", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swift-server/async-http-client.git", 7 | "state" : { 8 | "revision" : "333e60cc90f52973f7ee29cd8e3a7f6adfe79f4e", 9 | "version" : "1.17.0" 10 | } 11 | }, 12 | { 13 | "identity" : "async-kit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/vapor/async-kit.git", 16 | "state" : { 17 | "revision" : "a61da00d404ec91d12766f1b9aac7d90777b484d", 18 | "version" : "1.17.0" 19 | } 20 | }, 21 | { 22 | "identity" : "console-kit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/vapor/console-kit.git", 25 | "state" : { 26 | "revision" : "447f1046fb4e9df40973fe426ecb24a6f0e8d3b4", 27 | "version" : "4.6.0" 28 | } 29 | }, 30 | { 31 | "identity" : "fluent", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/vapor/fluent.git", 34 | "state" : { 35 | "revision" : "4b4d8bf15a06fd60137e9c543e5503c4b842654e", 36 | "version" : "4.8.0" 37 | } 38 | }, 39 | { 40 | "identity" : "fluent-kit", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/vapor/fluent-kit.git", 43 | "state" : { 44 | "revision" : "e53acf986e32c54fe522b2c12f737baa01828c1c", 45 | "version" : "1.42.2" 46 | } 47 | }, 48 | { 49 | "identity" : "fluent-postgres-driver", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/vapor/fluent-postgres-driver.git", 52 | "state" : { 53 | "revision" : "bb3ab8e861152157f712cd08fc473e885bd0b4df", 54 | "version" : "2.7.2" 55 | } 56 | }, 57 | { 58 | "identity" : "multipart-kit", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/vapor/multipart-kit.git", 61 | "state" : { 62 | "revision" : "1adfd69df2da08f7931d4281b257475e32c96734", 63 | "version" : "4.5.4" 64 | } 65 | }, 66 | { 67 | "identity" : "postgres-kit", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/vapor/postgres-kit.git", 70 | "state" : { 71 | "revision" : "69d85952c4e17a891ea01352564b0ff37de31edf", 72 | "version" : "2.11.1" 73 | } 74 | }, 75 | { 76 | "identity" : "postgres-nio", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/vapor/postgres-nio.git", 79 | "state" : { 80 | "revision" : "2df54bc94607f44584ae6ffa74e3cd754fffafc7", 81 | "version" : "1.14.2" 82 | } 83 | }, 84 | { 85 | "identity" : "queues", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/vapor/queues.git", 88 | "state" : { 89 | "revision" : "f1adaf4c925eb7074a4acf363172c1fa6ec888c8", 90 | "version" : "1.12.1" 91 | } 92 | }, 93 | { 94 | "identity" : "routing-kit", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/vapor/routing-kit.git", 97 | "state" : { 98 | "revision" : "611bc45c5dfb1f54b84d99b89d1f72191fb6b71b", 99 | "version" : "4.7.2" 100 | } 101 | }, 102 | { 103 | "identity" : "sql-kit", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/vapor/sql-kit.git", 106 | "state" : { 107 | "revision" : "5026e7c0f2e464ea1af9f5948701aa8922ab14eb", 108 | "version" : "3.27.0" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-algorithms", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/apple/swift-algorithms.git", 115 | "state" : { 116 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 117 | "version" : "1.0.0" 118 | } 119 | }, 120 | { 121 | "identity" : "swift-atomics", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/apple/swift-atomics.git", 124 | "state" : { 125 | "revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10", 126 | "version" : "1.1.0" 127 | } 128 | }, 129 | { 130 | "identity" : "swift-backtrace", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/swift-server/swift-backtrace.git", 133 | "state" : { 134 | "revision" : "f25620d5d05e2f1ba27154b40cafea2b67566956", 135 | "version" : "1.3.3" 136 | } 137 | }, 138 | { 139 | "identity" : "swift-collections", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/apple/swift-collections.git", 142 | "state" : { 143 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 144 | "version" : "1.0.4" 145 | } 146 | }, 147 | { 148 | "identity" : "swift-crypto", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/apple/swift-crypto.git", 151 | "state" : { 152 | "revision" : "33a20e650c33f6d72d822d558333f2085effa3dc", 153 | "version" : "2.5.0" 154 | } 155 | }, 156 | { 157 | "identity" : "swift-log", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/apple/swift-log.git", 160 | "state" : { 161 | "revision" : "32e8d724467f8fe623624570367e3d50c5638e46", 162 | "version" : "1.5.2" 163 | } 164 | }, 165 | { 166 | "identity" : "swift-metrics", 167 | "kind" : "remoteSourceControl", 168 | "location" : "https://github.com/apple/swift-metrics.git", 169 | "state" : { 170 | "revision" : "e8bced74bc6d747745935e469f45d03f048d6cbd", 171 | "version" : "2.3.4" 172 | } 173 | }, 174 | { 175 | "identity" : "swift-nio", 176 | "kind" : "remoteSourceControl", 177 | "location" : "https://github.com/apple/swift-nio.git", 178 | "state" : { 179 | "revision" : "2d8e6ca36fe3e8ed74b0883f593757a45463c34d", 180 | "version" : "2.53.0" 181 | } 182 | }, 183 | { 184 | "identity" : "swift-nio-extras", 185 | "kind" : "remoteSourceControl", 186 | "location" : "https://github.com/apple/swift-nio-extras.git", 187 | "state" : { 188 | "revision" : "0e0d0aab665ff1a0659ce75ac003081f2b1c8997", 189 | "version" : "1.19.0" 190 | } 191 | }, 192 | { 193 | "identity" : "swift-nio-http2", 194 | "kind" : "remoteSourceControl", 195 | "location" : "https://github.com/apple/swift-nio-http2.git", 196 | "state" : { 197 | "revision" : "6d021a48483dbb273a9be43f65234bdc9185b364", 198 | "version" : "1.26.0" 199 | } 200 | }, 201 | { 202 | "identity" : "swift-nio-ssl", 203 | "kind" : "remoteSourceControl", 204 | "location" : "https://github.com/apple/swift-nio-ssl.git", 205 | "state" : { 206 | "revision" : "e866a626e105042a6a72a870c88b4c531ba05f83", 207 | "version" : "2.24.0" 208 | } 209 | }, 210 | { 211 | "identity" : "swift-nio-transport-services", 212 | "kind" : "remoteSourceControl", 213 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 214 | "state" : { 215 | "revision" : "41f4098903878418537020075a4d8a6e20a0b182", 216 | "version" : "1.17.0" 217 | } 218 | }, 219 | { 220 | "identity" : "swift-numerics", 221 | "kind" : "remoteSourceControl", 222 | "location" : "https://github.com/apple/swift-numerics", 223 | "state" : { 224 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 225 | "version" : "1.0.2" 226 | } 227 | }, 228 | { 229 | "identity" : "vapor", 230 | "kind" : "remoteSourceControl", 231 | "location" : "https://github.com/vapor/vapor.git", 232 | "state" : { 233 | "revision" : "f4b00a5350238fe896d865d96d64f12fcbbeda95", 234 | "version" : "4.76.0" 235 | } 236 | }, 237 | { 238 | "identity" : "vapor-queues-fluent-driver", 239 | "kind" : "remoteSourceControl", 240 | "location" : "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git", 241 | "state" : { 242 | "revision" : "e2ce6775850bdbe277cb2e5792d05eff42434f52", 243 | "version" : "3.0.0-beta1" 244 | } 245 | }, 246 | { 247 | "identity" : "websocket-kit", 248 | "kind" : "remoteSourceControl", 249 | "location" : "https://github.com/vapor/websocket-kit.git", 250 | "state" : { 251 | "revision" : "17c0afbf24f4e288e183bc34317dc97b5cd084bd", 252 | "version" : "2.9.0" 253 | } 254 | } 255 | ], 256 | "version" : 2 257 | } 258 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "async-http-client", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swift-server/async-http-client.git", 7 | "state" : { 8 | "revision" : "78db67e5bf4a8543075787f228e8920097319281", 9 | "version" : "1.18.0" 10 | } 11 | }, 12 | { 13 | "identity" : "async-kit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/vapor/async-kit.git", 16 | "state" : { 17 | "revision" : "a61da00d404ec91d12766f1b9aac7d90777b484d", 18 | "version" : "1.17.0" 19 | } 20 | }, 21 | { 22 | "identity" : "console-kit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/vapor/console-kit.git", 25 | "state" : { 26 | "revision" : "447f1046fb4e9df40973fe426ecb24a6f0e8d3b4", 27 | "version" : "4.6.0" 28 | } 29 | }, 30 | { 31 | "identity" : "fluent", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/vapor/fluent.git", 34 | "state" : { 35 | "revision" : "4b4d8bf15a06fd60137e9c543e5503c4b842654e", 36 | "version" : "4.8.0" 37 | } 38 | }, 39 | { 40 | "identity" : "fluent-kit", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/vapor/fluent-kit.git", 43 | "state" : { 44 | "revision" : "e53acf986e32c54fe522b2c12f737baa01828c1c", 45 | "version" : "1.42.2" 46 | } 47 | }, 48 | { 49 | "identity" : "fluent-postgres-driver", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/vapor/fluent-postgres-driver.git", 52 | "state" : { 53 | "revision" : "bb3ab8e861152157f712cd08fc473e885bd0b4df", 54 | "version" : "2.7.2" 55 | } 56 | }, 57 | { 58 | "identity" : "leaf", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/vapor/leaf", 61 | "state" : { 62 | "revision" : "6fe0e843c6599f5189e45c7b08739ebc5c410c3b", 63 | "version" : "4.2.4" 64 | } 65 | }, 66 | { 67 | "identity" : "leaf-kit", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/vapor/leaf-kit.git", 70 | "state" : { 71 | "revision" : "13f2fc4c8479113cd23876d9a434ef4573e368bb", 72 | "version" : "1.10.2" 73 | } 74 | }, 75 | { 76 | "identity" : "multipart-kit", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/vapor/multipart-kit.git", 79 | "state" : { 80 | "revision" : "1adfd69df2da08f7931d4281b257475e32c96734", 81 | "version" : "4.5.4" 82 | } 83 | }, 84 | { 85 | "identity" : "postgres-kit", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/vapor/postgres-kit.git", 88 | "state" : { 89 | "revision" : "875e8c191ea776802fddf6773146349803629754", 90 | "version" : "2.11.2" 91 | } 92 | }, 93 | { 94 | "identity" : "postgres-nio", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/vapor/postgres-nio.git", 97 | "state" : { 98 | "revision" : "2df54bc94607f44584ae6ffa74e3cd754fffafc7", 99 | "version" : "1.14.2" 100 | } 101 | }, 102 | { 103 | "identity" : "queues", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/vapor/queues.git", 106 | "state" : { 107 | "revision" : "f1adaf4c925eb7074a4acf363172c1fa6ec888c8", 108 | "version" : "1.12.1" 109 | } 110 | }, 111 | { 112 | "identity" : "queues-database-hooks", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/brokenhandsio/queues-database-hooks", 115 | "state" : { 116 | "revision" : "b080458280e4a1c14799bdd905adec0ab79cff4e", 117 | "version" : "0.4.3" 118 | } 119 | }, 120 | { 121 | "identity" : "routing-kit", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/vapor/routing-kit.git", 124 | "state" : { 125 | "revision" : "611bc45c5dfb1f54b84d99b89d1f72191fb6b71b", 126 | "version" : "4.7.2" 127 | } 128 | }, 129 | { 130 | "identity" : "sql-kit", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/vapor/sql-kit.git", 133 | "state" : { 134 | "revision" : "5026e7c0f2e464ea1af9f5948701aa8922ab14eb", 135 | "version" : "3.27.0" 136 | } 137 | }, 138 | { 139 | "identity" : "swift-algorithms", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/apple/swift-algorithms.git", 142 | "state" : { 143 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 144 | "version" : "1.0.0" 145 | } 146 | }, 147 | { 148 | "identity" : "swift-atomics", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/apple/swift-atomics.git", 151 | "state" : { 152 | "revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10", 153 | "version" : "1.1.0" 154 | } 155 | }, 156 | { 157 | "identity" : "swift-backtrace", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/swift-server/swift-backtrace.git", 160 | "state" : { 161 | "revision" : "f25620d5d05e2f1ba27154b40cafea2b67566956", 162 | "version" : "1.3.3" 163 | } 164 | }, 165 | { 166 | "identity" : "swift-collections", 167 | "kind" : "remoteSourceControl", 168 | "location" : "https://github.com/apple/swift-collections.git", 169 | "state" : { 170 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 171 | "version" : "1.0.4" 172 | } 173 | }, 174 | { 175 | "identity" : "swift-crypto", 176 | "kind" : "remoteSourceControl", 177 | "location" : "https://github.com/apple/swift-crypto.git", 178 | "state" : { 179 | "revision" : "33a20e650c33f6d72d822d558333f2085effa3dc", 180 | "version" : "2.5.0" 181 | } 182 | }, 183 | { 184 | "identity" : "swift-log", 185 | "kind" : "remoteSourceControl", 186 | "location" : "https://github.com/apple/swift-log.git", 187 | "state" : { 188 | "revision" : "32e8d724467f8fe623624570367e3d50c5638e46", 189 | "version" : "1.5.2" 190 | } 191 | }, 192 | { 193 | "identity" : "swift-metrics", 194 | "kind" : "remoteSourceControl", 195 | "location" : "https://github.com/apple/swift-metrics.git", 196 | "state" : { 197 | "revision" : "e8bced74bc6d747745935e469f45d03f048d6cbd", 198 | "version" : "2.3.4" 199 | } 200 | }, 201 | { 202 | "identity" : "swift-nio", 203 | "kind" : "remoteSourceControl", 204 | "location" : "https://github.com/apple/swift-nio.git", 205 | "state" : { 206 | "revision" : "2d8e6ca36fe3e8ed74b0883f593757a45463c34d", 207 | "version" : "2.53.0" 208 | } 209 | }, 210 | { 211 | "identity" : "swift-nio-extras", 212 | "kind" : "remoteSourceControl", 213 | "location" : "https://github.com/apple/swift-nio-extras.git", 214 | "state" : { 215 | "revision" : "0e0d0aab665ff1a0659ce75ac003081f2b1c8997", 216 | "version" : "1.19.0" 217 | } 218 | }, 219 | { 220 | "identity" : "swift-nio-http2", 221 | "kind" : "remoteSourceControl", 222 | "location" : "https://github.com/apple/swift-nio-http2.git", 223 | "state" : { 224 | "revision" : "6d021a48483dbb273a9be43f65234bdc9185b364", 225 | "version" : "1.26.0" 226 | } 227 | }, 228 | { 229 | "identity" : "swift-nio-ssl", 230 | "kind" : "remoteSourceControl", 231 | "location" : "https://github.com/apple/swift-nio-ssl.git", 232 | "state" : { 233 | "revision" : "e866a626e105042a6a72a870c88b4c531ba05f83", 234 | "version" : "2.24.0" 235 | } 236 | }, 237 | { 238 | "identity" : "swift-nio-transport-services", 239 | "kind" : "remoteSourceControl", 240 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 241 | "state" : { 242 | "revision" : "41f4098903878418537020075a4d8a6e20a0b182", 243 | "version" : "1.17.0" 244 | } 245 | }, 246 | { 247 | "identity" : "swift-numerics", 248 | "kind" : "remoteSourceControl", 249 | "location" : "https://github.com/apple/swift-numerics", 250 | "state" : { 251 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 252 | "version" : "1.0.2" 253 | } 254 | }, 255 | { 256 | "identity" : "vapor", 257 | "kind" : "remoteSourceControl", 258 | "location" : "https://github.com/vapor/vapor.git", 259 | "state" : { 260 | "revision" : "8b79ff0bd264a33bd0b7471dcf50dd1be983f992", 261 | "version" : "4.76.3" 262 | } 263 | }, 264 | { 265 | "identity" : "websocket-kit", 266 | "kind" : "remoteSourceControl", 267 | "location" : "https://github.com/vapor/websocket-kit.git", 268 | "state" : { 269 | "revision" : "17c0afbf24f4e288e183bc34317dc97b5cd084bd", 270 | "version" : "2.9.0" 271 | } 272 | } 273 | ], 274 | "version" : 2 275 | } 276 | -------------------------------------------------------------------------------- /Resources/Views/jobs.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Jobs 8 | 9 | 10 | 11 | 12 | 13 | 30 | 31 | 32 |
33 |
34 |
35 |
36 |

Jobs

37 |
38 |
39 | 55 |
56 | 57 | 58 |
59 |
60 |
61 |
62 |
63 | 73 |
74 | 75 | 76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 | 92 | 96 | 100 | 104 | 108 | 109 | 110 | 111 | 112 | 113 | #for(job in jobs): 114 | 115 | 119 | 124 | 139 | 143 | 147 | 151 | 152 | 153 | #endfor 154 | 155 |
90 | Name 91 | 94 | ID 95 | 98 | Status 99 | 102 | Queued At 103 | 106 | Queue 107 |
117 | #(job.jobName) 118 | 121 | 122 | #(job.jobId) 123 | 125 | 138 | 141 | #dateFormat(job.queuedAt) 142 | 145 | #(job.queueName) 146 | 149 | View 150 |
156 |
157 |
158 |
159 |
160 | 161 |
162 |
163 |
164 |
165 | 166 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /Resources/Views/job.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Jobs 8 | 9 | 10 | 11 | 12 | 13 | 30 | 31 | 32 |
33 |
34 |
35 |
36 |

Job Details

37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |

46 | #(job.jobName) 47 |

48 | 61 |
62 |
63 |
64 |
65 |
66 | ID 67 |
68 |
70 | #(job.jobId) 71 |
72 |
73 |
75 |
76 | Queued At 77 |
78 |
80 | #dateFormat(job.queuedAt) 81 |
82 |
83 |
85 |
86 | Dequeued At 87 |
88 |
90 | #if(job.dequeuedAt): 91 | #dateFormat(job.dequeuedAt) 92 | #else: 93 | N/A 94 | #endif 95 |
96 |
97 |
99 |
100 | Completed At 101 |
102 |
104 | #if(job.completedAt): 105 | #dateFormat(job.completedAt) 106 | #else: 107 | N/A 108 | #endif 109 |
110 |
111 |
113 |
114 | Queue Name 115 |
116 |
118 | #(job.queueName) 119 |
120 |
121 |
123 |
124 | Payload 125 |
126 |
127 | Reveal 128 | 129 | 130 | 131 |
132 |
133 |
135 |
136 | Timing 137 |
138 |
139 |
    140 |
  • Waiting:
  • 141 |
  • Execution:
  • 142 |
143 |
144 |
145 |
147 |
148 | Error 149 |
150 |
152 | #if(job.errorString): 153 | #(job.errorString) 154 | #else: 155 | N/A 156 | #endif 157 |
158 |
159 |
161 |
162 | Max Retry Count 163 |
164 |
166 | #(job.maxRetryCount) 167 |
168 |
169 |
171 |
172 | Delay Until 173 |
174 |
176 | #if(job.delayUntil): 177 | #dateFormat(job.delayUntil) 178 | #else: 179 | N/A 180 | #endif 181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 | 191 |
192 |
193 |
194 |
195 | 196 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /Resources/Views/dashboard.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Dashboard 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 33 | 34 | 35 |
36 |
37 |
38 |
39 |

Dashboard

40 | 41 | 42 |
43 |
44 | 60 |
61 | 62 | 63 |
64 |
65 |
66 |
67 | 68 | 69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | Completed Jobs Last #(hours) Hour#if(hours > 1):s #endif 77 |
78 |
79 | #(completedJobData.completedJobs) 80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | Jobs Currently Running 89 |
90 |
91 | #(currentJobData.runningCount) 92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | Jobs Currently Waiting 101 |
102 |
103 | #(currentJobData.queuedCount) 104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | Avg. Run Time 113 |
114 |
115 | 116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 | Avg. Wait Time 125 |
126 |
127 | 128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 | Successful % Last #(hours) Hour#if(hours > 1):s #endif 137 |
138 |
139 | #(completedJobData.percentSuccess * 100)% 140 |
141 |
142 |
143 |
144 |
145 |
146 | 147 | 148 |
149 |
150 |
151 |
152 |
153 |

Throughput Last 24 Hrs

154 |
155 |
156 |
157 | 158 |
159 |
160 |
161 |
162 |
163 |

Execution Time Last 24 Hrs

164 |
165 |
166 |
167 | 168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 | 179 | 180 | 216 | 217 | 362 | 363 | 364 | -------------------------------------------------------------------------------- /Public/build/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | */ 36 | 37 | html { 38 | line-height: 1.5; 39 | /* 1 */ 40 | -webkit-text-size-adjust: 100%; 41 | /* 2 */ 42 | -moz-tab-size: 4; 43 | /* 3 */ 44 | -o-tab-size: 4; 45 | tab-size: 4; 46 | /* 3 */ 47 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 48 | /* 4 */ 49 | font-feature-settings: normal; 50 | /* 5 */ 51 | font-variation-settings: normal; 52 | /* 6 */ 53 | } 54 | 55 | /* 56 | 1. Remove the margin in all browsers. 57 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 58 | */ 59 | 60 | body { 61 | margin: 0; 62 | /* 1 */ 63 | line-height: inherit; 64 | /* 2 */ 65 | } 66 | 67 | /* 68 | 1. Add the correct height in Firefox. 69 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 70 | 3. Ensure horizontal rules are visible by default. 71 | */ 72 | 73 | hr { 74 | height: 0; 75 | /* 1 */ 76 | color: inherit; 77 | /* 2 */ 78 | border-top-width: 1px; 79 | /* 3 */ 80 | } 81 | 82 | /* 83 | Add the correct text decoration in Chrome, Edge, and Safari. 84 | */ 85 | 86 | abbr:where([title]) { 87 | -webkit-text-decoration: underline dotted; 88 | text-decoration: underline dotted; 89 | } 90 | 91 | /* 92 | Remove the default font size and weight for headings. 93 | */ 94 | 95 | h1, 96 | h2, 97 | h3, 98 | h4, 99 | h5, 100 | h6 { 101 | font-size: inherit; 102 | font-weight: inherit; 103 | } 104 | 105 | /* 106 | Reset links to optimize for opt-in styling instead of opt-out. 107 | */ 108 | 109 | a { 110 | color: inherit; 111 | text-decoration: inherit; 112 | } 113 | 114 | /* 115 | Add the correct font weight in Edge and Safari. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bolder; 121 | } 122 | 123 | /* 124 | 1. Use the user's configured `mono` font family by default. 125 | 2. Correct the odd `em` font sizing in all browsers. 126 | */ 127 | 128 | code, 129 | kbd, 130 | samp, 131 | pre { 132 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 133 | /* 1 */ 134 | font-size: 1em; 135 | /* 2 */ 136 | } 137 | 138 | /* 139 | Add the correct font size in all browsers. 140 | */ 141 | 142 | small { 143 | font-size: 80%; 144 | } 145 | 146 | /* 147 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 148 | */ 149 | 150 | sub, 151 | sup { 152 | font-size: 75%; 153 | line-height: 0; 154 | position: relative; 155 | vertical-align: baseline; 156 | } 157 | 158 | sub { 159 | bottom: -0.25em; 160 | } 161 | 162 | sup { 163 | top: -0.5em; 164 | } 165 | 166 | /* 167 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 168 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 169 | 3. Remove gaps between table borders by default. 170 | */ 171 | 172 | table { 173 | text-indent: 0; 174 | /* 1 */ 175 | border-color: inherit; 176 | /* 2 */ 177 | border-collapse: collapse; 178 | /* 3 */ 179 | } 180 | 181 | /* 182 | 1. Change the font styles in all browsers. 183 | 2. Remove the margin in Firefox and Safari. 184 | 3. Remove default padding in all browsers. 185 | */ 186 | 187 | button, 188 | input, 189 | optgroup, 190 | select, 191 | textarea { 192 | font-family: inherit; 193 | /* 1 */ 194 | font-size: 100%; 195 | /* 1 */ 196 | font-weight: inherit; 197 | /* 1 */ 198 | line-height: inherit; 199 | /* 1 */ 200 | color: inherit; 201 | /* 1 */ 202 | margin: 0; 203 | /* 2 */ 204 | padding: 0; 205 | /* 3 */ 206 | } 207 | 208 | /* 209 | Remove the inheritance of text transform in Edge and Firefox. 210 | */ 211 | 212 | button, 213 | select { 214 | text-transform: none; 215 | } 216 | 217 | /* 218 | 1. Correct the inability to style clickable types in iOS and Safari. 219 | 2. Remove default button styles. 220 | */ 221 | 222 | button, 223 | [type='button'], 224 | [type='reset'], 225 | [type='submit'] { 226 | -webkit-appearance: button; 227 | /* 1 */ 228 | background-color: transparent; 229 | /* 2 */ 230 | background-image: none; 231 | /* 2 */ 232 | } 233 | 234 | /* 235 | Use the modern Firefox focus style for all focusable elements. 236 | */ 237 | 238 | :-moz-focusring { 239 | outline: auto; 240 | } 241 | 242 | /* 243 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 244 | */ 245 | 246 | :-moz-ui-invalid { 247 | box-shadow: none; 248 | } 249 | 250 | /* 251 | Add the correct vertical alignment in Chrome and Firefox. 252 | */ 253 | 254 | progress { 255 | vertical-align: baseline; 256 | } 257 | 258 | /* 259 | Correct the cursor style of increment and decrement buttons in Safari. 260 | */ 261 | 262 | ::-webkit-inner-spin-button, 263 | ::-webkit-outer-spin-button { 264 | height: auto; 265 | } 266 | 267 | /* 268 | 1. Correct the odd appearance in Chrome and Safari. 269 | 2. Correct the outline style in Safari. 270 | */ 271 | 272 | [type='search'] { 273 | -webkit-appearance: textfield; 274 | /* 1 */ 275 | outline-offset: -2px; 276 | /* 2 */ 277 | } 278 | 279 | /* 280 | Remove the inner padding in Chrome and Safari on macOS. 281 | */ 282 | 283 | ::-webkit-search-decoration { 284 | -webkit-appearance: none; 285 | } 286 | 287 | /* 288 | 1. Correct the inability to style clickable types in iOS and Safari. 289 | 2. Change font properties to `inherit` in Safari. 290 | */ 291 | 292 | ::-webkit-file-upload-button { 293 | -webkit-appearance: button; 294 | /* 1 */ 295 | font: inherit; 296 | /* 2 */ 297 | } 298 | 299 | /* 300 | Add the correct display in Chrome and Safari. 301 | */ 302 | 303 | summary { 304 | display: list-item; 305 | } 306 | 307 | /* 308 | Removes the default spacing and border for appropriate elements. 309 | */ 310 | 311 | blockquote, 312 | dl, 313 | dd, 314 | h1, 315 | h2, 316 | h3, 317 | h4, 318 | h5, 319 | h6, 320 | hr, 321 | figure, 322 | p, 323 | pre { 324 | margin: 0; 325 | } 326 | 327 | fieldset { 328 | margin: 0; 329 | padding: 0; 330 | } 331 | 332 | legend { 333 | padding: 0; 334 | } 335 | 336 | ol, 337 | ul, 338 | menu { 339 | list-style: none; 340 | margin: 0; 341 | padding: 0; 342 | } 343 | 344 | /* 345 | Prevent resizing textareas horizontally by default. 346 | */ 347 | 348 | textarea { 349 | resize: vertical; 350 | } 351 | 352 | /* 353 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 354 | 2. Set the default placeholder color to the user's configured gray 400 color. 355 | */ 356 | 357 | input::-moz-placeholder, textarea::-moz-placeholder { 358 | opacity: 1; 359 | /* 1 */ 360 | color: #9ca3af; 361 | /* 2 */ 362 | } 363 | 364 | input::placeholder, 365 | textarea::placeholder { 366 | opacity: 1; 367 | /* 1 */ 368 | color: #9ca3af; 369 | /* 2 */ 370 | } 371 | 372 | /* 373 | Set the default cursor for buttons. 374 | */ 375 | 376 | button, 377 | [role="button"] { 378 | cursor: pointer; 379 | } 380 | 381 | /* 382 | Make sure disabled buttons don't get the pointer cursor. 383 | */ 384 | 385 | :disabled { 386 | cursor: default; 387 | } 388 | 389 | /* 390 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 391 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 392 | This can trigger a poorly considered lint error in some tools but is included by design. 393 | */ 394 | 395 | img, 396 | svg, 397 | video, 398 | canvas, 399 | audio, 400 | iframe, 401 | embed, 402 | object { 403 | display: block; 404 | /* 1 */ 405 | vertical-align: middle; 406 | /* 2 */ 407 | } 408 | 409 | /* 410 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 411 | */ 412 | 413 | img, 414 | video { 415 | max-width: 100%; 416 | height: auto; 417 | } 418 | 419 | /* Make elements with the HTML hidden attribute stay hidden by default */ 420 | 421 | [hidden] { 422 | display: none; 423 | } 424 | 425 | *, ::before, ::after { 426 | --tw-border-spacing-x: 0; 427 | --tw-border-spacing-y: 0; 428 | --tw-translate-x: 0; 429 | --tw-translate-y: 0; 430 | --tw-rotate: 0; 431 | --tw-skew-x: 0; 432 | --tw-skew-y: 0; 433 | --tw-scale-x: 1; 434 | --tw-scale-y: 1; 435 | --tw-pan-x: ; 436 | --tw-pan-y: ; 437 | --tw-pinch-zoom: ; 438 | --tw-scroll-snap-strictness: proximity; 439 | --tw-gradient-from-position: ; 440 | --tw-gradient-via-position: ; 441 | --tw-gradient-to-position: ; 442 | --tw-ordinal: ; 443 | --tw-slashed-zero: ; 444 | --tw-numeric-figure: ; 445 | --tw-numeric-spacing: ; 446 | --tw-numeric-fraction: ; 447 | --tw-ring-inset: ; 448 | --tw-ring-offset-width: 0px; 449 | --tw-ring-offset-color: #fff; 450 | --tw-ring-color: rgb(59 130 246 / 0.5); 451 | --tw-ring-offset-shadow: 0 0 #0000; 452 | --tw-ring-shadow: 0 0 #0000; 453 | --tw-shadow: 0 0 #0000; 454 | --tw-shadow-colored: 0 0 #0000; 455 | --tw-blur: ; 456 | --tw-brightness: ; 457 | --tw-contrast: ; 458 | --tw-grayscale: ; 459 | --tw-hue-rotate: ; 460 | --tw-invert: ; 461 | --tw-saturate: ; 462 | --tw-sepia: ; 463 | --tw-drop-shadow: ; 464 | --tw-backdrop-blur: ; 465 | --tw-backdrop-brightness: ; 466 | --tw-backdrop-contrast: ; 467 | --tw-backdrop-grayscale: ; 468 | --tw-backdrop-hue-rotate: ; 469 | --tw-backdrop-invert: ; 470 | --tw-backdrop-opacity: ; 471 | --tw-backdrop-saturate: ; 472 | --tw-backdrop-sepia: ; 473 | } 474 | 475 | ::backdrop { 476 | --tw-border-spacing-x: 0; 477 | --tw-border-spacing-y: 0; 478 | --tw-translate-x: 0; 479 | --tw-translate-y: 0; 480 | --tw-rotate: 0; 481 | --tw-skew-x: 0; 482 | --tw-skew-y: 0; 483 | --tw-scale-x: 1; 484 | --tw-scale-y: 1; 485 | --tw-pan-x: ; 486 | --tw-pan-y: ; 487 | --tw-pinch-zoom: ; 488 | --tw-scroll-snap-strictness: proximity; 489 | --tw-gradient-from-position: ; 490 | --tw-gradient-via-position: ; 491 | --tw-gradient-to-position: ; 492 | --tw-ordinal: ; 493 | --tw-slashed-zero: ; 494 | --tw-numeric-figure: ; 495 | --tw-numeric-spacing: ; 496 | --tw-numeric-fraction: ; 497 | --tw-ring-inset: ; 498 | --tw-ring-offset-width: 0px; 499 | --tw-ring-offset-color: #fff; 500 | --tw-ring-color: rgb(59 130 246 / 0.5); 501 | --tw-ring-offset-shadow: 0 0 #0000; 502 | --tw-ring-shadow: 0 0 #0000; 503 | --tw-shadow: 0 0 #0000; 504 | --tw-shadow-colored: 0 0 #0000; 505 | --tw-blur: ; 506 | --tw-brightness: ; 507 | --tw-contrast: ; 508 | --tw-grayscale: ; 509 | --tw-hue-rotate: ; 510 | --tw-invert: ; 511 | --tw-saturate: ; 512 | --tw-sepia: ; 513 | --tw-drop-shadow: ; 514 | --tw-backdrop-blur: ; 515 | --tw-backdrop-brightness: ; 516 | --tw-backdrop-contrast: ; 517 | --tw-backdrop-grayscale: ; 518 | --tw-backdrop-hue-rotate: ; 519 | --tw-backdrop-invert: ; 520 | --tw-backdrop-opacity: ; 521 | --tw-backdrop-saturate: ; 522 | --tw-backdrop-sepia: ; 523 | } 524 | 525 | .pointer-events-none { 526 | pointer-events: none; 527 | } 528 | 529 | .absolute { 530 | position: absolute; 531 | } 532 | 533 | .relative { 534 | position: relative; 535 | } 536 | 537 | .inset-y-0 { 538 | top: 0px; 539 | bottom: 0px; 540 | } 541 | 542 | .right-0 { 543 | right: 0px; 544 | } 545 | 546 | .z-0 { 547 | z-index: 0; 548 | } 549 | 550 | .-my-2 { 551 | margin-top: -0.5rem; 552 | margin-bottom: -0.5rem; 553 | } 554 | 555 | .mx-auto { 556 | margin-left: auto; 557 | margin-right: auto; 558 | } 559 | 560 | .mb-6 { 561 | margin-bottom: 1.5rem; 562 | } 563 | 564 | .ml-4 { 565 | margin-left: 1rem; 566 | } 567 | 568 | .mr-3 { 569 | margin-right: 0.75rem; 570 | } 571 | 572 | .mt-1 { 573 | margin-top: 0.25rem; 574 | } 575 | 576 | .mt-3 { 577 | margin-top: 0.75rem; 578 | } 579 | 580 | .mt-5 { 581 | margin-top: 1.25rem; 582 | } 583 | 584 | .mt-8 { 585 | margin-top: 2rem; 586 | } 587 | 588 | .block { 589 | display: block; 590 | } 591 | 592 | .inline-block { 593 | display: inline-block; 594 | } 595 | 596 | .flex { 597 | display: flex; 598 | } 599 | 600 | .table { 601 | display: table; 602 | } 603 | 604 | .grid { 605 | display: grid; 606 | } 607 | 608 | .h-16 { 609 | height: 4rem; 610 | } 611 | 612 | .h-4 { 613 | height: 1rem; 614 | } 615 | 616 | .h-full { 617 | height: 100%; 618 | } 619 | 620 | .h-screen { 621 | height: 100vh; 622 | } 623 | 624 | .w-4 { 625 | width: 1rem; 626 | } 627 | 628 | .w-full { 629 | width: 100%; 630 | } 631 | 632 | .min-w-full { 633 | min-width: 100%; 634 | } 635 | 636 | .max-w-7xl { 637 | max-width: 80rem; 638 | } 639 | 640 | .flex-1 { 641 | flex: 1 1 0%; 642 | } 643 | 644 | .flex-shrink-0 { 645 | flex-shrink: 0; 646 | } 647 | 648 | .list-disc { 649 | list-style-type: disc; 650 | } 651 | 652 | .appearance-none { 653 | -webkit-appearance: none; 654 | -moz-appearance: none; 655 | appearance: none; 656 | } 657 | 658 | .grid-cols-1 { 659 | grid-template-columns: repeat(1, minmax(0, 1fr)); 660 | } 661 | 662 | .flex-col { 663 | flex-direction: column; 664 | } 665 | 666 | .items-center { 667 | align-items: center; 668 | } 669 | 670 | .justify-between { 671 | justify-content: space-between; 672 | } 673 | 674 | .gap-5 { 675 | gap: 1.25rem; 676 | } 677 | 678 | .divide-y > :not([hidden]) ~ :not([hidden]) { 679 | --tw-divide-y-reverse: 0; 680 | border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); 681 | border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); 682 | } 683 | 684 | .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { 685 | --tw-divide-opacity: 1; 686 | border-color: rgb(229 231 235 / var(--tw-divide-opacity)); 687 | } 688 | 689 | .overflow-hidden { 690 | overflow: hidden; 691 | } 692 | 693 | .overflow-x-auto { 694 | overflow-x: auto; 695 | } 696 | 697 | .overflow-y-auto { 698 | overflow-y: auto; 699 | } 700 | 701 | .truncate { 702 | overflow: hidden; 703 | text-overflow: ellipsis; 704 | white-space: nowrap; 705 | } 706 | 707 | .whitespace-pre { 708 | white-space: pre; 709 | } 710 | 711 | .rounded { 712 | border-radius: 0.25rem; 713 | } 714 | 715 | .rounded-full { 716 | border-radius: 9999px; 717 | } 718 | 719 | .rounded-lg { 720 | border-radius: 0.5rem; 721 | } 722 | 723 | .rounded-md { 724 | border-radius: 0.375rem; 725 | } 726 | 727 | .border { 728 | border-width: 1px; 729 | } 730 | 731 | .border-b { 732 | border-bottom-width: 1px; 733 | } 734 | 735 | .border-gray-200 { 736 | --tw-border-opacity: 1; 737 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 738 | } 739 | 740 | .bg-gray-100 { 741 | --tw-bg-opacity: 1; 742 | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); 743 | } 744 | 745 | .bg-gray-200 { 746 | --tw-bg-opacity: 1; 747 | background-color: rgb(229 231 235 / var(--tw-bg-opacity)); 748 | } 749 | 750 | .bg-gray-50 { 751 | --tw-bg-opacity: 1; 752 | background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 753 | } 754 | 755 | .bg-gray-800 { 756 | --tw-bg-opacity: 1; 757 | background-color: rgb(31 41 55 / var(--tw-bg-opacity)); 758 | } 759 | 760 | .bg-gray-900 { 761 | --tw-bg-opacity: 1; 762 | background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 763 | } 764 | 765 | .bg-indigo-500 { 766 | --tw-bg-opacity: 1; 767 | background-color: rgb(99 102 241 / var(--tw-bg-opacity)); 768 | } 769 | 770 | .bg-white { 771 | --tw-bg-opacity: 1; 772 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 773 | } 774 | 775 | .fill-current { 776 | fill: currentColor; 777 | } 778 | 779 | .px-2 { 780 | padding-left: 0.5rem; 781 | padding-right: 0.5rem; 782 | } 783 | 784 | .px-3 { 785 | padding-left: 0.75rem; 786 | padding-right: 0.75rem; 787 | } 788 | 789 | .px-4 { 790 | padding-left: 1rem; 791 | padding-right: 1rem; 792 | } 793 | 794 | .px-5 { 795 | padding-left: 1.25rem; 796 | padding-right: 1.25rem; 797 | } 798 | 799 | .px-6 { 800 | padding-left: 1.5rem; 801 | padding-right: 1.5rem; 802 | } 803 | 804 | .py-1 { 805 | padding-top: 0.25rem; 806 | padding-bottom: 0.25rem; 807 | } 808 | 809 | .py-2 { 810 | padding-top: 0.5rem; 811 | padding-bottom: 0.5rem; 812 | } 813 | 814 | .py-3 { 815 | padding-top: 0.75rem; 816 | padding-bottom: 0.75rem; 817 | } 818 | 819 | .py-4 { 820 | padding-top: 1rem; 821 | padding-bottom: 1rem; 822 | } 823 | 824 | .py-5 { 825 | padding-top: 1.25rem; 826 | padding-bottom: 1.25rem; 827 | } 828 | 829 | .pb-4 { 830 | padding-bottom: 1rem; 831 | } 832 | 833 | .pb-6 { 834 | padding-bottom: 1.5rem; 835 | } 836 | 837 | .pr-8 { 838 | padding-right: 2rem; 839 | } 840 | 841 | .pt-2 { 842 | padding-top: 0.5rem; 843 | } 844 | 845 | .pt-8 { 846 | padding-top: 2rem; 847 | } 848 | 849 | .text-left { 850 | text-align: left; 851 | } 852 | 853 | .text-right { 854 | text-align: right; 855 | } 856 | 857 | .align-middle { 858 | vertical-align: middle; 859 | } 860 | 861 | .text-2xl { 862 | font-size: 1.5rem; 863 | line-height: 2rem; 864 | } 865 | 866 | .text-3xl { 867 | font-size: 1.875rem; 868 | line-height: 2.25rem; 869 | } 870 | 871 | .text-lg { 872 | font-size: 1.125rem; 873 | line-height: 1.75rem; 874 | } 875 | 876 | .text-sm { 877 | font-size: 0.875rem; 878 | line-height: 1.25rem; 879 | } 880 | 881 | .text-xs { 882 | font-size: 0.75rem; 883 | line-height: 1rem; 884 | } 885 | 886 | .font-bold { 887 | font-weight: 700; 888 | } 889 | 890 | .font-medium { 891 | font-weight: 500; 892 | } 893 | 894 | .font-semibold { 895 | font-weight: 600; 896 | } 897 | 898 | .uppercase { 899 | text-transform: uppercase; 900 | } 901 | 902 | .leading-4 { 903 | line-height: 1rem; 904 | } 905 | 906 | .leading-5 { 907 | line-height: 1.25rem; 908 | } 909 | 910 | .leading-6 { 911 | line-height: 1.5rem; 912 | } 913 | 914 | .leading-9 { 915 | line-height: 2.25rem; 916 | } 917 | 918 | .leading-none { 919 | line-height: 1; 920 | } 921 | 922 | .leading-tight { 923 | line-height: 1.25; 924 | } 925 | 926 | .tracking-wider { 927 | letter-spacing: 0.05em; 928 | } 929 | 930 | .text-gray-300 { 931 | --tw-text-opacity: 1; 932 | color: rgb(209 213 219 / var(--tw-text-opacity)); 933 | } 934 | 935 | .text-gray-500 { 936 | --tw-text-opacity: 1; 937 | color: rgb(107 114 128 / var(--tw-text-opacity)); 938 | } 939 | 940 | .text-gray-700 { 941 | --tw-text-opacity: 1; 942 | color: rgb(55 65 81 / var(--tw-text-opacity)); 943 | } 944 | 945 | .text-gray-900 { 946 | --tw-text-opacity: 1; 947 | color: rgb(17 24 39 / var(--tw-text-opacity)); 948 | } 949 | 950 | .text-green-500 { 951 | --tw-text-opacity: 1; 952 | color: rgb(34 197 94 / var(--tw-text-opacity)); 953 | } 954 | 955 | .text-indigo-600 { 956 | --tw-text-opacity: 1; 957 | color: rgb(79 70 229 / var(--tw-text-opacity)); 958 | } 959 | 960 | .text-white { 961 | --tw-text-opacity: 1; 962 | color: rgb(255 255 255 / var(--tw-text-opacity)); 963 | } 964 | 965 | .shadow { 966 | --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 967 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 968 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 969 | } 970 | 971 | .filter { 972 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 973 | } 974 | 975 | .transition { 976 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 977 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 978 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 979 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 980 | transition-duration: 150ms; 981 | } 982 | 983 | .duration-150 { 984 | transition-duration: 150ms; 985 | } 986 | 987 | .ease-in-out { 988 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 989 | } 990 | 991 | .hover\:bg-gray-700:hover { 992 | --tw-bg-opacity: 1; 993 | background-color: rgb(55 65 81 / var(--tw-bg-opacity)); 994 | } 995 | 996 | .hover\:text-indigo-900:hover { 997 | --tw-text-opacity: 1; 998 | color: rgb(49 46 129 / var(--tw-text-opacity)); 999 | } 1000 | 1001 | .hover\:text-white:hover { 1002 | --tw-text-opacity: 1; 1003 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1004 | } 1005 | 1006 | .focus\:border-gray-500:focus { 1007 | --tw-border-opacity: 1; 1008 | border-color: rgb(107 114 128 / var(--tw-border-opacity)); 1009 | } 1010 | 1011 | .focus\:bg-gray-700:focus { 1012 | --tw-bg-opacity: 1; 1013 | background-color: rgb(55 65 81 / var(--tw-bg-opacity)); 1014 | } 1015 | 1016 | .focus\:bg-white:focus { 1017 | --tw-bg-opacity: 1; 1018 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1019 | } 1020 | 1021 | .focus\:text-white:focus { 1022 | --tw-text-opacity: 1; 1023 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1024 | } 1025 | 1026 | .focus\:outline-none:focus { 1027 | outline: 2px solid transparent; 1028 | outline-offset: 2px; 1029 | } 1030 | 1031 | @media (min-width: 640px) { 1032 | .sm\:col-span-2 { 1033 | grid-column: span 2 / span 2; 1034 | } 1035 | 1036 | .sm\:-mx-6 { 1037 | margin-left: -1.5rem; 1038 | margin-right: -1.5rem; 1039 | } 1040 | 1041 | .sm\:mt-0 { 1042 | margin-top: 0px; 1043 | } 1044 | 1045 | .sm\:grid { 1046 | display: grid; 1047 | } 1048 | 1049 | .sm\:grid-cols-2 { 1050 | grid-template-columns: repeat(2, minmax(0, 1fr)); 1051 | } 1052 | 1053 | .sm\:grid-cols-3 { 1054 | grid-template-columns: repeat(3, minmax(0, 1fr)); 1055 | } 1056 | 1057 | .sm\:gap-4 { 1058 | gap: 1rem; 1059 | } 1060 | 1061 | .sm\:rounded-lg { 1062 | border-radius: 0.5rem; 1063 | } 1064 | 1065 | .sm\:border-t { 1066 | border-top-width: 1px; 1067 | } 1068 | 1069 | .sm\:border-gray-200 { 1070 | --tw-border-opacity: 1; 1071 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 1072 | } 1073 | 1074 | .sm\:p-0 { 1075 | padding: 0px; 1076 | } 1077 | 1078 | .sm\:p-6 { 1079 | padding: 1.5rem; 1080 | } 1081 | 1082 | .sm\:px-6 { 1083 | padding-left: 1.5rem; 1084 | padding-right: 1.5rem; 1085 | } 1086 | 1087 | .sm\:py-5 { 1088 | padding-top: 1.25rem; 1089 | padding-bottom: 1.25rem; 1090 | } 1091 | } 1092 | 1093 | @media (min-width: 768px) { 1094 | .md\:mb-0 { 1095 | margin-bottom: 0px; 1096 | } 1097 | 1098 | .md\:ml-6 { 1099 | margin-left: 1.5rem; 1100 | } 1101 | 1102 | .md\:flex { 1103 | display: flex; 1104 | } 1105 | 1106 | .md\:w-1\/4 { 1107 | width: 25%; 1108 | } 1109 | 1110 | .md\:items-center { 1111 | align-items: center; 1112 | } 1113 | 1114 | .md\:px-8 { 1115 | padding-left: 2rem; 1116 | padding-right: 2rem; 1117 | } 1118 | 1119 | .md\:py-6 { 1120 | padding-top: 1.5rem; 1121 | padding-bottom: 1.5rem; 1122 | } 1123 | } 1124 | 1125 | @media (min-width: 1024px) { 1126 | .lg\:-mx-8 { 1127 | margin-left: -2rem; 1128 | margin-right: -2rem; 1129 | } 1130 | 1131 | .lg\:inline-flex { 1132 | display: inline-flex; 1133 | } 1134 | 1135 | .lg\:rounded-full { 1136 | border-radius: 9999px; 1137 | } 1138 | 1139 | .lg\:px-8 { 1140 | padding-left: 2rem; 1141 | padding-right: 2rem; 1142 | } 1143 | } 1144 | --------------------------------------------------------------------------------