| 1 | |
|
| 2 | |
|
| 3 | | use super::{filters, Error, Page, Shell}; |
| 4 | | use crate::auth::Viewer; |
| 5 | | use crate::{db, Shared}; |
| 6 | | use askama::Template; |
| 7 | | use axum::extract::{Path, Query, State}; |
| 8 | | use axum::http::header; |
| 9 | | use axum::response::{IntoResponse, Response}; |
| 10 | | use hub_api::Visibility; |
| 11 | | use serde::Deserialize; |
| 12 | |
|
| 13 | | #[derive(Template)] |
| 14 | | #[template(path = "home.html")] |
| 15 | | pub struct HomePage { |
| 16 | | shell: Shell, |
| 17 | | description: String, |
| 18 | | repos: Vec<db::repos::RepoRow>, |
| 19 | | owner: Option<String>, |
| 20 | | |
| 21 | | q: String, |
| 22 | | sort: String, |
| 23 | | topic: String, |
| 24 | | stars: std::collections::HashMap<i64, i64>, |
| 25 | | |
| 26 | | |
| 27 | | backdrop: String, |
| 28 | | } |
| 29 | |
|
| 30 | | impl HomePage { |
| 31 | 22× | fn star_count(&self, id: &i64) -> i64 { |
| 32 | 22× | self.stars.get(id).copied().unwrap_or(0) |
| 33 | 22× | } |
| 34 | | } |
| 35 | |
|
| 36 | |
|
| 37 | | #[derive(Deserialize, Default)] |
| 38 | | pub struct ExploreQuery { |
| 39 | | #[serde(default)] |
| 40 | | q: String, |
| 41 | | #[serde(default)] |
| 42 | | sort: String, |
| 43 | | #[serde(default)] |
| 44 | | topic: String, |
| 45 | | } |
| 46 | |
|
| 47 | |
|
| 48 | |
|
| 49 | |
|
| 50 | | #[derive(Template)] |
| 51 | | #[template(path = "dashboard.html")] |
| 52 | | pub struct Dashboard { |
| 53 | | shell: Shell, |
| 54 | | mine: Vec<db::repos::RepoRow>, |
| 55 | | reviews: Vec<db::activity::ProposalLine>, |
| 56 | | authored: Vec<db::activity::ProposalLine>, |
| 57 | | assigned: Vec<db::activity::IssueLine>, |
| 58 | | feed: Vec<db::activity::Activity>, |
| 59 | | |
| 60 | | backdrop: String, |
| 61 | | |
| 62 | | |
| 63 | | live: String, |
| 64 | | } |
| 65 | |
|
| 66 | |
|
| 67 | | const FEED_LIMIT: i64 = 40; |
| 68 | |
|
| 69 | | const SKY_EVENTS: i64 = 1500; |
| 70 | |
|
| 71 | | const SKY_REPOS: usize = 40; |
| 72 | | const SKY_DAYS: i64 = 30; |
| 73 | |
|
| 74 | |
|
| 75 | |
|
| 76 | |
|
| 77 | |
|
| 78 | |
|
| 79 | |
|
| 80 | 168× | fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64, live: &str) -> String { |
| 81 | 168× | let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect(); |
| 82 | 168× | let evs: Vec<serde_json::Value> = events |
| 83 | 168× | .iter() |
| 84 | 1035× | .filter_map(|e| { |
| 85 | 1038× | let repo = mine.iter().position(|r| r.id == e.repo_id)?; |
| 86 | 1035× | Some(serde_json::json!([repo, e.kind, e.at_ms, e.n])) |
| 87 | 1035× | }) |
| 88 | 168× | .collect(); |
| 89 | 168× | serde_json::json!({ "now": now, "repos": slugs, "events": evs, "live": live }).to_string().replace('<', "\\u003c") |
| 90 | 168× | } |
| 91 | |
|
| 92 | |
|
| 93 | |
|
| 94 | 144× | pub(crate) async fn repo_sky(app: &Shared, row: &db::repos::RepoRow) -> String { |
| 95 | 144× | let now = db::now_ms(); |
| 96 | 144× | match db::activity::sky(&app.db, &[row.id], now - SKY_DAYS * 86_400_000, SKY_EVENTS).await { |
| 97 | 144× | Ok(events) => sky_json(std::slice::from_ref(row), &events, now, ""), |
| 98 | 0× | Err(_) => String::new(), |
| 99 | | } |
| 100 | 144× | } |
| 101 | |
|
| 102 | |
|
| 103 | |
|
| 104 | |
|
| 105 | 6× | async fn sky_repos(app: &Shared, shell: &Shell, viewer: &Viewer, scene: &str) -> Result<Vec<db::repos::RepoRow>, Error> { |
| 106 | 6× | let db_err = |e| Error::from_db(shell.clone(), e); |
| 107 | 6× | let seen = visible(app, shell, viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?; |
| 108 | 6× | match (scene, viewer.account()) { |
| 109 | 6× | ("mine", Some(account)) => { |
| 110 | 3× | let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?; |
| 111 | 5× | Ok(seen.into_iter().filter(|r| member.contains(&r.id)).collect()) |
| 112 | | } |
| 113 | 2× | ("hub", _) => { |
| 114 | 3× | let mut public: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| r.visibility == "public").collect(); |
| 115 | 2× | public.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); |
| 116 | 2× | public.truncate(SKY_REPOS); |
| 117 | 2× | Ok(public) |
| 118 | | } |
| 119 | 1× | _ => Ok(Vec::new()), |
| 120 | | } |
| 121 | 6× | } |
| 122 | |
|
| 123 | | #[derive(Deserialize)] |
| 124 | | pub struct ArrivalsQuery { |
| 125 | | #[serde(default)] |
| 126 | | scene: String, |
| 127 | | #[serde(default)] |
| 128 | | since: i64, |
| 129 | | } |
| 130 | |
|
| 131 | |
|
| 132 | |
|
| 133 | |
|
| 134 | |
|
| 135 | 6× | pub async fn arrivals(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ArrivalsQuery>) -> Result<Response, Error> { |
| 136 | 6× | let shell = Shell::new(&app, "/sky/arrivals", &viewer); |
| 137 | 6× | let repos = sky_repos(&app, &shell, &viewer, &q.scene).await?; |
| 138 | 6× | let now = db::now_ms(); |
| 139 | | |
| 140 | 6× | let since = q.since.clamp(now - 86_400_000, now); |
| 141 | 6× | let ids: Vec<i64> = repos.iter().map(|r| r.id).collect(); |
| 142 | 6× | let events = if ids.is_empty() { |
| 143 | 2× | Vec::new() |
| 144 | | } else { |
| 145 | 4× | db::activity::sky(&app.db, &ids, since + 1, 200).await.map_err(|e| Error::from_db(shell.clone(), e))? |
| 146 | | }; |
| 147 | 6× | let evs: Vec<serde_json::Value> = events |
| 148 | 6× | .iter() |
| 149 | 6× | .filter_map(|e| { |
| 150 | 5× | let r = repos.iter().find(|r| r.id == e.repo_id)?; |
| 151 | 4× | Some(serde_json::json!([format!("{}/{}", r.owner, r.name), e.kind, e.at_ms, e.n])) |
| 152 | 4× | }) |
| 153 | 6× | .collect(); |
| 154 | 6× | let body = serde_json::json!({ "now": now, "events": evs }); |
| 155 | 6× | Ok(([(axum::http::header::CACHE_CONTROL, "private, no-store")], axum::Json(body)).into_response()) |
| 156 | 6× | } |
| 157 | |
|
| 158 | |
|
| 159 | 11× | pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> { |
| 160 | 11× | let Some(account) = viewer.account().cloned() else { |
| 161 | 3× | return Ok(explore(State(app), viewer, Query(ExploreQuery::default())).await?.into_response()); |
| 162 | | }; |
| 163 | 8× | let shell = Shell::new(&app, "/", &viewer); |
| 164 | 8× | let db_err = |e| Error::from_db(shell.clone(), e); |
| 165 | 8× | let all = db::repos::list(&app.db).await.map_err(db_err)?; |
| 166 | 8× | let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?; |
| 167 | 8× | let seen = visible(&app, &shell, &viewer, all).await?; |
| 168 | 8× | let seen_ids: Vec<i64> = seen.iter().map(|r| r.id).collect(); |
| 169 | 8× | let mut mine: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| member.contains(&r.id)).collect(); |
| 170 | 8× | mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); |
| 171 | 8× | let reviews = db::activity::awaiting_review(&app.db, account.id, &member).await.map_err(db_err)?; |
| 172 | 8× | let authored = db::activity::authored(&app.db, account.id, &seen_ids).await.map_err(db_err)?; |
| 173 | 8× | let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?; |
| 174 | 8× | let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?; |
| 175 | 8× | let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect(); |
| 176 | 8× | let now = db::now_ms(); |
| 177 | 8× | let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; |
| 178 | 8× | let backdrop = sky_json(&mine, &sky, now, "mine"); |
| 179 | 8× | let live = std::iter::once(shell.live_user.clone()) |
| 180 | 8× | .chain(mine.iter().take(15).map(|r| crate::events::topic::repo(r.id))) |
| 181 | 15× | .filter(|t| !t.is_empty()) |
| 182 | 8× | .collect::<Vec<_>>() |
| 183 | 8× | .join(" "); |
| 184 | 8× | Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop, live }).into_response()) |
| 185 | 11× | } |
| 186 | |
|
| 187 | |
|
| 188 | |
|
| 189 | 16× | pub async fn explore(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ExploreQuery>) -> Result<Page<HomePage>, Error> { |
| 190 | 16× | let shell = Shell::new(&app, "/explore", &viewer); |
| 191 | 16× | let db_err = |e| Error::from_db(shell.clone(), e); |
| 192 | 16× | let mut repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?; |
| 193 | 16× | let stars = db::repos::star_counts(&app.db).await.map_err(db_err)?; |
| 194 | 16× | let needle = q.q.trim().to_lowercase(); |
| 195 | 16× | if !needle.is_empty() { |
| 196 | 3× | repos.retain(|r| { |
| 197 | 3× | r.slug().to_lowercase().contains(&needle) |
| 198 | 3× | || r.description.to_lowercase().contains(&needle) |
| 199 | 2× | || r.topic_list().iter().any(|t| t.contains(&needle)) |
| 200 | 3× | }); |
| 201 | 15× | } |
| 202 | 16× | let topic = q.topic.trim().to_lowercase(); |
| 203 | 16× | if !topic.is_empty() { |
| 204 | 3× | repos.retain(|r| r.topic_list().contains(&topic.as_str())); |
| 205 | 15× | } |
| 206 | 16× | let sort = match q.sort.as_str() { |
| 207 | 16× | "stars" | "name" => q.sort.clone(), |
| 208 | 14× | _ => "activity".to_string(), |
| 209 | | }; |
| 210 | 16× | match sort.as_str() { |
| 211 | 16× | "stars" => repos.sort_by_key(|r| (std::cmp::Reverse(stars.get(&r.id).copied().unwrap_or(0)), std::cmp::Reverse(r.updated_ms))), |
| 212 | 15× | "name" => repos.sort_by_key(|r| (r.name.to_lowercase(), r.owner.to_lowercase())), |
| 213 | 14× | _ => repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)), |
| 214 | | } |
| 215 | | |
| 216 | | |
| 217 | 16× | let public: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.visibility == "public").take(SKY_REPOS).cloned().collect(); |
| 218 | 16× | let ids: Vec<i64> = public.iter().map(|r| r.id).collect(); |
| 219 | 16× | let now = db::now_ms(); |
| 220 | 16× | let sky = db::activity::sky(&app.db, &ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; |
| 221 | 16× | let backdrop = sky_json(&public, &sky, now, "hub"); |
| 222 | 16× | Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars, backdrop })) |
| 223 | 16× | } |
| 224 | |
|
| 225 | |
|
| 226 | |
|
| 227 | | #[derive(Template)] |
| 228 | | #[template(path = "profile.html")] |
| 229 | | pub struct ProfilePage { |
| 230 | | shell: Shell, |
| 231 | | account: db::accounts::Account, |
| 232 | | keys: Vec<db::accounts::LinkedIdentity>, |
| 233 | | orgs: Vec<(db::accounts::Organisation, String)>, |
| 234 | | repos: Vec<db::repos::RepoRow>, |
| 235 | | feed: Vec<db::activity::Activity>, |
| 236 | | is_me: bool, |
| 237 | | |
| 238 | | pulse: String, |
| 239 | | |
| 240 | | pulse_total: i64, |
| 241 | | } |
| 242 | |
|
| 243 | | impl ProfilePage { |
| 244 | | |
| 245 | 11× | fn website_href(&self) -> Option<&str> { |
| 246 | 11× | let w = self.account.website.trim(); |
| 247 | 11× | (w.starts_with("https://") || w.starts_with("http://")).then_some(w) |
| 248 | 11× | } |
| 249 | | } |
| 250 | |
|
| 251 | |
|
| 252 | |
|
| 253 | | #[derive(Template)] |
| 254 | | #[template(path = "org.html")] |
| 255 | | pub struct OrgPage { |
| 256 | | shell: Shell, |
| 257 | | org: db::accounts::Organisation, |
| 258 | | members: Vec<(db::accounts::Account, String)>, |
| 259 | | teams: Vec<(db::accounts::Team, usize)>, |
| 260 | | repos: Vec<db::repos::RepoRow>, |
| 261 | | feed: Vec<db::activity::Activity>, |
| 262 | | can_admin: bool, |
| 263 | | } |
| 264 | |
|
| 265 | | const DAY_MS: i64 = 86_400_000; |
| 266 | |
|
| 267 | |
|
| 268 | |
|
| 269 | |
|
| 270 | |
|
| 271 | |
|
| 272 | |
|
| 273 | |
|
| 274 | 11× | async fn pulse_json(app: &Shared, account: &db::accounts::Account, seen: &[db::repos::RepoRow]) -> sqlx::Result<(String, i64)> { |
| 275 | 11× | let today = db::now_ms() / DAY_MS; |
| 276 | 11× | let ids: Vec<i64> = seen.iter().map(|r| r.id).collect(); |
| 277 | 11× | let rows = db::activity::pulse(&app.db, account.id, &ids, (today - 364) * DAY_MS).await?; |
| 278 | | |
| 279 | 11× | let used = rows.first().map(|r| today - r.1 / DAY_MS + 1).unwrap_or(0); |
| 280 | 11× | let (months, span) = [(3, 91), (6, 182), (9, 273), (12, 365)].into_iter().find(|(_, d)| used <= *d).unwrap_or((12, 365)); |
| 281 | 11× | let first = today - span + 1; |
| 282 | 11× | let (pulse, total) = pulse_of(&rows, seen, first, span as usize); |
| 283 | 11× | let mut value = pulse; |
| 284 | 11× | let mut peaks = Vec::new(); |
| 285 | 11× | for (repo_id, tag, at) in db::activity::releases_by(&app.db, account.id, &ids, first * DAY_MS).await? { |
| 286 | 1× | let name = seen.iter().find(|r| r.id == repo_id).map(|r| r.name.as_str()).unwrap_or(""); |
| 287 | 1× | peaks.push(serde_json::json!([at / DAY_MS - first, format!("{name} {tag}")])); |
| 288 | | } |
| 289 | 11× | value["peaks"] = serde_json::Value::Array(peaks); |
| 290 | 11× | value["months"] = months.into(); |
| 291 | 11× | Ok((value.to_string().replace('<', "\\u003c"), total)) |
| 292 | 11× | } |
| 293 | |
|
| 294 | |
|
| 295 | |
|
| 296 | 11× | fn pulse_of(rows: &[(i64, i64, i64, String)], seen: &[db::repos::RepoRow], first: i64, span: usize) -> (serde_json::Value, i64) { |
| 297 | 11× | let mut per_repo: std::collections::BTreeMap<i64, i64> = std::collections::BTreeMap::new(); |
| 298 | 11× | for (repo, _, n, _) in rows { |
| 299 | 3× | *per_repo.entry(*repo).or_default() += n; |
| 300 | 3× | } |
| 301 | 11× | let mut order: Vec<(i64, i64)> = per_repo.into_iter().collect(); |
| 302 | 11× | order.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); |
| 303 | 11× | let names: Vec<String> = order |
| 304 | 11× | .iter() |
| 305 | 11× | .map(|(id, _)| seen.iter().find(|r| r.id == *id).map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default()) |
| 306 | 11× | .collect(); |
| 307 | 11× | let slot = |id: i64| order.iter().position(|(r, _)| *r == id).unwrap_or(0); |
| 308 | 11× | let mut days: Vec<(i64, i64, std::collections::BTreeMap<usize, i64>)> = vec![(0, 0, Default::default()); span]; |
| 309 | 11× | let mut total = 0; |
| 310 | 11× | for (repo, at, n, via) in rows { |
| 311 | 3× | let d = at / DAY_MS - first; |
| 312 | 3× | if d < 0 || d as usize >= span { |
| 313 | 0× | continue; |
| 314 | 3× | } |
| 315 | 3× | let day = &mut days[d as usize]; |
| 316 | 3× | day.0 += n; |
| 317 | 3× | if !via.is_empty() { |
| 318 | 1× | day.1 += n; |
| 319 | 2× | } |
| 320 | 3× | *day.2.entry(slot(*repo)).or_default() += n; |
| 321 | 3× | total += n; |
| 322 | | } |
| 323 | 11× | let days: Vec<serde_json::Value> = days |
| 324 | 11× | .into_iter() |
| 325 | 1001× | .map(|(t, v, by)| serde_json::json!([t, v, by.into_iter().map(|(r, n)| serde_json::json!([r, n])).collect::<Vec<_>>()])) |
| 326 | 11× | .collect(); |
| 327 | 11× | (serde_json::json!({ "start": first * DAY_MS, "repos": names, "days": days }), total) |
| 328 | 11× | } |
| 329 | |
|
| 330 | |
|
| 331 | | const OWNER_FEED: i64 = 20; |
| 332 | |
|
| 333 | |
|
| 334 | |
|
| 335 | |
|
| 336 | 16× | pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Response, Error> { |
| 337 | 16× | let shell = Shell::new(&app, &format!("/{owner}"), &viewer); |
| 338 | 16× | if !crate::repos::names::valid_owner(&owner) { |
| 339 | 0× | return Err(Error::not_found(shell)); |
| 340 | 16× | } |
| 341 | 16× | let db_err = |e| Error::from_db(shell.clone(), e); |
| 342 | 16× | let mut repos = visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(db_err)?).await?; |
| 343 | 16× | repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); |
| 344 | 16× | if let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? { |
| 345 | | |
| 346 | 11× | let all = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?; |
| 347 | 11× | let ids: Vec<i64> = all.iter().map(|r| r.id).collect(); |
| 348 | 11× | let feed = db::activity::feed_by(&app.db, &ids, Some(&account.username), OWNER_FEED).await.map_err(db_err)?; |
| 349 | 11× | let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?; |
| 350 | 11× | let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?; |
| 351 | 11× | let is_me = viewer.account().is_some_and(|a| a.id == account.id); |
| 352 | 11× | let (pulse, pulse_total) = pulse_json(&app, &account, &all).await.map_err(db_err)?; |
| 353 | 11× | return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me, pulse, pulse_total }).into_response()); |
| 354 | 5× | } |
| 355 | 5× | if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? { |
| 356 | 2× | let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?; |
| 357 | 2× | let mut teams = Vec::new(); |
| 358 | 2× | for t in db::accounts::teams(&app.db, org.id).await.map_err(db_err)? { |
| 359 | 2× | let n = db::accounts::team_members(&app.db, t.id).await.map_err(db_err)?.len(); |
| 360 | 2× | teams.push((t, n)); |
| 361 | | } |
| 362 | 2× | let ids: Vec<i64> = repos.iter().map(|r| r.id).collect(); |
| 363 | 2× | let feed = db::activity::feed(&app.db, &ids, OWNER_FEED).await.map_err(db_err)?; |
| 364 | 2× | let can_admin = |
| 365 | 2× | viewer.is_admin() || viewer.account().is_some_and(|a| members.iter().any(|(m, role)| m.id == a.id && role == "owner")); |
| 366 | 2× | return Ok(Page(OrgPage { shell, org, members, teams, repos, feed, can_admin }).into_response()); |
| 367 | 3× | } |
| 368 | 3× | if repos.is_empty() { |
| 369 | 2× | return Err(Error::not_found(shell)); |
| 370 | 1× | } |
| 371 | 1× | let stars = db::repos::star_counts(&app.db).await.unwrap_or_default(); |
| 372 | 1× | Ok(Page(HomePage { |
| 373 | 1× | description: String::new(), |
| 374 | 1× | shell, |
| 375 | 1× | repos, |
| 376 | 1× | owner: Some(owner), |
| 377 | 1× | q: String::new(), |
| 378 | 1× | sort: "activity".into(), |
| 379 | 1× | topic: String::new(), |
| 380 | 1× | stars, |
| 381 | 1× | backdrop: String::new(), |
| 382 | 1× | }) |
| 383 | 1× | .into_response()) |
| 384 | 16× | } |
| 385 | |
|
| 386 | |
|
| 387 | |
|
| 388 | 110× | pub(crate) async fn visible( |
| 389 | 110× | app: &Shared, |
| 390 | 110× | shell: &Shell, |
| 391 | 110× | viewer: &Viewer, |
| 392 | 110× | rows: Vec<db::repos::RepoRow>, |
| 393 | 110× | ) -> Result<Vec<db::repos::RepoRow>, Error> { |
| 394 | 110× | let mine: Vec<i64> = match viewer.account() { |
| 395 | 30× | Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(), |
| 396 | 23× | Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?, |
| 397 | 57× | None => vec![], |
| 398 | | }; |
| 399 | 110× | let signed_in = viewer.signed_in(); |
| 400 | 110× | Ok(rows |
| 401 | 110× | .into_iter() |
| 402 | 110× | .filter(|r| match r.visibility() { |
| 403 | 43× | Visibility::Public => true, |
| 404 | 0× | Visibility::Internal => signed_in, |
| 405 | 27× | Visibility::Private => mine.contains(&r.id), |
| 406 | 70× | }) |
| 407 | 110× | .collect()) |
| 408 | 110× | } |
| 409 | |
|
| 410 | 5× | pub async fn health(State(app): State<Shared>) -> Response { |
| 411 | 5× | let (states, weight, summaries) = app.repos.cache().stats(); |
| 412 | 5× | let strata = oxy_strata::stats(); |
| 413 | 5× | let body = serde_json::json!({ |
| 414 | 5× | "ok": true, |
| 415 | 5× | "version": env!("CARGO_PKG_VERSION"), |
| 416 | 5× | "build": env!("OXY_BUILD_ID"), |
| 417 | 5× | "uptime_s": app.started.elapsed().as_secs(), |
| 418 | | |
| 419 | 5× | "roles": app.roles.names(), |
| 420 | 5× | "leading": app.leader.leading(), |
| 421 | | |
| 422 | 5× | "state_cache": { |
| 423 | 5× | "states": states, |
| 424 | 5× | "estimated_mb": weight >> 20, |
| 425 | 5× | "budget_mb": crate::repos::cache::budget() >> 20, |
| 426 | 5× | "summaries": summaries, |
| 427 | | }, |
| 428 | | |
| 429 | | |
| 430 | | |
| 431 | 5× | "cloud_stores": { |
| 432 | 5× | "opened": strata.opens, |
| 433 | 5× | "requests": strata.calls, |
| 434 | 5× | "request_ms": strata.millis, |
| 435 | 5× | "by_kind": strata |
| 436 | 5× | .ops() |
| 437 | 60× | .map(|(k, n)| (k.to_string(), n.into())) |
| 438 | 5× | .collect::<serde_json::Map<_, _>>(), |
| 439 | | }, |
| 440 | | }); |
| 441 | 5× | axum::Json(body).into_response() |
| 442 | 5× | } |
| 443 | |
|
| 444 | |
|
| 445 | |
|
| 446 | 27× | pub async fn hub_key(State(app): State<Shared>) -> Response { |
| 447 | 27× | ( |
| 448 | 27× | [(header::CONTENT_TYPE, "application/oxy-identity"), (header::CONTENT_DISPOSITION, "attachment; filename=\"hub.identity\"")], |
| 449 | 27× | app.hub_key.bytes.clone(), |
| 450 | 27× | ) |
| 451 | 27× | .into_response() |
| 452 | 27× | } |
| 453 | |
|
| 454 | |
|
| 455 | |
|
| 456 | |
|
| 457 | 1× | pub async fn hub_ca(State(app): State<Shared>) -> Response { |
| 458 | 1× | if !app.config.tls.self_signed { |
| 459 | 1× | return (axum::http::StatusCode::NOT_FOUND, "this hub does not use a self-signed certificate").into_response(); |
| 460 | 0× | } |
| 461 | 0× | match tokio::fs::read(app.config.tls_cert()).await { |
| 462 | 0× | Ok(pem) => ( |
| 463 | 0× | [(header::CONTENT_TYPE, "application/x-pem-file"), (header::CONTENT_DISPOSITION, "attachment; filename=\"oxyhub-ca.pem\"")], |
| 464 | 0× | pem, |
| 465 | 0× | ) |
| 466 | 0× | .into_response(), |
| 467 | 0× | Err(_) => (axum::http::StatusCode::NOT_FOUND, "no certificate").into_response(), |
| 468 | | } |
| 469 | 1× | } |
| 470 | |
|
| 471 | |
|
| 472 | |
|
| 473 | 1× | pub fn versions() -> serde_json::Value { |
| 474 | 1× | serde_json::json!({ |
| 475 | 1× | "oxygenhub": { "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID") }, |
| 476 | 1× | "oxy_core": oxy_core::VERSION, |
| 477 | 1× | "flow_core": flow_core::VERSION, |
| 478 | | }) |
| 479 | 1× | } |
| 480 | |
|
| 481 | | #[derive(askama::Template)] |
| 482 | | #[template(path = "about.html")] |
| 483 | | pub struct AboutPage { |
| 484 | | shell: Shell, |
| 485 | | oxy_core: &'static str, |
| 486 | | flow_core: &'static str, |
| 487 | | |
| 488 | | runners: Vec<crate::db::runners::Runner>, |
| 489 | | } |
| 490 | |
|
| 491 | |
|
| 492 | |
|
| 493 | 1× | pub async fn about(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AboutPage>, Error> { |
| 494 | 1× | let shell = Shell::new(&app, "/about", &viewer); |
| 495 | 1× | let runners = if viewer.is_admin() { crate::db::runners::list(&app.db).await.unwrap_or_default() } else { vec![] }; |
| 496 | 1× | Ok(Page(AboutPage { shell, oxy_core: oxy_core::VERSION, flow_core: flow_core::VERSION, runners })) |
| 497 | 1× | } |