feat(ui): add /dash route, shared nav, project dashboard with /v1/projects API
Restructure routes: / and /dash show a project overview dashboard, /activity hosts the existing timeline, /cv remains. Shared Layout component provides consistent nav header and footer across all routes. New /v1/projects endpoint aggregates per-repo activity stats (commits, issues, PRs, date range) from existing event data via SQL. Dashboard ranks projects by weighted recency + volume score and renders a card grid. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use moments_core::{EventReader, reshape};
|
||||
use moments_data::PgStore;
|
||||
use moments_entities::{EventQuery, Source, SourceSummary, TimelineItem};
|
||||
use moments_entities::{EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem};
|
||||
use serde::Deserialize;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
@@ -50,6 +50,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/v1/healthz", get(healthz))
|
||||
.route("/v1/events", get(list_events))
|
||||
.route("/v1/sources", get(list_sources))
|
||||
.route("/v1/projects", get(list_projects))
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive());
|
||||
@@ -122,6 +123,13 @@ async fn list_sources(
|
||||
Ok(Json(summaries))
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<ProjectSummary>>, ApiError> {
|
||||
let projects = state.store.list_projects().await.map_err(internal)?;
|
||||
Ok(Json(projects))
|
||||
}
|
||||
|
||||
fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
|
||||
@@ -5,7 +5,7 @@ pub use presentation::reshape;
|
||||
pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use moments_entities::{Event, EventQuery, SourceSummary};
|
||||
use moments_entities::{Event, EventQuery, ProjectSummary, SourceSummary};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
@@ -18,6 +18,7 @@ pub enum StoreError {
|
||||
pub trait EventReader: Send + Sync {
|
||||
async fn list_events(&self, query: &EventQuery) -> Result<Vec<Event>, StoreError>;
|
||||
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
|
||||
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
|
||||
}
|
||||
|
||||
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.
|
||||
|
||||
@@ -8,7 +8,7 @@ pub mod hg;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
|
||||
use moments_entities::{Event, EventQuery, Source, SourceSummary};
|
||||
use moments_entities::{Event, EventQuery, ProjectSummary, Source, SourceSummary};
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
use std::str::FromStr;
|
||||
@@ -115,6 +115,62 @@ impl EventReader for PgStore {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT source, repo, host,
|
||||
SUM(commits)::bigint AS commit_count,
|
||||
SUM(issues)::bigint AS issue_count,
|
||||
SUM(prs)::bigint AS pr_count,
|
||||
MIN(occurred_at) AS first_activity,
|
||||
MAX(occurred_at) AS last_activity
|
||||
FROM (
|
||||
SELECT source, occurred_at,
|
||||
COALESCE(
|
||||
payload->'repo'->>'name',
|
||||
payload->'repository'->>'full_name',
|
||||
payload->>'_repo',
|
||||
payload->>'product'
|
||||
) AS repo,
|
||||
CASE source
|
||||
WHEN 'github' THEN 'github.com'
|
||||
WHEN 'gitea' THEN COALESCE(payload->>'_host', 'git.lair.cafe')
|
||||
WHEN 'hg' THEN COALESCE(payload->>'_host', 'hg-edge.mozilla.org')
|
||||
WHEN 'bugzilla' THEN 'bugzilla.mozilla.org'
|
||||
ELSE 'unknown'
|
||||
END AS host,
|
||||
CASE WHEN action IN ('Commit', 'PushEvent', 'commit_repo') THEN 1 ELSE 0 END AS commits,
|
||||
CASE WHEN action IN ('Issue', 'IssuesEvent') THEN 1 ELSE 0 END AS issues,
|
||||
CASE WHEN action IN ('PullRequest', 'PullRequestEvent') THEN 1 ELSE 0 END AS prs
|
||||
FROM events
|
||||
WHERE public = true
|
||||
) sub
|
||||
WHERE repo IS NOT NULL AND repo != ''
|
||||
GROUP BY source, repo, host
|
||||
ORDER BY MAX(occurred_at) DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
let source_str: String = r.try_get("source").map_err(map_err)?;
|
||||
Ok(ProjectSummary {
|
||||
source: Source::from_str(&source_str).map_err(map_err)?,
|
||||
repo: r.try_get("repo").map_err(map_err)?,
|
||||
host: r.try_get("host").map_err(map_err)?,
|
||||
commit_count: r.try_get::<i64, _>("commit_count").map_err(map_err).unwrap_or(0),
|
||||
issue_count: r.try_get::<i64, _>("issue_count").map_err(map_err).unwrap_or(0),
|
||||
pr_count: r.try_get::<i64, _>("pr_count").map_err(map_err).unwrap_or(0),
|
||||
first_activity: r.try_get("first_activity").map_err(map_err)?,
|
||||
last_activity: r.try_get("last_activity").map_err(map_err)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -82,6 +82,19 @@ pub struct SourceSummary {
|
||||
pub latest: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Per-repo activity rollup for the dashboard.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectSummary {
|
||||
pub repo: String,
|
||||
pub source: Source,
|
||||
pub host: String,
|
||||
pub commit_count: i64,
|
||||
pub issue_count: i64,
|
||||
pub pr_count: i64,
|
||||
pub first_activity: Option<DateTime<Utc>>,
|
||||
pub last_activity: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Presentation shape — what `GET /v1/events` actually returns.
|
||||
// The API reshapes raw payloads into these so the frontend stays dumb.
|
||||
|
||||
Reference in New Issue
Block a user