Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 134 additions & 130 deletions src/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,166 +5,170 @@ use rapina::http::Response;
use rapina::http::header::CONTENT_TYPE;
use rapina::prelude::*;
use rapina::response::BoxBody;
use rapina::sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use std::collections::HashMap;
use rapina::sea_orm::{
ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
};

use crate::entity::batch::Column as BatchColumn;
use crate::entity::merge_event::Column as EventColumn;
use crate::entity::PullRequest;
use crate::entity::pull_request::Column as PrColumn;
use crate::entity::{Batch, MergeEvent, PullRequest};
use crate::types::PrStatus;

const PAGE_SIZE: u64 = 50;

#[derive(serde::Deserialize)]
struct DashboardQuery {
page: Option<u64>,
}

#[public]
#[get("/")]
pub async fn dashboard(db: Db) -> Result<Response<BoxBody>> {
let queued = PullRequest::find()
.filter(PrColumn::Status.eq(PrStatus::Queued.as_ref()))
.order_by_desc(PrColumn::Priority)
.order_by_asc(PrColumn::QueuedAt)
.all(db.conn())
pub async fn dashboard(db: Db, query: Query<DashboardQuery>) -> Result<Response<BoxBody>> {
let page = query.page.unwrap_or(1).max(1);
let now = chrono::Utc::now();

// Stats
let total = PullRequest::find()
.count(db.conn())
.await
.map_err(DbError)?;

let testing = PullRequest::find()
.filter(PrColumn::Status.eq(PrStatus::Testing.as_ref()))
let in_queue = PullRequest::find()
.filter(
PrColumn::Status
.eq(PrStatus::Queued.as_ref())
.or(PrColumn::Status.eq(PrStatus::Testing.as_ref()))
.or(PrColumn::Status.eq(PrStatus::Batched.as_ref())),
)
.count(db.conn())
.await
.map_err(DbError)?;

let failed = PullRequest::find()
.filter(PrColumn::Status.eq(PrStatus::Failed.as_ref()))
.count(db.conn())
.await
.map_err(DbError)?;

let merged = PullRequest::find()
.filter(PrColumn::Status.eq(PrStatus::Merged.as_ref()))
.count(db.conn())
.await
.map_err(DbError)?;

// All PRs: active first (queued/testing/batched), then rest by id desc
// SeaORM doesn't support CASE ordering easily, so we do two queries
let active_statuses = vec![
PrStatus::Queued.to_string(),
PrStatus::Testing.to_string(),
PrStatus::Batched.to_string(),
];

let active_prs = PullRequest::find()
.filter(PrColumn::Status.is_in(active_statuses))
.order_by_desc(PrColumn::Priority)
.order_by_asc(PrColumn::QueuedAt)
.all(db.conn())
.await
.map_err(DbError)?;

let batches = Batch::find()
.order_by_desc(BatchColumn::Id)
.limit(10)
let inactive_statuses = vec![
PrStatus::Merged.to_string(),
PrStatus::Failed.to_string(),
PrStatus::Cancelled.to_string(),
];

let offset = (page - 1) * PAGE_SIZE;
let inactive_prs = PullRequest::find()
.filter(PrColumn::Status.is_in(inactive_statuses))
.order_by_desc(PrColumn::Id)
.offset(offset)
.limit(PAGE_SIZE)
.all(db.conn())
.await
.map_err(DbError)?;

let events = MergeEvent::find()
.order_by_desc(EventColumn::Id)
.limit(20)
.all(db.conn())
let inactive_total = PullRequest::find()
.filter(
PrColumn::Status
.eq(PrStatus::Merged.as_ref())
.or(PrColumn::Status.eq(PrStatus::Failed.as_ref()))
.or(PrColumn::Status.eq(PrStatus::Cancelled.as_ref())),
)
.count(db.conn())
.await
.map_err(DbError)?;

let mut html = String::with_capacity(16384);
let total_pages = (inactive_total as f64 / PAGE_SIZE as f64).ceil() as u64;

let mut html = String::with_capacity(32768);
html.push_str(HEADER);

// Queue section
html.push_str("<div id=\"section-queue\"><h2>Queue</h2>");
if queued.is_empty() {
html.push_str("<p class=\"empty\">No PRs in queue.</p>");
} else {
html.push_str(
"<table><thead><tr><th>PR</th><th>Title</th><th>Author</th><th>Shipped by</th><th>HEAD</th><th>Status</th><th>Time in queue</th></tr></thead><tbody>",
);
let now = chrono::Utc::now();
for pr in &queued {
let time_in_queue = pr
.queued_at
.map(|t| relative_time(now, t))
.unwrap_or_default();
let short_sha = &pr.head_sha[..7.min(pr.head_sha.len())];
let approved = pr.approved_by.as_deref().unwrap_or("\u{2014}");
html.push_str(&format!(
"<tr><td class=\"mono\"><a href=\"https://github.com/{}/{}/pull/{}\" target=\"_blank\">#{}</a></td><td>{}</td><td>{}</td><td>{}</td><td class=\"mono\"><a href=\"https://github.com/{}/{}/commit/{}\" target=\"_blank\">{}</a></td><td><span class=\"status status-queued\">queued</span></td><td class=\"mono\">{}</td></tr>",
pr.repo_owner, pr.repo_name, pr.pr_number, pr.pr_number, pr.title, pr.author, approved, pr.repo_owner, pr.repo_name, pr.head_sha, short_sha, time_in_queue,
));
}
html.push_str("</tbody></table>");
}
html.push_str("</div>");
// Summary stats
html.push_str(&format!(
"<div id=\"section-stats\" class=\"stats\">{} total &middot; {} in queue &middot; {} merged &middot; {} failed</div>",
total, in_queue, merged, failed,
));

// In Progress section
html.push_str("<div id=\"section-testing\"><h2>In Progress</h2>");
if testing.is_empty() {
html.push_str("<p class=\"empty\">No PRs currently testing.</p>");
// PR table
html.push_str("<div id=\"section-prs\">");
if active_prs.is_empty() && inactive_prs.is_empty() {
html.push_str("<p class=\"empty\">No PRs yet.</p>");
} else {
html.push_str(
"<table><thead><tr><th>PR</th><th>Title</th><th>Author</th><th>Shipped by</th><th>HEAD</th><th>Status</th></tr></thead><tbody>",
"<table><thead><tr><th>#</th><th>Status</th><th>Title</th><th>Author</th><th>Shipped by</th><th>HEAD</th><th>Time</th></tr></thead><tbody>",
);
for pr in &testing {

for pr in active_prs.iter().chain(inactive_prs.iter()) {
let short_sha = &pr.head_sha[..7.min(pr.head_sha.len())];
let approved = pr.approved_by.as_deref().unwrap_or("\u{2014}");
html.push_str(&format!(
"<tr><td class=\"mono\"><a href=\"https://github.com/{}/{}/pull/{}\" target=\"_blank\">#{}</a></td><td>{}</td><td>{}</td><td>{}</td><td class=\"mono\"><a href=\"https://github.com/{}/{}/commit/{}\" target=\"_blank\">{}</a></td><td><span class=\"status status-testing\">testing</span></td></tr>",
pr.repo_owner, pr.repo_name, pr.pr_number, pr.pr_number, pr.title, pr.author, approved, pr.repo_owner, pr.repo_name, pr.head_sha, short_sha,
));
}
html.push_str("</tbody></table>");
}
html.push_str("</div>");
let status = PrStatus::from(pr.status.as_str());
let time_col = match status {
PrStatus::Queued | PrStatus::Testing | PrStatus::Batched => pr
.queued_at
.map(|t| relative_time(now, t))
.unwrap_or_default(),
PrStatus::Merged => pr
.merged_at
.map(|t| relative_time(now, t))
.unwrap_or_default(),
_ => pr
.queued_at
.map(|t| relative_time(now, t))
.unwrap_or_default(),
};

// Batches section
html.push_str("<div id=\"section-batches\"><h2>Recent Batches</h2>");
if batches.is_empty() {
html.push_str("<p class=\"empty\">No batches yet.</p>");
} else {
html.push_str(
"<table><thead><tr><th>ID</th><th>Status</th><th>Completed At</th></tr></thead><tbody>",
);
for batch in &batches {
html.push_str(&format!(
"<tr><td class=\"mono\">{}</td><td><span class=\"status status-{}\">{}</span></td><td class=\"mono\">{}</td></tr>",
batch.id,
batch.status,
batch.status,
batch.completed_at.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_else(|| "\u{2014}".to_string()),
"<tr><td class=\"mono\"><a href=\"https://github.com/{}/{}/pull/{}\" target=\"_blank\">{}</a></td><td><span class=\"status status-{}\">{}</span></td><td>{}</td><td>{}</td><td>{}</td><td class=\"mono\"><a href=\"https://github.com/{}/{}/commit/{}\" target=\"_blank\">{}</a></td><td class=\"mono\">{}</td></tr>",
pr.repo_owner, pr.repo_name, pr.pr_number, pr.pr_number,
pr.status, pr.status,
pr.title, pr.author, approved,
pr.repo_owner, pr.repo_name, pr.head_sha, short_sha,
time_col,
));
}
html.push_str("</tbody></table>");
}
html.push_str("</div>");

// Recent Activity section — look up PR info for links
html.push_str("<div id=\"section-events\"><h2>Recent Activity</h2>");
if events.is_empty() {
html.push_str("<p class=\"empty\">No events yet.</p>");
} else {
let pr_ids: Vec<i32> = events.iter().map(|e| e.pull_request_id).collect();
let prs = PullRequest::find()
.filter(PrColumn::Id.is_in(pr_ids))
.all(db.conn())
.await
.map_err(DbError)?;
let pr_map: HashMap<i32, _> = prs.into_iter().map(|p| (p.id, p)).collect();

html.push_str(
"<table><thead><tr><th>Event</th><th>PR</th><th>Details</th></tr></thead><tbody>",
);
for event in &events {
let pr_cell = if let Some(pr) = pr_map.get(&event.pull_request_id) {
format!(
"<a href=\"https://github.com/{}/{}/pull/{}\" target=\"_blank\">#{}</a>",
pr.repo_owner, pr.repo_name, pr.pr_number, pr.pr_number
)
} else {
format!("#{}", event.pull_request_id)
};

let details = match event.details.as_deref() {
Some(sha) if sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit()) => {
if let Some(pr) = pr_map.get(&event.pull_request_id) {
format!(
"<a href=\"https://github.com/{}/{}/commit/{}\" target=\"_blank\">{}</a>",
pr.repo_owner,
pr.repo_name,
sha,
&sha[..7]
)
} else {
sha[..7].to_string()
}
}
Some(d) => d.to_string(),
None => "\u{2014}".to_string(),
};

// Pagination
if total_pages > 1 {
html.push_str("<div class=\"pagination\">");
if page > 1 {
html.push_str(&format!(
"<a href=\"/?page={}\">&#8592; Newer</a>",
page - 1
));
}
html.push_str(&format!(
"<tr><td><span class=\"status status-{}\">{}</span></td><td class=\"mono\">{}</td><td class=\"mono\">{}</td></tr>",
event.event_type, event.event_type, pr_cell, details,
"<span class=\"page-info\">Page {} of {}</span>",
page, total_pages
));
if page < total_pages {
html.push_str(&format!(
"<a href=\"/?page={}\">Older &#8594;</a>",
page + 1
));
}
html.push_str("</div>");
}
html.push_str("</tbody></table>");
}
html.push_str("</div>");

Expand Down Expand Up @@ -211,14 +215,15 @@ const HEADER: &str = r#"<!DOCTYPE html>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: #fff; color: #1a1a1a; padding: 2rem; max-width: 1200px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin-bottom: 2rem; font-weight: 600; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }
h2 { font-size: 1.1rem; margin: 2rem 0 0.75rem; font-weight: 600; color: #333; }
table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; font-size: 0.875rem; }
.stats { font-size: 0.875rem; color: #666; margin-bottom: 1.5rem; }
table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: 0.875rem; }
th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #e5e5e5; font-weight: 600; color: #666; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
tr:hover td { background: #fafafa; }
.mono { font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, monospace; font-size: 0.8125rem; }
.status { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 3px; font-size: 0.75rem; font-weight: 500; }
.status { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 3px; font-size: 0.75rem; font-weight: 500; white-space: nowrap; }
.status-queued { background: #dbeafe; color: #1e40af; }
.status-batched { background: #fef3c7; color: #92400e; }
.status-merged { background: #d1fae5; color: #065f46; }
Expand All @@ -227,13 +232,12 @@ tr:hover td { background: #fafafa; }
.status-pending { background: #e0e7ff; color: #3730a3; }
.status-testing { background: #fef3c7; color: #92400e; }
.status-done { background: #d1fae5; color: #065f46; }
.status-merge_created { background: #e0e7ff; color: #3730a3; }
.status-ci_passed { background: #d1fae5; color: #065f46; }
.status-ci_failed { background: #fee2e2; color: #991b1b; }
a { color: #1e40af; text-decoration: none; }
a:hover { text-decoration: underline; }
.empty { color: #999; font-style: italic; padding: 1rem 0; }
.updated { font-size: 0.75rem; color: #999; margin-top: 2rem; }
.updated { font-size: 0.75rem; color: #999; margin-top: 1rem; }
.pagination { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; margin-bottom: 1rem; }
.page-info { color: #999; }
</style>
</head>
<body>
Expand All @@ -243,7 +247,7 @@ a:hover { text-decoration: underline; }
const FOOTER: &str = r#"<p class="updated" id="updated"></p>
<script>
(function() {
var ids = ['section-queue', 'section-testing', 'section-batches', 'section-events'];
var ids = ['section-stats', 'section-prs'];
function refresh() {
fetch(window.location.href, { headers: { 'Accept': 'text/html' } })
.then(function(r) { return r.text(); })
Expand Down