Skip to content

Commit 5b1ca24

Browse files
authored
Merge pull request #2076 from Urgau/gha_logs_ansi_up
Add enhanced GitHub Actions raw logs viewer
2 parents de15a03 + 85cf3f4 commit 5b1ca24

File tree

9 files changed

+252
-3
lines changed

9 files changed

+252
-3
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/gha_logs.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
use crate::github;
2+
use crate::handlers::Context;
3+
use anyhow::Context as _;
4+
use hyper::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE};
5+
use hyper::{Body, Response, StatusCode};
6+
use std::collections::VecDeque;
7+
use std::str::FromStr;
8+
use std::sync::Arc;
9+
use uuid::Uuid;
10+
11+
pub const ANSI_UP_URL: &str = "/gha_logs/ansi_up@6.0.6.min.js";
12+
const MAX_CACHE_CAPACITY_BYTES: u64 = 50 * 1024 * 1024; // 50 Mb
13+
14+
#[derive(Default)]
15+
pub struct GitHubActionLogsCache {
16+
capacity: u64,
17+
entries: VecDeque<(String, Arc<String>)>,
18+
}
19+
20+
impl GitHubActionLogsCache {
21+
pub fn get(&mut self, key: &String) -> Option<Arc<String>> {
22+
if let Some(pos) = self.entries.iter().position(|(k, _)| k == key) {
23+
// Move previously cached entry to the front
24+
let entry = self.entries.remove(pos).unwrap();
25+
self.entries.push_front(entry.clone());
26+
Some(entry.1)
27+
} else {
28+
None
29+
}
30+
}
31+
32+
pub fn put(&mut self, key: String, value: Arc<String>) -> Arc<String> {
33+
if value.len() as u64 > MAX_CACHE_CAPACITY_BYTES {
34+
// Entry is too large, don't cache, return as is
35+
return value;
36+
}
37+
38+
// Remove duplicate or last entry when necessary
39+
let removed = if let Some(pos) = self.entries.iter().position(|(k, _)| k == &key) {
40+
self.entries.remove(pos)
41+
} else if self.capacity + value.len() as u64 >= MAX_CACHE_CAPACITY_BYTES {
42+
self.entries.pop_back()
43+
} else {
44+
None
45+
};
46+
if let Some(removed) = removed {
47+
self.capacity -= removed.1.len() as u64;
48+
}
49+
50+
// Add entry the front of the list ane return it
51+
self.capacity += value.len() as u64;
52+
self.entries.push_front((key, value.clone()));
53+
value
54+
}
55+
}
56+
57+
pub async fn gha_logs(
58+
ctx: Arc<Context>,
59+
owner: &str,
60+
repo: &str,
61+
log_id: &str,
62+
) -> Result<Response<Body>, hyper::Error> {
63+
let res = process_logs(ctx, owner, repo, log_id).await;
64+
let res = match res {
65+
Ok(r) => r,
66+
Err(e) => {
67+
tracing::error!("gha_logs: unable to serve logs for {owner}/{repo}#{log_id}: {e:?}");
68+
return Ok(Response::builder()
69+
.status(StatusCode::INTERNAL_SERVER_ERROR)
70+
.body(Body::from(format!("{:?}", e)))
71+
.unwrap());
72+
}
73+
};
74+
75+
Ok(res)
76+
}
77+
78+
async fn process_logs(
79+
ctx: Arc<Context>,
80+
owner: &str,
81+
repo: &str,
82+
log_id: &str,
83+
) -> anyhow::Result<Response<Body>> {
84+
let log_id = u128::from_str(log_id).context("log_id is not a number")?;
85+
86+
let repos = ctx
87+
.team
88+
.repos()
89+
.await
90+
.context("unable to retrieve team repos")?;
91+
92+
let Some(repos) = repos.repos.get(owner) else {
93+
anyhow::bail!("Organization `{owner}` is not part of team repos")
94+
};
95+
96+
if !repos.iter().any(|r| r.name == repo) {
97+
anyhow::bail!("Repository `{repo}` is not part of team repos");
98+
}
99+
100+
let log_uuid = format!("{owner}/{repo}${log_id}");
101+
102+
let logs = 'logs: {
103+
if let Some(logs) = ctx.gha_logs.write().await.get(&log_uuid) {
104+
tracing::info!("gha_logs: cache hit for {log_uuid}");
105+
break 'logs logs;
106+
}
107+
108+
tracing::info!("gha_logs: cache miss for {log_uuid}");
109+
let logs = ctx
110+
.github
111+
.raw_job_logs(
112+
&github::IssueRepository {
113+
organization: owner.to_string(),
114+
repository: repo.to_string(),
115+
},
116+
log_id,
117+
)
118+
.await
119+
.context("unable to get the raw logs")?;
120+
121+
let json_logs = serde_json::to_string(&*logs).context("unable to JSON-ify the raw logs")?;
122+
123+
ctx.gha_logs
124+
.write()
125+
.await
126+
.put(log_uuid.clone(), json_logs.into())
127+
};
128+
129+
let nonce = Uuid::new_v4().to_hyphenated().to_string();
130+
131+
let html = format!(
132+
r#"<!DOCTYPE html>
133+
<html>
134+
<head>
135+
<title>{log_uuid} - triagebot</title>
136+
<meta charset="UTF-8">
137+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
138+
<link rel="icon" sizes="32x32" type="image/png" href="https://rust-lang.org/static/images/favicon-32x32.png">
139+
<style>
140+
body {{
141+
font: 14px SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
142+
background: #0C0C0C;
143+
color: #CCCCCC;
144+
white-space: pre;
145+
}}
146+
</style>
147+
<script type="module" nonce="{nonce}">
148+
import {{ AnsiUp }} from '{ANSI_UP_URL}'
149+
150+
var logs = {logs};
151+
var ansi_up = new AnsiUp();
152+
153+
var html = ansi_up.ansi_to_html(logs);
154+
155+
var cdiv = document.getElementById("console");
156+
cdiv.innerHTML = html;
157+
</script>
158+
</head>
159+
<body id="console">
160+
</body>
161+
</html>"#,
162+
);
163+
164+
tracing::info!("gha_logs: serving logs for {log_uuid}");
165+
166+
return Ok(Response::builder()
167+
.status(StatusCode::OK)
168+
.header(CONTENT_TYPE, "text/html; charset=utf-8")
169+
.header(
170+
CONTENT_SECURITY_POLICY,
171+
format!("script-src 'nonce-{nonce}' {ANSI_UP_URL}"),
172+
)
173+
.body(Body::from(html))?);
174+
}
175+
176+
pub fn ansi_up_min_js() -> anyhow::Result<Response<Body>, hyper::Error> {
177+
const ANSI_UP_MIN_JS: &str = include_str!("gha_logs/ansi_up@6.0.6.min.js");
178+
179+
Ok(Response::builder()
180+
.status(StatusCode::OK)
181+
.header(CACHE_CONTROL, "public, max-age=15552000, immutable")
182+
.header(CONTENT_TYPE, "text/javascript; charset=utf-8")
183+
.body(Body::from(ANSI_UP_MIN_JS))
184+
.unwrap())
185+
}

0 commit comments

Comments
 (0)