mod account_commands;
mod accounts;
mod api;
mod assets;
mod auth;
mod bug_origins;
pub(crate) mod conversation;
mod cronjobs;
mod experiments;
mod filters;
mod github;
pub(crate) use account_commands::Scopes;
pub use account_commands::{password, print_log, sign_out};
mod install;
pub use install::{install, install_to};
mod identity;
pub use identity::whoami;
mod live;
mod logs;
mod look;
pub use look::{export_theme, import_theme, sections, theme};
mod openapi;
pub use openapi::print as openapi;
mod changes;
mod dismiss;
mod peek;
mod resources;
mod restart;
mod scoring;
mod stop;
mod subscription;
mod time;
pub(crate) mod tokens;
mod watching;
mod waterfall;
use std::io::Write;
use std::net::Ipv4Addr;
use anyhow::{Context, Result, ensure};
use askama::Template;
use axum::Router;
use axum::extract::{DefaultBodyLimit, Path, RawQuery};
use axum::http::{Method, StatusCode, header};
use axum::middleware;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use include_dir::{Dir, include_dir};
use crate::config::UserConfig;
use crate::logs::status::{self, Inbox, Row};
static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets");
fn hostname() -> String {
crate::host::name().unwrap_or_else(|| "unknown".into())
}
struct PageContext {
scopes: Vec<Scope>,
hostname: String,
consoles: Vec<String>,
theme: String,
resources: resources::Status,
}
impl PageContext {
fn new(config: &UserConfig, scopes: &[Scope]) -> Self {
Self {
scopes: scopes.to_vec(),
hostname: hostname(),
consoles: config.console.other_consoles.clone(),
theme: config.console.theme.clone(),
resources: resources::Status::new(config),
}
}
fn can_act(&self) -> bool {
self.scopes.contains(&Scope::Act)
}
fn can_read(&self) -> bool {
self.scopes.contains(&Scope::Read)
}
fn can_security(&self) -> bool {
self.scopes.contains(&Scope::Security)
}
}
#[derive(Clone)]
struct PageRoute {
path: &'static str,
api: &'static str,
scope: Option<Scope>,
cli: &'static [&'static str],
method: Method,
view: View,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Scope {
Read,
Security,
Act,
Run,
Admin,
}
impl Scope {
pub(crate) const ALL: [Self; 5] = [
Self::Read,
Self::Security,
Self::Act,
Self::Run,
Self::Admin,
];
pub(crate) fn name(self) -> &'static str {
match self {
Self::Read => "read",
Self::Security => "security",
Self::Act => "act",
Self::Run => "run",
Self::Admin => "admin",
}
}
pub(crate) fn parse(value: &str) -> Result<Vec<Self>> {
let mut scopes = Vec::new();
for name in value.split(',').filter(|_| !value.is_empty()) {
let scope = Self::ALL
.into_iter()
.find(|scope| scope.name() == name)
.with_context(|| {
format!("unknown scope {name:?}; expected read,security,act,run,admin")
})?;
if !scopes.contains(&scope) {
scopes.push(scope);
}
}
Ok(scopes)
}
}
#[derive(Clone, Copy)]
enum View {
Whoami,
OpenApi,
BugOrigins,
SettleBugOrigin,
Scores,
HumanScores,
Experiments,
Experiment,
ExperimentSession,
ApproveEvalTask,
CronJobs,
Resources,
ResourceStatus,
Metrics,
Inbox,
Inspect,
Spans,
Log,
Changes,
Logs,
CommandLogs,
Reader,
LogStream,
ConsoleLog,
Account,
SignOut,
SignInPage,
SignIn,
Look,
Themes,
Theme,
ThemeImport,
ThemeExport,
Sections,
StopPage,
Stop,
Dismiss,
UndoDismissal,
SetPasswordPage,
SendCode,
SetPassword,
}
impl PageRoute {
fn serves_api(&self) -> bool {
matches!(
self.view,
View::Whoami
| View::BugOrigins
| View::OpenApi
| View::Scores
| View::Experiments
| View::Experiment
| View::ExperimentSession
| View::CronJobs
| View::Resources
| View::ResourceStatus
| View::Metrics
| View::Inbox
| View::Inspect
| View::Spans
| View::Log
| View::Logs
| View::CommandLogs
| View::Reader
| View::ConsoleLog
| View::Look
| View::Themes
| View::ThemeImport
| View::ThemeExport
| View::Stop
| View::Dismiss
| View::UndoDismissal
| View::HumanScores
| View::SettleBugOrigin
| View::ApproveEvalTask
| View::Theme
| View::Sections
)
}
}
const PAGES: &[PageRoute] = &[
PageRoute {
path: "/whoami",
api: "/api/v1/whoami",
scope: Some(Scope::Read),
cli: &["thirdshift console whoami --json"],
method: Method::GET,
view: View::Whoami,
},
PageRoute {
path: "/api/v1/openapi.json",
api: "/api/v1/openapi.json",
scope: Some(Scope::Read),
cli: &["thirdshift console openapi"],
method: Method::GET,
view: View::OpenApi,
},
PageRoute {
path: "/metrics",
api: "/api/v1/metrics",
scope: Some(Scope::Read),
cli: &["thirdshift metrics --json"],
method: Method::GET,
view: View::Metrics,
},
PageRoute {
path: "/bug-origins",
api: "/api/v1/bug-origins",
scope: Some(Scope::Read),
cli: &["thirdshift bug-origins --json"],
method: Method::GET,
view: View::BugOrigins,
},
PageRoute {
path: "/bug-origins/settle",
api: "/api/v1/bug-origins/settle",
scope: Some(Scope::Act),
cli: &[
"thirdshift bug-origins 8 --confirm --json",
"thirdshift bug-origins 8 --correct 24 --json",
"thirdshift bug-origins 8 --none --json",
],
method: Method::POST,
view: View::SettleBugOrigin,
},
PageRoute {
path: "/scores",
api: "/api/v1/scores",
scope: Some(Scope::Read),
cli: &["thirdshift scores --json", "thirdshift scores 23 --json"],
method: Method::GET,
view: View::Scores,
},
PageRoute {
path: "/scores/mine",
api: "/api/v1/scores/mine",
scope: Some(Scope::Act),
cli: &["thirdshift scores 23 --mine --json"],
method: Method::POST,
view: View::HumanScores,
},
PageRoute {
path: "/experiments/proposals/{suite}/{task}/approve",
api: "/api/v1/experiments/proposals/{suite}/{task}/approve",
scope: Some(Scope::Act),
cli: &["thirdshift evals approve suite task --json"],
method: Method::POST,
view: View::ApproveEvalTask,
},
PageRoute {
path: "/experiments/{id}/sessions/{session}",
api: "/api/v1/experiments/{id}/sessions/{session}",
scope: Some(Scope::Read),
cli: &["thirdshift experiments id --session session --json"],
method: Method::GET,
view: View::ExperimentSession,
},
PageRoute {
path: "/experiments",
api: "/api/v1/experiments",
scope: Some(Scope::Read),
cli: &["thirdshift experiments --json"],
method: Method::GET,
view: View::Experiments,
},
PageRoute {
path: "/experiments/{id}",
api: "/api/v1/experiments/{id}",
scope: Some(Scope::Read),
cli: &["thirdshift experiments id --json"],
method: Method::GET,
view: View::Experiment,
},
PageRoute {
path: "/dismiss",
api: "/api/v1/dismiss",
scope: Some(Scope::Act),
cli: &[
"thirdshift dismiss widgets#7",
"thirdshift dismiss widgets#7 --json",
],
method: Method::POST,
view: View::Dismiss,
},
PageRoute {
path: "/dismiss/undo",
api: "/api/v1/dismiss/undo",
scope: Some(Scope::Act),
cli: &[
"thirdshift dismiss widgets#7 --undo",
"thirdshift dismiss widgets#7 --undo --json",
],
method: Method::POST,
view: View::UndoDismissal,
},
PageRoute {
path: "/resources",
api: "/api/v1/resources",
scope: Some(Scope::Read),
cli: &["thirdshift resources --json"],
method: Method::GET,
view: View::Resources,
},
PageRoute {
path: "/resources/status",
api: "/api/v1/resources/status",
scope: Some(Scope::Read),
cli: &["thirdshift resources --json"],
method: Method::GET,
view: View::ResourceStatus,
},
PageRoute {
path: "/cronjobs",
api: "/api/v1/cronjobs",
scope: Some(Scope::Read),
cli: &["thirdshift cronjobs --json"],
method: Method::GET,
view: View::CronJobs,
},
PageRoute {
path: "/themes",
api: "/api/v1/themes",
scope: Some(Scope::Read),
cli: &["thirdshift console theme list --json"],
method: Method::GET,
view: View::Themes,
},
PageRoute {
path: "/themes",
api: "/api/v1/themes",
scope: Some(Scope::Act),
cli: &["thirdshift console theme import theme.json"],
method: Method::POST,
view: View::ThemeImport,
},
PageRoute {
path: "/themes/{name}",
api: "/api/v1/themes/{name}",
scope: Some(Scope::Read),
cli: &["thirdshift console theme export focus-auto"],
method: Method::GET,
view: View::ThemeExport,
},
PageRoute {
path: "/look",
api: "/api/v1/look",
scope: Some(Scope::Read),
cli: &["thirdshift console sections --json"],
method: Method::GET,
view: View::Look,
},
PageRoute {
path: "/theme",
api: "/api/v1/theme",
scope: Some(Scope::Act),
cli: &["thirdshift console theme focus-auto --json"],
method: Method::POST,
view: View::Theme,
},
PageRoute {
path: "/sections",
api: "/api/v1/sections",
scope: Some(Scope::Act),
cli: &["thirdshift console sections --hide done --json"],
method: Method::POST,
view: View::Sections,
},
PageRoute {
path: "/stop",
api: "/api/v1/stop",
scope: Some(Scope::Act),
cli: &[
"thirdshift stop widgets#7",
"thirdshift stop widgets#7 --requeue",
"thirdshift stop widgets#7 --json",
"thirdshift stop widgets#7 --requeue --json",
],
method: Method::GET,
view: View::StopPage,
},
PageRoute {
path: "/stop",
api: "/api/v1/stop",
scope: Some(Scope::Act),
cli: &[
"thirdshift stop widgets#7",
"thirdshift stop widgets#7 --requeue",
"thirdshift stop widgets#7 --json",
"thirdshift stop widgets#7 --requeue --json",
],
method: Method::POST,
view: View::Stop,
},
PageRoute {
path: "/set-password",
api: "/api/v1/set-password",
scope: Some(Scope::Act),
cli: &["thirdshift console password"],
method: Method::GET,
view: View::SetPasswordPage,
},
PageRoute {
path: "/set-password/code",
api: "/api/v1/set-password/code",
scope: Some(Scope::Act),
cli: &["thirdshift console password"],
method: Method::POST,
view: View::SendCode,
},
PageRoute {
path: "/set-password",
api: "/api/v1/set-password",
scope: Some(Scope::Act),
cli: &["thirdshift console password"],
method: Method::POST,
view: View::SetPassword,
},
PageRoute {
path: "/waterfall",
api: "/api/v1/waterfall",
scope: Some(Scope::Read),
cli: &[
"thirdshift show widgets#7 --spans --json",
"thirdshift show widgets#7 --json",
"thirdshift logs widgets#7",
],
method: Method::GET,
view: View::Inbox,
},
PageRoute {
path: "/spans",
api: "/api/v1/spans",
scope: Some(Scope::Read),
cli: &[
"thirdshift show widgets#7 --spans --json",
"thirdshift show widgets#7 --span id --json",
],
method: Method::GET,
view: View::Spans,
},
PageRoute {
path: "/lanes",
api: "/api/v1/lanes",
scope: Some(Scope::Read),
cli: &["thirdshift status --all --json"],
method: Method::GET,
view: View::Inbox,
},
PageRoute {
path: "/stages",
api: "/api/v1/stages",
scope: Some(Scope::Read),
cli: &["thirdshift status --all --json"],
method: Method::GET,
view: View::Inbox,
},
PageRoute {
path: "/",
api: "/api/v1/status",
scope: Some(Scope::Read),
cli: &["thirdshift status", "thirdshift status --json"],
method: Method::GET,
view: View::Inbox,
},
PageRoute {
path: "/events",
api: "/api/v1/events",
scope: Some(Scope::Read),
cli: &["thirdshift status"],
method: Method::GET,
view: View::Changes,
},
PageRoute {
path: "/history",
api: "/api/v1/history",
scope: Some(Scope::Read),
cli: &["thirdshift status --all --json"],
method: Method::GET,
view: View::Inbox,
},
PageRoute {
path: "/security",
api: "/api/v1/security",
scope: Some(Scope::Security),
cli: &["thirdshift status --all --json"],
method: Method::GET,
view: View::Inbox,
},
PageRoute {
path: "/peek",
api: "/api/v1/peek",
scope: Some(Scope::Read),
cli: &["thirdshift show widgets#7 --json"],
method: Method::GET,
view: View::Inspect,
},
PageRoute {
path: "/log",
api: "/api/v1/log",
scope: Some(Scope::Read),
cli: &[
"thirdshift logs widgets#7 --json",
"thirdshift logs widgets#7 --session implement --json",
],
method: Method::GET,
view: View::Log,
},
PageRoute {
path: "/logs",
api: "/api/v1/logs",
scope: Some(Scope::Read),
cli: &["thirdshift logs --json"],
method: Method::GET,
view: View::Logs,
},
PageRoute {
path: "/logs/command/{id}",
api: "/api/v1/logs/command/{id}",
scope: Some(Scope::Read),
cli: &["thirdshift logs --command id --json"],
method: Method::GET,
view: View::CommandLogs,
},
PageRoute {
path: "/logs/{id}",
api: "/api/v1/logs/{id}",
scope: Some(Scope::Read),
cli: &["thirdshift logs --file id --json"],
method: Method::GET,
view: View::Reader,
},
PageRoute {
path: "/logs/{id}/stream/{offset}",
api: "/api/v1/logs/{id}/stream/{offset}",
scope: Some(Scope::Read),
cli: &["thirdshift logs --file id --follow"],
method: Method::GET,
view: View::LogStream,
},
PageRoute {
path: "/console-log",
api: "/api/v1/console-log",
scope: Some(Scope::Read),
cli: &["thirdshift console log --json"],
method: Method::GET,
view: View::ConsoleLog,
},
PageRoute {
path: "/account",
api: "/api/v1/account",
scope: None,
cli: &["thirdshift console sign-out"],
method: Method::GET,
view: View::Account,
},
PageRoute {
path: "/sign-out",
api: "/api/v1/sign-out",
scope: None,
cli: &["thirdshift console sign-out"],
method: Method::POST,
view: View::SignOut,
},
PageRoute {
path: "/sign-in",
api: "/api/v1/sign-in",
scope: Some(Scope::Act),
cli: &["thirdshift console password"],
method: Method::GET,
view: View::SignInPage,
},
PageRoute {
path: "/sign-in",
api: "/api/v1/sign-in",
scope: Some(Scope::Act),
cli: &["thirdshift console password"],
method: Method::POST,
view: View::SignIn,
},
];
pub fn serve(no_sign_in: bool) -> Result<()> {
let config = UserConfig::load()?;
ensure!(
no_sign_in || !config.console.allowed_emails.is_empty(),
"console.allowed_emails is empty; configure allowed addresses or use --no-sign-in on localhost"
);
if !no_sign_in {
accounts::Accounts::load()?;
}
let executable = restart::Executable::current()?;
let assets = assets::Development::from_environment()?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, config.console.port))
.await
.context("can't listen for the Console on localhost")?;
let watched = executable.clone();
let (changes, notifier, observer) = changes::observe(&config, &executable, &assets)?;
let github = github::follow();
let mut control = changes.clone();
println!("http://{}", listener.local_addr()?);
std::io::stdout().flush()?;
axum::serve(
listener,
router(no_sign_in, changes)
.layer(axum::Extension(notifier))
.layer(axum::Extension(assets))
.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
let mut accounts_revision = 0;
loop {
if control.changed().await.is_err() {
break;
}
if watched.changed() {
break;
}
let revision = control.borrow_and_update().accounts;
if !no_sign_in && revision != accounts_revision {
accounts_revision = revision;
match tokio::task::spawn_blocking(|| accounts::Accounts::load().map(drop)).await
{
Ok(Ok(())) => {}
error => {
eprintln!("thirdshift Console: can't reconcile allowlist: {error:?}")
}
}
}
}
crate::interrupt::request();
observer.abort();
let _ = observer.await;
github.abort();
let _ = github.await;
})
.await
.context("Console server failed")?;
Ok::<_, anyhow::Error>(())
})?;
drop(runtime);
executable.exec()
}
fn router(no_sign_in: bool, changes: changes::Receiver) -> Router {
let mut router = Router::new();
for page in PAGES {
let cli = page.cli[0];
let path = page.path;
let handler = match (&page.view, page.method.clone()) {
(View::Whoami, Method::GET) => get(identity::show),
(View::OpenApi, Method::GET) => get(openapi::serve),
(View::Experiments, Method::GET) => get(experiments::index),
(View::Experiment, Method::GET) => get(experiments::detail),
(View::ExperimentSession, Method::GET) => get(experiments::session),
(View::ApproveEvalTask, Method::POST) => post(experiments::approve),
(View::CronJobs, Method::GET) => get(cronjobs::show),
(View::Scores, Method::GET) => get(scoring::show),
(View::HumanScores, Method::POST) => post(scoring::submit),
(View::BugOrigins, Method::GET) => get(bug_origins::show),
(View::SettleBugOrigin, Method::POST) => post(bug_origins::submit),
(View::Resources, Method::GET) => get(resources::show),
(View::ResourceStatus, Method::GET) => get(resources::status),
(View::Metrics, Method::GET) => get(metrics),
(View::Inbox, Method::GET) => {
get(move |viewer, format, query| inbox(cli, path, viewer, format, query))
}
(View::Inspect, Method::GET) => get(inspect),
(View::Spans, Method::GET) => get(waterfall::data),
(View::Log, Method::GET) => get(log),
(View::Changes, Method::GET) => get(live::events),
(View::Logs, Method::GET) => get(logs::index),
(View::CommandLogs, Method::GET) => get(logs::command),
(View::Reader, Method::GET) => get(logs::reader),
(View::LogStream, Method::GET) => get(logs::stream),
(View::ConsoleLog, Method::GET) => get(auth::log),
(View::Account, Method::GET) => get(auth::account),
(View::SignOut, Method::POST) => post(auth::sign_out),
(View::SignInPage, Method::GET) => get(auth::sign_in_page),
(View::SignIn, Method::POST) => post(auth::sign_in),
(View::Look, Method::GET) => get(look::get),
(View::Themes, Method::GET) => get(look::list_themes),
(View::Theme, Method::POST) => post(look::set_theme),
(View::ThemeImport, Method::POST) => post(look::import_document),
(View::ThemeExport, Method::GET) => get(look::export_document),
(View::Sections, Method::POST) => post(look::set_sections),
(View::StopPage, Method::GET) => get(stop::show),
(View::Stop, Method::POST) => post(stop::submit),
(View::Dismiss, Method::POST) => post(dismiss::submit),
(View::UndoDismissal, Method::POST) => post(dismiss::undo),
(View::SetPasswordPage, Method::GET) => get(auth::set_password_page),
(View::SendCode, Method::POST) => post(auth::code),
(View::SetPassword, Method::POST) => post(auth::set_password),
_ => unreachable!("unknown Console page method"),
}
.route_layer(middleware::from_fn(auth::csrf_guard));
if page.serves_api() {
assert!(page.scope.is_some(), "API route needs a scope");
router = router.route(
page.api,
handler
.clone()
.layer(axum::Extension(api::Format::Json))
.layer(middleware::from_fn_with_state(
page.scope.unwrap(),
auth::require_scope,
)),
);
if page.api == page.path {
continue;
}
}
let handler = if let Some(scope) = page.scope {
handler.layer(middleware::from_fn_with_state(scope, auth::require_scope))
} else {
handler
};
router = router.route(page.path, handler);
}
router = router.route("/assets/{*path}", get(asset));
router = router
.fallback(|request: axum::extract::Request| async move {
if api::is_path(request.uri().path()) {
api::error(StatusCode::NOT_FOUND, "Unknown Console API endpoint.")
} else {
StatusCode::NOT_FOUND.into_response()
}
})
.method_not_allowed_fallback(|request: axum::extract::Request| async move {
if api::is_path(request.uri().path()) {
api::error(
StatusCode::METHOD_NOT_ALLOWED,
"Method not allowed for this API endpoint.",
)
} else {
StatusCode::METHOD_NOT_ALLOWED.into_response()
}
});
let access = auth::Access::new(no_sign_in);
router
.layer(axum::Extension(api::Format::Html))
.layer(axum::Extension(changes.clone()))
.with_state(changes)
.layer(DefaultBodyLimit::max(16 * 1024))
.layer(middleware::from_fn_with_state(access.clone(), auth::guard))
.layer(axum::Extension(access))
.layer(middleware::from_fn(auth::headers))
}
#[derive(Template)]
#[template(path = "console/inbox.html")]
struct InboxPage {
page: PageContext,
csrf: String,
hidden_sections: Vec<String>,
cli: &'static str,
inbox: Inbox,
title: &'static str,
archive: Option<status::Archive>,
outcome: String,
repository: String,
section: String,
}
impl InboxPage {
fn hidden(&self, group: &str) -> bool {
self.hidden_sections
.iter()
.any(|section| section == &slug(group))
}
fn folder(&self) -> &'static str {
self.inbox
.sections
.iter()
.find(|section| !self.hidden(section.group) && slug(section.group) == self.section)
.map_or("overview", |section| section.group)
}
fn in_overview(&self, group: &str) -> bool {
matches!(
group,
"needs you" | "ready for review" | "landing" | "working"
)
}
fn shown(&self, group: &str) -> bool {
!self.hidden(group)
&& (self.folder() == group || (self.folder() == "overview" && self.in_overview(group)))
}
fn slug(&self, group: &str) -> String {
slug(group)
}
fn needs_you(&self) -> usize {
self.inbox.sections[0].rows.len()
}
fn idle(&self) -> bool {
self.inbox.active_runs == 0
}
fn active_runs(&self) -> String {
status::active_runs_text(self.inbox.active_runs)
}
fn ready_for_review(&self) -> usize {
self.inbox.sections[1].rows.len()
}
fn urgent(&self) -> bool {
self.inbox.sections[0]
.rows
.iter()
.any(|row| row.state != "Slow")
}
fn row<'a>(&'a self, row: &'a Row, can_copy: bool) -> RowTemplate<'a> {
RowTemplate {
row,
csrf: &self.csrf,
can_copy,
can_act: self.page.can_act(),
can_security: self.page.can_security(),
}
}
}
#[derive(Template)]
#[template(path = "console/row.html")]
struct RowTemplate<'a> {
row: &'a Row,
csrf: &'a str,
can_copy: bool,
can_act: bool,
can_security: bool,
}
impl RowTemplate<'_> {
fn gist(&self) -> String {
if !self.can_security && !self.row.findings.is_empty() {
self.row.now_line.clone()
} else {
self.row.gist()
}
}
fn child<'a>(&'a self, row: &'a Row) -> RowTemplate<'a> {
RowTemplate {
row,
csrf: self.csrf,
can_copy: false,
can_act: self.can_act,
can_security: self.can_security,
}
}
}
fn slug(group: &str) -> String {
group.replace(' ', "-")
}
fn query_value(query: &Option<String>, key: &str) -> String {
let url = reqwest::Url::parse(&format!(
"http://localhost/?{}",
query.as_deref().unwrap_or_default()
))
.expect("fixed URL");
url.query_pairs()
.find(|(name, _)| name == key)
.map(|(_, value)| value.into_owned())
.unwrap_or_default()
}
async fn inbox(
cli: &'static str,
path: &'static str,
axum::Extension(viewer): axum::Extension<auth::Viewer>,
axum::Extension(format): axum::Extension<api::Format>,
RawQuery(query): RawQuery,
) -> Response {
let mut requested_page = if format == api::Format::Html {
query_value(&query, "page")
} else {
String::new()
};
if requested_page.is_empty() {
requested_page = path.trim_start_matches('/').into();
}
if matches!(requested_page.as_str(), "logs" | "6") {
return logs::index(axum::Extension(viewer), axum::Extension(format)).await;
}
if matches!(requested_page.as_str(), "waterfall" | "4") {
return waterfall::show(viewer, format, query).await;
}
if matches!(requested_page.as_str(), "lanes" | "2" | "stages" | "3") {
return time::show(
viewer,
format,
matches!(requested_page.as_str(), "stages" | "3"),
query,
)
.await;
}
let archive_page = match requested_page.as_str() {
"history" | "5" => Some(status::ArchivePage::History),
"security" | "7" => Some(status::ArchivePage::Security),
"" | "inbox" | "1" => None,
_ => return StatusCode::NOT_FOUND.into_response(),
};
if matches!(archive_page, Some(status::ArchivePage::Security))
&& !viewer.scopes.contains(&Scope::Security)
{
return api::error(StatusCode::FORBIDDEN, "Missing scope: security");
}
render_page(format, cli, archive_page, query, viewer).await
}
async fn render_page(
format: api::Format,
cli: &'static str,
archive_page: Option<status::ArchivePage>,
query: Option<String>,
viewer: auth::Viewer,
) -> Response {
api::read(format, move || {
let config = UserConfig::load()?;
let read = status::Read::capture(&config, false)?;
let all = archive_page.is_some() || query_value(&query, "all") == "true";
let outcome = query_value(&query, "outcome");
let repository = query_value(&query, "repository");
let (title, cli) = match archive_page {
Some(status::ArchivePage::History) => ("History", "thirdshift status --all --json"),
Some(status::ArchivePage::Security) => (
"Security",
"thirdshift status --all --json · thirdshift show <command> --json",
),
None => ("Inbox", cli),
};
format.render(read.json(&config, all)?, |_| {
Ok(InboxPage {
page: PageContext::new(&config, &viewer.scopes),
csrf: viewer.csrf,
hidden_sections: config.console.hidden_sections.clone(),
cli,
inbox: read.inbox(&config)?,
title,
archive: archive_page
.map(|page| read.archive(&config, page, &outcome, &repository))
.transpose()?,
outcome,
repository,
section: query_value(&query, "section"),
}
.render()?)
})
})
.await
}
async fn metrics(
axum::Extension(format): axum::Extension<api::Format>,
RawQuery(query): RawQuery,
) -> Response {
api::read(format, move || {
let mut arguments = vec!["metrics".to_owned(), "--json".to_owned()];
if let Some(query) = &query {
for (key, value) in
reqwest::Url::parse(&format!("http://localhost/?{query}"))?.query_pairs()
{
match key.as_ref() {
"command" => arguments.push(value.into_owned()),
"from" | "through" | "repo" => {
arguments.push(format!("--{key}"));
arguments.push(value.into_owned());
}
_ => anyhow::bail!("unknown metrics argument: {key}"),
}
}
}
let crate::args::Command::Metrics(asked) = crate::args::parse(&arguments)? else {
unreachable!("fixed metrics command")
};
format.render(
crate::logs::metrics::read(&UserConfig::load()?, &asked)?,
|value| {
Ok(format!(
"<pre>{}</pre>",
filters::escaped(&serde_json::to_string_pretty(&value)?)
))
},
)
})
.await
}
async fn inspect(
axum::Extension(viewer): axum::Extension<auth::Viewer>,
axum::Extension(format): axum::Extension<api::Format>,
RawQuery(query): RawQuery,
) -> Response {
let command = query_value(&query, "command");
let response = api::read(format, move || {
let config = UserConfig::load()?;
let mut details = crate::logs::stop::inspect(&config, &command)?;
if details.security_details && !viewer.scopes.contains(&Scope::Security) {
return Ok(api::error(StatusCode::FORBIDDEN, "Missing scope: security"));
}
let document = status::inspect::envelope(details.details.clone());
if !viewer.scopes.contains(&Scope::Act) {
details.can_stop = false;
details.dismissal = None;
}
format.render(document, |_| {
Ok(peek::Peek::new(command, details, viewer.csrf, &config).render()?)
})
})
.await;
if format == api::Format::Html
&& !response.status().is_success()
&& response.status() != StatusCode::FORBIDDEN
{
(StatusCode::NOT_FOUND, "Command details are unavailable.").into_response()
} else {
response
}
}
async fn log(
axum::Extension(viewer): axum::Extension<auth::Viewer>,
axum::Extension(format): axum::Extension<api::Format>,
RawQuery(query): RawQuery,
) -> Response {
let command = query_value(&query, "command");
let session = query_value(&query, "session");
if format == api::Format::Json {
if !viewer.scopes.contains(&Scope::Security) {
let command_for_check = command.clone();
let session_for_check = session.clone();
match tokio::task::spawn_blocking(move || -> Result<bool> {
let config = UserConfig::load()?;
let path = status::inspect::log_path(
&config,
&command_for_check,
(!session_for_check.is_empty()).then_some(session_for_check.as_str()),
)?;
Ok(crate::logs::owner::security_session(&path)
|| api::security_command(&config, &command_for_check)?)
})
.await
{
Ok(Ok(true)) => {
return api::error(StatusCode::FORBIDDEN, "Missing scope: security");
}
Ok(Ok(false)) => {}
Ok(Err(error)) => return api::error(StatusCode::UNPROCESSABLE_ENTITY, error),
Err(_) => {
return api::error(
StatusCode::INTERNAL_SERVER_ERROR,
"Can't read this view; check the Console's stderr.",
);
}
}
}
return api::read(format, move || {
let config = UserConfig::load()?;
Ok(axum::Json(status::inspect::read_log(
&config,
&command,
(!session.is_empty()).then_some(session.as_str()),
)?)
.into_response())
})
.await;
}
match tokio::task::spawn_blocking(move || {
let config = UserConfig::load()?;
let path = status::inspect::log_path(
&config,
&command,
(!session.is_empty()).then_some(session.as_str()),
)?;
crate::logs::catalog::load(&config, None)?
.into_iter()
.find(|log| log.path == path)
.map(|log| log.id)
.context("log is unavailable")
})
.await
{
Ok(Ok(id)) => {
logs::reader(Path(id), axum::Extension(viewer), axum::Extension(format)).await
}
_ => (StatusCode::NOT_FOUND, "Log is unavailable.").into_response(),
}
}
fn page(result: std::result::Result<Result<String>, tokio::task::JoinError>) -> Response {
match result {
Ok(Ok(html)) => Html(html).into_response(),
error => {
eprintln!("thirdshift Console: can't read page: {error:?}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Can't read this page; check the Console's stderr.",
)
.into_response()
}
}
}
async fn asset(
axum::Extension(assets): axum::Extension<assets::Development>,
Path(path): Path<String>,
) -> Response {
if path == "user-themes.css" || path.starts_with("user-fonts/") {
return match tokio::task::spawn_blocking(move || user_asset(&path)).await {
Ok(response) => response,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
}
let body = if assets.directory().is_some() {
let requested = path.clone();
match tokio::task::spawn_blocking(move || assets.read(&requested)).await {
Ok(Ok(bytes)) => axum::body::Body::from(bytes),
Ok(Err(_)) => return StatusCode::NOT_FOUND.into_response(),
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
} else {
let Some(file) = ASSETS.get_file(&path) else {
return StatusCode::NOT_FOUND.into_response();
};
axum::body::Body::from(file.contents())
};
let content_type = match std::path::Path::new(&path)
.extension()
.and_then(|ext| ext.to_str())
{
Some("css") => "text/css; charset=utf-8",
Some("js") => "text/javascript; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("woff2") => "font/woff2",
_ => "text/plain; charset=utf-8",
};
(
[
(header::CONTENT_TYPE, content_type),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
],
body,
)
.into_response()
}
fn user_asset(path: &str) -> Response {
use crate::config::console::themes::{self, Theme};
if let Some(name) = path.strip_prefix("user-fonts/") {
return match (themes::font_type(name), themes::font(name)) {
(Some(kind), Ok(bytes)) => ([(header::CONTENT_TYPE, kind)], bytes).into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
};
}
match themes::names() {
Ok(names) => {
let css: String = names
.iter()
.filter(|name| !crate::config::console::theme_names().contains(name))
.filter_map(|name| Theme::export(name).ok())
.map(|theme| theme.stylesheet())
.collect();
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
],
css,
)
.into_response()
}
Err(error) => {
eprintln!("thirdshift Console: Theme stylesheet failed: {error:#}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[cfg(test)]
mod tests {
use axum::{
Router,
routing::{Route, get, post},
};
use std::sync::{Arc, Mutex};
use tower::ServiceExt;
use tower_layer::Layer;
fn router() -> Router {
let (_, changes) = tokio::sync::watch::channel(super::changes::Revision::default());
super::router(false, changes)
}
#[derive(Clone)]
struct Routes(Arc<Mutex<Vec<Route>>>);
impl Layer<Route> for Routes {
type Service = Route;
fn layer(&self, route: Route) -> Route {
self.0.lock().unwrap().push(route.clone());
route
}
}
fn registered_methods(router: Router) -> usize {
let routes = Routes(Arc::default());
let _router = router.route_layer(routes.clone());
routes.0.lock().unwrap().len()
}
fn assert_cli_parity(router: Router) {
assert_parity(router, super::openapi::document());
}
fn assert_routed_operations(
router: Router,
operations: &std::collections::BTreeSet<(&str, String)>,
) {
let router = router
.fallback(|| async { axum::http::StatusCode::NOT_FOUND })
.method_not_allowed_fallback(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED })
.route_layer(axum::middleware::from_fn(
|_: axum::extract::Request, _: axum::middleware::Next| async {
axum::http::StatusCode::NO_CONTENT
},
));
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
for (path, method) in operations {
let uri = path
.split('/')
.map(|segment| {
if segment.starts_with('{') {
"parity-fixture"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/");
let request = axum::http::Request::builder()
.uri(uri)
.method(method.as_str())
.body(axum::body::Body::empty())
.unwrap();
let response = runtime.block_on(router.clone().oneshot(request)).unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::NO_CONTENT,
"registered routes must all have a CLI pair: {method} {path} is not routed"
);
assert!(
!response.headers().contains_key(axum::http::header::ALLOW),
"registered routes must all have a CLI pair: {method} {path} used a method fallback"
);
}
}
fn assert_parity(router: Router, description: serde_json::Value) {
let described = description["paths"]
.as_object()
.unwrap()
.iter()
.flat_map(|(path, methods)| {
methods
.as_object()
.unwrap()
.keys()
.map(move |method| (path.as_str(), method.to_ascii_uppercase()))
})
.collect::<std::collections::BTreeSet<_>>();
let routed = super::PAGES
.iter()
.filter(|route| route.serves_api())
.map(|route| (route.api, route.method.as_str().to_owned()))
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
described, routed,
"OpenAPI operations must match API routes"
);
let (_, changes) = tokio::sync::watch::channel(0_u64);
let get_methods = registered_methods(
Router::<tokio::sync::watch::Receiver<u64>>::new()
.route("/known", get(|| async {}))
.with_state(changes.clone()),
);
let post_methods = registered_methods(
Router::<tokio::sync::watch::Receiver<u64>>::new()
.route("/known", post(|| async {}))
.with_state(changes),
);
let mut registrations = Vec::new();
for page in super::PAGES {
let is_get = page.method == axum::http::Method::GET;
registrations.push((page.path, is_get));
if page.serves_api() && page.api != page.path {
registrations.push((page.api, is_get));
}
}
let unique_paths = registrations
.iter()
.map(|(path, _)| path)
.collect::<std::collections::BTreeSet<_>>()
.len();
let shared_fallbacks = registrations.len() - unique_paths;
let expected = registrations
.iter()
.map(
|(_, is_get)| {
if *is_get { get_methods } else { post_methods }
},
)
.sum::<usize>()
+ get_methods
- shared_fallbacks;
assert_eq!(
registered_methods(router.clone()),
expected,
"registered routes must all have a CLI pair; only static resources are exempt"
);
assert_routed_operations(router, &described);
for route in super::PAGES {
if route.serves_api() {
assert_api_metadata(route);
}
assert!(
!route.cli.is_empty(),
"{} has no paired CLI command",
route.path
);
for cli in route.cli {
assert!(cli.starts_with("thirdshift "), "{cli}");
let arguments = cli
.split_whitespace()
.skip(1)
.map(str::to_owned)
.collect::<Vec<_>>();
assert!(
crate::args::parse(&arguments).is_ok(),
"{} is not a CLI command",
cli
);
}
}
}
fn assert_api_metadata(route: &super::PageRoute) {
assert!(
route.api.starts_with("/api/v1/"),
"{} has no versioned API path",
route.path
);
assert!(route.scope.is_some(), "{} has no API scope", route.path);
}
#[test]
#[should_panic(expected = "has no versioned API path")]
fn parity_rejects_a_route_without_an_api_path() {
assert_api_metadata(&super::PageRoute {
api: "",
..super::PAGES[0].clone()
});
}
#[test]
#[should_panic(expected = "has no API scope")]
fn parity_rejects_a_route_without_a_scope() {
assert_api_metadata(&super::PageRoute {
scope: None,
..super::PAGES[0].clone()
});
}
#[test]
fn every_console_route_has_a_cli_equivalent() {
assert_cli_parity(router());
}
#[test]
fn token_management_has_no_http_routes() {
for route in super::PAGES {
assert!(
!route
.api
.split('/')
.any(|part| matches!(part, "token" | "tokens"))
);
assert!(
route
.cli
.iter()
.all(|cli| !cli.starts_with("thirdshift console token "))
);
}
}
#[test]
#[should_panic(expected = "OpenAPI operations must match API routes")]
fn parity_rejects_an_api_route_missing_from_openapi() {
let mut description = super::openapi::document();
description["paths"]
.as_object_mut()
.unwrap()
.remove("/api/v1/metrics");
assert_parity(router(), description);
}
#[test]
#[should_panic(expected = "OpenAPI operations must match API routes")]
fn parity_rejects_an_openapi_operation_without_a_route() {
let mut description = super::openapi::document();
description["paths"]["/api/v1/status"]["post"] = serde_json::json!({});
assert_parity(router(), description);
}
#[test]
#[should_panic(expected = "registered routes must all have a CLI pair")]
fn parity_rejects_an_api_route_registered_outside_the_table() {
assert_cli_parity(router().route("/api/v1/undocumented", get(|| async {})));
}
#[test]
#[should_panic(expected = "registered routes must all have a CLI pair")]
fn parity_rejects_api_routes_mounted_under_wrong_prefix() {
assert_cli_parity(Router::new().nest("/wrong", router()));
}
#[test]
#[should_panic(expected = "used a method fallback")]
fn parity_rejects_a_different_method_at_the_described_path() {
assert_routed_operations(
Router::new().route("/api/v1/status", post(|| async {})),
&std::collections::BTreeSet::from([("/api/v1/status", "GET".to_owned())]),
);
}
#[test]
#[should_panic(expected = "registered routes must all have a CLI pair")]
fn parity_rejects_a_route_registered_without_a_cli_pair() {
assert_cli_parity(router().route("/unpaired", get(|| async { "page with no CLI pair" })));
}
}