@@ -18,6 +18,12 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/orgs", post(create_org_submit))
|
||||
.route("/orgs/{org}/projects", get(projects_list))
|
||||
.route("/orgs/{org}/projects/{project}", get(project_detail))
|
||||
.route(
|
||||
"/orgs/{org}/projects/{project}/releases/{slug}",
|
||||
get(artifact_detail),
|
||||
)
|
||||
.route("/orgs/{org}/releases", get(releases_page))
|
||||
.route("/orgs/{org}/destinations", get(destinations_page))
|
||||
.route("/orgs/{org}/usage", get(usage))
|
||||
.route(
|
||||
"/orgs/{org}/settings/members",
|
||||
@@ -97,6 +103,7 @@ async fn dashboard(
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
active_tab => "dashboard",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -111,40 +118,16 @@ async fn dashboard(
|
||||
return Ok(Html(html).into_response());
|
||||
}
|
||||
|
||||
// Fetch recent activity: for each org, get projects, then artifacts
|
||||
let mut recent_activity = Vec::new();
|
||||
for org in orgs {
|
||||
let projects = state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org.name)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Fetch recent releases for the first org to show the pipeline on dashboard
|
||||
let first_org = &orgs[0];
|
||||
let projects = state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &first_org.name)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for project in projects.iter().take(5) {
|
||||
let artifacts = state
|
||||
.platform_client
|
||||
.list_artifacts(&session.access_token, &org.name, project)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for artifact in artifacts {
|
||||
recent_activity.push(context! {
|
||||
org_name => org.name,
|
||||
project_name => project,
|
||||
slug => artifact.slug,
|
||||
title => artifact.context.title,
|
||||
description => artifact.context.description,
|
||||
created_at => artifact.created_at,
|
||||
});
|
||||
if recent_activity.len() >= 10 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if recent_activity.len() >= 10 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let items = fetch_org_artifacts(&state, &session.access_token, &first_org.name, &projects).await;
|
||||
let data = build_timeline(items, &first_org.name);
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
@@ -155,8 +138,11 @@ async fn dashboard(
|
||||
description => "Your Forage dashboard",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &first_org.name,
|
||||
orgs => orgs_context(orgs),
|
||||
recent_activity => recent_activity,
|
||||
timeline => data.timeline,
|
||||
lanes => data.lanes,
|
||||
active_tab => "dashboard",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -205,6 +191,7 @@ async fn create_org_submit(
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
active_tab => "dashboard",
|
||||
error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.",
|
||||
},
|
||||
)
|
||||
@@ -248,6 +235,7 @@ async fn create_org_submit(
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
active_tab => "dashboard",
|
||||
error => "Could not create organisation. Please try again.",
|
||||
},
|
||||
)
|
||||
@@ -289,6 +277,7 @@ async fn projects_list(
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
projects => projects,
|
||||
active_tab => "projects",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -329,6 +318,15 @@ async fn project_detail(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let items: Vec<ArtifactWithProject> = artifacts
|
||||
.into_iter()
|
||||
.map(|a| ArtifactWithProject {
|
||||
artifact: a,
|
||||
project_name: project.clone(),
|
||||
})
|
||||
.collect();
|
||||
let data = build_timeline(items, &org);
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
@@ -342,23 +340,99 @@ async fn project_detail(
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
project_name => &project,
|
||||
artifacts => artifacts.iter().map(|a| {
|
||||
context! {
|
||||
slug => a.slug,
|
||||
title => a.context.title,
|
||||
description => a.context.description,
|
||||
created_at => a.created_at,
|
||||
source_user => a.source.as_ref().and_then(|s| s.user.clone()),
|
||||
source_type => a.source.as_ref().and_then(|s| s.source_type.clone()),
|
||||
run_url => a.source.as_ref().and_then(|s| s.run_url.clone()),
|
||||
commit_sha => a.git_ref.as_ref().map(|r| r.commit_sha.clone()),
|
||||
branch => a.git_ref.as_ref().and_then(|r| r.branch.clone()),
|
||||
version => a.git_ref.as_ref().and_then(|r| r.version.clone()),
|
||||
destinations => a.destinations.iter().map(|d| {
|
||||
context! { name => d.name, environment => d.environment }
|
||||
}).collect::<Vec<_>>(),
|
||||
}
|
||||
}).collect::<Vec<_>>(),
|
||||
active_tab => "projects",
|
||||
timeline => data.timeline,
|
||||
lanes => data.lanes,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Artifact detail ─────────────────────────────────────────────────
|
||||
|
||||
async fn artifact_detail(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, project, slug)): Path<(String, String, String)>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
if !validate_slug(&project) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Invalid project name.",
|
||||
));
|
||||
}
|
||||
|
||||
let artifact = state
|
||||
.platform_client
|
||||
.get_artifact_by_slug(&session.access_token, &slug)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
forage_core::platform::PlatformError::NotFound(_) => error_page(
|
||||
&state,
|
||||
StatusCode::NOT_FOUND,
|
||||
"Not found",
|
||||
"This release could not be found.",
|
||||
),
|
||||
other => {
|
||||
tracing::error!("failed to fetch artifact: {other}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/artifact_detail.html.jinja",
|
||||
context! {
|
||||
title => format!("{} - {} - {} - Forage", artifact.context.title, project, org),
|
||||
description => artifact.context.description,
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &org,
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
project_name => &project,
|
||||
active_tab => "projects",
|
||||
artifact => context! {
|
||||
slug => artifact.slug,
|
||||
title => artifact.context.title,
|
||||
description => artifact.context.description,
|
||||
web => artifact.context.web,
|
||||
pr => artifact.context.pr,
|
||||
created_at => artifact.created_at,
|
||||
source_user => artifact.source.as_ref().and_then(|s| s.user.clone()),
|
||||
source_email => artifact.source.as_ref().and_then(|s| s.email.clone()),
|
||||
source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()),
|
||||
run_url => artifact.source.as_ref().and_then(|s| s.run_url.clone()),
|
||||
commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
|
||||
branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
|
||||
commit_message => artifact.git_ref.as_ref().and_then(|r| r.commit_message.clone()),
|
||||
version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
|
||||
repo_url => artifact.git_ref.as_ref().and_then(|r| r.repo_url.clone()),
|
||||
destinations => artifact.destinations.iter().map(|d| {
|
||||
context! { name => d.name, environment => d.environment }
|
||||
}).collect::<Vec<_>>(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -404,6 +478,259 @@ async fn usage(
|
||||
org_name => &org,
|
||||
role => ¤t_org_data.role,
|
||||
project_count => projects.len(),
|
||||
active_tab => "usage",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Timeline builder (shared between dashboard, project detail, releases) ───
|
||||
|
||||
struct ArtifactWithProject {
|
||||
artifact: forage_core::platform::Artifact,
|
||||
project_name: String,
|
||||
}
|
||||
|
||||
struct TimelineData {
|
||||
timeline: Vec<minijinja::Value>,
|
||||
lanes: Vec<minijinja::Value>,
|
||||
}
|
||||
|
||||
fn build_timeline(items: Vec<ArtifactWithProject>, org_name: &str) -> TimelineData {
|
||||
struct RawRelease {
|
||||
value: minijinja::Value,
|
||||
has_dests: bool,
|
||||
}
|
||||
|
||||
let mut raw_releases: Vec<RawRelease> = Vec::new();
|
||||
let mut env_set = std::collections::BTreeSet::new();
|
||||
|
||||
for item in items {
|
||||
let artifact = item.artifact;
|
||||
let project = &item.project_name;
|
||||
|
||||
let mut release_envs = Vec::new();
|
||||
let dests: Vec<minijinja::Value> = artifact
|
||||
.destinations
|
||||
.iter()
|
||||
.map(|d| {
|
||||
env_set.insert(d.environment.clone());
|
||||
release_envs.push(d.environment.clone());
|
||||
context! {
|
||||
name => d.name,
|
||||
environment => d.environment,
|
||||
type_name => d.type_name,
|
||||
type_version => d.type_version,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let has_dests = !dests.is_empty();
|
||||
let dest_envs_str = release_envs.join(",");
|
||||
raw_releases.push(RawRelease {
|
||||
value: context! {
|
||||
slug => artifact.slug,
|
||||
title => artifact.context.title,
|
||||
description => artifact.context.description,
|
||||
project_name => project,
|
||||
org_name => org_name,
|
||||
created_at => artifact.created_at,
|
||||
commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
|
||||
branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
|
||||
version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
|
||||
source_user => artifact.source.as_ref().and_then(|s| s.user.clone()),
|
||||
source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()),
|
||||
destinations => dests,
|
||||
dest_envs => dest_envs_str,
|
||||
},
|
||||
has_dests,
|
||||
});
|
||||
}
|
||||
|
||||
let lanes: Vec<minijinja::Value> = env_set
|
||||
.into_iter()
|
||||
.map(|env| context! { name => env })
|
||||
.collect();
|
||||
|
||||
let mut timeline_items: Vec<minijinja::Value> = Vec::new();
|
||||
let mut hidden_buf: Vec<minijinja::Value> = Vec::new();
|
||||
|
||||
for raw in raw_releases {
|
||||
if raw.has_dests {
|
||||
if !hidden_buf.is_empty() {
|
||||
let count = hidden_buf.len();
|
||||
timeline_items.push(context! {
|
||||
kind => "hidden",
|
||||
count => count,
|
||||
releases => std::mem::take(&mut hidden_buf),
|
||||
});
|
||||
}
|
||||
timeline_items.push(context! {
|
||||
kind => "release",
|
||||
release => raw.value,
|
||||
});
|
||||
} else {
|
||||
hidden_buf.push(raw.value);
|
||||
}
|
||||
}
|
||||
if !hidden_buf.is_empty() {
|
||||
let count = hidden_buf.len();
|
||||
timeline_items.push(context! {
|
||||
kind => "hidden",
|
||||
count => count,
|
||||
releases => std::mem::take(&mut hidden_buf),
|
||||
});
|
||||
}
|
||||
|
||||
TimelineData {
|
||||
timeline: timeline_items,
|
||||
lanes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all artifacts across projects and return as ArtifactWithProject list.
|
||||
async fn fetch_org_artifacts(
|
||||
state: &AppState,
|
||||
access_token: &str,
|
||||
org: &str,
|
||||
projects: &[String],
|
||||
) -> Vec<ArtifactWithProject> {
|
||||
let mut items = Vec::new();
|
||||
for project in projects {
|
||||
let artifacts = state
|
||||
.platform_client
|
||||
.list_artifacts(access_token, org, project)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for artifact in artifacts {
|
||||
items.push(ArtifactWithProject {
|
||||
artifact,
|
||||
project_name: project.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
// ─── Releases (Up-inspired pipeline) ─────────────────────────────────
|
||||
|
||||
async fn releases_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
let projects = state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let items = fetch_org_artifacts(&state, &session.access_token, &org, &projects).await;
|
||||
let data = build_timeline(items, &org);
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/releases.html.jinja",
|
||||
context! {
|
||||
title => format!("Releases - {org} - Forage"),
|
||||
description => format!("Deployment pipeline for {org}"),
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &org,
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
timeline => data.timeline,
|
||||
lanes => data.lanes,
|
||||
active_tab => "releases",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Destinations ────────────────────────────────────────────────────
|
||||
|
||||
async fn destinations_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let current_org = require_org_membership(&state, orgs, &org)?;
|
||||
let is_admin = current_org.role == "owner" || current_org.role == "admin";
|
||||
|
||||
let projects = state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Aggregate unique destinations from artifacts
|
||||
let mut destinations = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for project in &projects {
|
||||
let artifacts = state
|
||||
.platform_client
|
||||
.list_artifacts(&session.access_token, &org, project)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for artifact in &artifacts {
|
||||
for dest in &artifact.destinations {
|
||||
let key = (dest.name.clone(), dest.environment.clone());
|
||||
if seen.insert(key) {
|
||||
destinations.push(context! {
|
||||
name => dest.name,
|
||||
environment => dest.environment,
|
||||
project_name => project,
|
||||
artifact_title => artifact.context.title,
|
||||
artifact_slug => artifact.slug,
|
||||
created_at => artifact.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/destinations.html.jinja",
|
||||
context! {
|
||||
title => format!("Destinations - {org} - Forage"),
|
||||
description => format!("Deployment destinations for {org}"),
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &org,
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
destinations => destinations,
|
||||
is_admin => is_admin,
|
||||
active_tab => "destinations",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -450,6 +777,7 @@ async fn members_page(
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
is_admin => is_admin,
|
||||
active_tab => "members",
|
||||
members => members.iter().map(|m| context! {
|
||||
user_id => m.user_id,
|
||||
username => m.username,
|
||||
|
||||
Reference in New Issue
Block a user