mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-08 05:03:49 -05:00
Refactor and simplify server code, UI updates requested in #5 and #6
Some checks failed
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-aarch64 os:ubuntu-latest release_for:linux-aarch64 target:aarch64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-x86_64 os:ubuntu-latest release_for:linux-x86_64 target:x86_64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-image (push) Has been cancelled
Some checks failed
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-aarch64 os:ubuntu-latest release_for:linux-aarch64 target:aarch64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-x86_64 os:ubuntu-latest release_for:linux-x86_64 target:x86_64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-image (push) Has been cancelled
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -353,6 +353,7 @@ name = "cup"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"bollard",
|
||||
"chrono",
|
||||
"clap",
|
||||
"http-auth",
|
||||
"indicatif",
|
||||
|
||||
@@ -17,6 +17,7 @@ once_cell = "1.19.0"
|
||||
http-auth = { version = "0.1.9", features = [] }
|
||||
termsize = { version = "0.1.8", optional = true }
|
||||
regex = "1.10.5"
|
||||
chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"] }
|
||||
|
||||
[features]
|
||||
default = ["server", "cli"]
|
||||
|
||||
@@ -95,8 +95,7 @@ async fn main() {
|
||||
},
|
||||
#[cfg(feature = "server")]
|
||||
Some(Commands::Serve { port }) => {
|
||||
let updates = get_all_updates(cli.socket).await;
|
||||
let _ = serve(port, &updates).await;
|
||||
let _ = serve(port, cli.socket).await;
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
|
||||
151
src/server.rs
151
src/server.rs
@@ -1,27 +1,33 @@
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Local;
|
||||
use liquid::{object, Object};
|
||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||
use xitca_web::{
|
||||
body::ResponseBody,
|
||||
handler::{handler_service, state::StateOwn},
|
||||
handler::{handler_service, state::StateRef},
|
||||
http::WebResponse,
|
||||
middleware::Logger,
|
||||
route::get,
|
||||
App,
|
||||
middleware::Logger
|
||||
};
|
||||
|
||||
use crate::{get_all_updates, utils::sort_update_vec};
|
||||
|
||||
const RAW_TEMPLATE: &str = include_str!("static/template.liquid");
|
||||
const STYLE: &str = include_str!("static/index.css");
|
||||
const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico");
|
||||
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg");
|
||||
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png");
|
||||
|
||||
pub async fn serve(port: &u16, updates: &[(String, Option<bool>)]) -> std::io::Result<()> {
|
||||
pub async fn serve(port: &u16, socket: Option<String>) -> std::io::Result<()> {
|
||||
let mut data = UpdateData::new(socket).await;
|
||||
data.refresh().await;
|
||||
App::new()
|
||||
.with_state(updates.to_owned())
|
||||
.with_state(Arc::new(Mutex::new(data)))
|
||||
.at("/", get(handler_service(home)))
|
||||
.at("/json", get(handler_service(json)))
|
||||
.at("/refresh", get(handler_service(refresh)))
|
||||
.at("/favicon.ico", handler_service(favicon_ico)) // These aren't pretty but this is xitca-web...
|
||||
.at("/favicon.svg", handler_service(favicon_svg))
|
||||
.at("/apple-touch-icon.png", handler_service(apple_touch_icon))
|
||||
@@ -32,62 +38,17 @@ pub async fn serve(port: &u16, updates: &[(String, Option<bool>)]) -> std::io::R
|
||||
.wait()
|
||||
}
|
||||
|
||||
async fn home(
|
||||
updates: StateOwn<Vec<(String, Option<bool>)>>,
|
||||
) -> WebResponse {
|
||||
let template = liquid::ParserBuilder::with_stdlib()
|
||||
.build()
|
||||
.unwrap()
|
||||
.parse(RAW_TEMPLATE)
|
||||
.unwrap();
|
||||
let images = updates
|
||||
.0
|
||||
.par_iter()
|
||||
.map(|(name, image)| match image {
|
||||
Some(value) => {
|
||||
if *value {
|
||||
object!({"name": name, "status": "update-available"})
|
||||
} else {
|
||||
object!({"name": name, "status": "up-to-date"})
|
||||
}
|
||||
}
|
||||
None => object!({"name": name, "status": "unknown"}),
|
||||
})
|
||||
.collect::<Vec<Object>>();
|
||||
let uptodate = images
|
||||
.par_iter()
|
||||
.filter(|&o| o["status"] == "up-to-date")
|
||||
.collect::<Vec<&Object>>()
|
||||
.len();
|
||||
let updatable = images
|
||||
.par_iter()
|
||||
.filter(|&o| o["status"] == "update-available")
|
||||
.collect::<Vec<&Object>>()
|
||||
.len();
|
||||
let unknown = images
|
||||
.par_iter()
|
||||
.filter(|&o| o["status"] == "unknown")
|
||||
.collect::<Vec<&Object>>()
|
||||
.len();
|
||||
let globals = object!({
|
||||
"metrics": [{"name": "Monitored images", "value": images.len()}, {"name": "Up to date", "value": uptodate}, {"name": "Updates available", "value": updatable}, {"name": "Unknown", "value": unknown}],
|
||||
"images": images,
|
||||
"style": STYLE
|
||||
});
|
||||
let result = template.render(&globals).unwrap();
|
||||
WebResponse::new(ResponseBody::from(result))
|
||||
async fn home(data: StateRef<'_, Arc<Mutex<UpdateData>>>) -> WebResponse {
|
||||
WebResponse::new(ResponseBody::from(data.lock().unwrap().template.clone()))
|
||||
}
|
||||
|
||||
async fn json(
|
||||
updates: StateOwn<Vec<(String, Option<bool>)>>
|
||||
) -> WebResponse {
|
||||
let result_mutex: Mutex<json::object::Object> = Mutex::new(json::object::Object::new());
|
||||
updates.par_iter().for_each(|image| match image.1 {
|
||||
Some(b) => result_mutex.lock().unwrap().insert(&image.0, json::from(b)),
|
||||
None => result_mutex.lock().unwrap().insert(&image.0, json::Null),
|
||||
});
|
||||
let result = json::stringify(result_mutex.lock().unwrap().clone());
|
||||
WebResponse::new(ResponseBody::from(result))
|
||||
async fn json(data: StateRef<'_, Arc<Mutex<UpdateData>>>) -> WebResponse {
|
||||
WebResponse::new(ResponseBody::from(data.lock().unwrap().json.clone()))
|
||||
}
|
||||
|
||||
async fn refresh(data: StateRef<'_, Arc<Mutex<UpdateData>>>) -> WebResponse {
|
||||
data.lock().unwrap().refresh().await;
|
||||
return WebResponse::new(ResponseBody::from("OK"));
|
||||
}
|
||||
|
||||
async fn favicon_ico() -> WebResponse {
|
||||
@@ -100,4 +61,74 @@ async fn favicon_svg() -> WebResponse {
|
||||
|
||||
async fn apple_touch_icon() -> WebResponse {
|
||||
WebResponse::new(ResponseBody::from(APPLE_TOUCH_ICON))
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateData {
|
||||
template: String,
|
||||
raw: Vec<(String, Option<bool>)>,
|
||||
json: String,
|
||||
socket: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateData {
|
||||
async fn new(socket: Option<String>) -> Self {
|
||||
return Self {
|
||||
socket,
|
||||
template: String::new(),
|
||||
json: String::new(),
|
||||
raw: Vec::new(),
|
||||
};
|
||||
}
|
||||
async fn refresh(self: &mut Self) {
|
||||
let updates = sort_update_vec(&get_all_updates(self.socket.clone()).await);
|
||||
self.raw = updates;
|
||||
let template = liquid::ParserBuilder::with_stdlib()
|
||||
.build()
|
||||
.unwrap()
|
||||
.parse(RAW_TEMPLATE)
|
||||
.unwrap();
|
||||
let images = self
|
||||
.raw
|
||||
.iter()
|
||||
.map(|(name, image)| match image {
|
||||
Some(value) => {
|
||||
if *value {
|
||||
object!({"name": name, "status": "update-available"})
|
||||
} else {
|
||||
object!({"name": name, "status": "up-to-date"})
|
||||
}
|
||||
}
|
||||
None => object!({"name": name, "status": "unknown"}),
|
||||
})
|
||||
.collect::<Vec<Object>>();
|
||||
let uptodate = images
|
||||
.par_iter()
|
||||
.filter(|&o| o["status"] == "up-to-date")
|
||||
.collect::<Vec<&Object>>()
|
||||
.len();
|
||||
let updatable = images
|
||||
.par_iter()
|
||||
.filter(|&o| o["status"] == "update-available")
|
||||
.collect::<Vec<&Object>>()
|
||||
.len();
|
||||
let unknown = images
|
||||
.par_iter()
|
||||
.filter(|&o| o["status"] == "unknown")
|
||||
.collect::<Vec<&Object>>()
|
||||
.len();
|
||||
let last_updated = Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let globals = object!({
|
||||
"metrics": [{"name": "Monitored images", "value": images.len()}, {"name": "Up to date", "value": uptodate}, {"name": "Updates available", "value": updatable}, {"name": "Unknown", "value": unknown}],
|
||||
"images": images,
|
||||
"style": STYLE,
|
||||
"last_updated": last_updated.to_string()
|
||||
});
|
||||
self.template = template.render(&globals).unwrap();
|
||||
let json_data: Mutex<json::object::Object> = Mutex::new(json::object::Object::new());
|
||||
self.raw.par_iter().for_each(|image| match image.1 {
|
||||
Some(b) => json_data.lock().unwrap().insert(&image.0, json::from(b)),
|
||||
None => json_data.lock().unwrap().insert(&image.0, json::Null),
|
||||
});
|
||||
self.json = json::stringify(json_data.lock().unwrap().clone());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<style>
|
||||
{{ style }}
|
||||
</style>
|
||||
@@ -70,13 +70,28 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function refresh(event) {
|
||||
var button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
|
||||
let request = new XMLHttpRequest()
|
||||
request.onload = function () {
|
||||
if (request.status === 200) {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
request.open("GET", `${window.location.origin}/refresh`);
|
||||
request.send();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div class="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full">
|
||||
<div class="max-w-[48rem] mx-auto h-full my-8">
|
||||
<div class="flex items-center gap-1">
|
||||
<h1 class="text-6xl font-bold dark:text-white">Cup</h1>
|
||||
<h1 class="text-5xl lg:text-6xl font-bold dark:text-white">Cup</h1>
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_2"
|
||||
@@ -120,7 +135,57 @@
|
||||
{% for metric in metrics %}
|
||||
<div class="gi">
|
||||
<div class="xl:px-8 px-6 py-4 gap-y-2 gap-x-4 justify-between align-baseline flex flex-col h-full">
|
||||
<dt class="text-gray-500 dark:text-gray-400 leading-6 font-medium">{{ metric.name }}</dt>
|
||||
<dt class="text-gray-500 dark:text-gray-400 leading-6 font-medium flex gap-1 justify-between">
|
||||
{{ metric.name }}
|
||||
{% if metric.name == 'Monitored images' %}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-6 text-black dark:text-white shrink-0"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 4c4.29 0 7.863 2.429 10.665 7.154l.22 .379l.045 .1l.03 .083l.014 .055l.014 .082l.011 .1v.11l-.014 .111a.992 .992 0 0 1 -.026 .11l-.039 .108l-.036 .075l-.016 .03c-2.764 4.836 -6.3 7.38 -10.555 7.499l-.313 .004c-4.396 0 -8.037 -2.549 -10.868 -7.504a1 1 0 0 1 0 -.992c2.831 -4.955 6.472 -7.504 10.868 -7.504zm0 5a3 3 0 1 0 0 6a3 3 0 0 0 0 -6z" />
|
||||
</svg>
|
||||
{% elsif metric.name == 'Up to date' %}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-6 text-green-500 shrink-0"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z" />
|
||||
</svg>
|
||||
{% elsif metric.name == 'Updates available' %}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-6 text-blue-500 shrink-0"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-4.98 3.66l-.163 .01l-.086 .016l-.142 .045l-.113 .054l-.07 .043l-.095 .071l-.058 .054l-4 4l-.083 .094a1 1 0 0 0 1.497 1.32l2.293 -2.293v5.586l.007 .117a1 1 0 0 0 1.993 -.117v-5.585l2.293 2.292l.094 .083a1 1 0 0 0 1.32 -1.497l-4 -4l-.082 -.073l-.089 -.064l-.113 -.062l-.081 -.034l-.113 -.034l-.112 -.02l-.098 -.006z" />
|
||||
</svg>
|
||||
{% elsif metric.name == 'Unknown' %}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-6 text-gray-500 shrink-0"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 2c5.523 0 10 4.477 10 10a10 10 0 0 1 -19.995 .324l-.005 -.324l.004 -.28c.148 -5.393 4.566 -9.72 9.996 -9.72zm0 13a1 1 0 0 0 -.993 .883l-.007 .117l.007 .127a1 1 0 0 0 1.986 0l.007 -.117l-.007 -.127a1 1 0 0 0 -.993 -.883zm1.368 -6.673a2.98 2.98 0 0 0 -3.631 .728a1 1 0 0 0 1.44 1.383l.171 -.18a.98 .98 0 0 1 1.11 -.15a1 1 0 0 1 -.34 1.886l-.232 .012a1 1 0 0 0 .111 1.994a3 3 0 0 0 1.371 -5.673z" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd class="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl flex-none w-full">
|
||||
{{ metric.value }}
|
||||
</dd>
|
||||
@@ -130,6 +195,25 @@
|
||||
</dl>
|
||||
</div>
|
||||
<div class="shadow-sm bg-white dark:bg-gray-900 rounded-md my-8">
|
||||
<div class="flex justify-between items-center px-6 py-4 text-gray-500">
|
||||
<h3>Last checked: {{ last_updated }}</h3>
|
||||
<button class="group" onclick="refresh(event)">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="group-disabled:animate-spin"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" /><path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-gray-800 divide-y dark:text-white">
|
||||
{% for image in images %}
|
||||
<li>
|
||||
|
||||
26
src/utils.rs
26
src/utils.rs
@@ -1,6 +1,7 @@
|
||||
use regex::Regex;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
/// This macro is an alternative to panic. It prints the message you give it and exits the process with code 1, without printing a stack trace. Useful for when the program has to exit due to a user error or something unexpected which is unrelated to the program (e.g. a failed web request)
|
||||
#[macro_export]
|
||||
macro_rules! error {
|
||||
($($arg:tt)*) => ({
|
||||
@@ -9,7 +10,7 @@ macro_rules! error {
|
||||
})
|
||||
}
|
||||
|
||||
// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest'].
|
||||
/// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest'].
|
||||
pub fn split_image(image: &str) -> (String, String, String) {
|
||||
static RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(
|
||||
@@ -45,26 +46,39 @@ pub fn split_image(image: &str) -> (String, String, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Given an image's parts which were previously created by split_image, recreate a reference that docker would use. This means removing the registry part, if it's Docker Hub and removing "library" if the image is official
|
||||
pub fn unsplit_image(registry: &str, repository: &str, tag: &str) -> String {
|
||||
let reg = match registry {
|
||||
"registry-1.docker.io" => String::new(),
|
||||
r => format!("{}/", r),
|
||||
};
|
||||
let repo = match repository.split('/').collect::<Vec<&str>>()[0] {
|
||||
"library" => repository.strip_prefix("library/").unwrap(),
|
||||
"library" => {
|
||||
if reg.is_empty() {
|
||||
repository.strip_prefix("library/").unwrap()
|
||||
} else {
|
||||
repository
|
||||
}
|
||||
}
|
||||
_ => repository,
|
||||
};
|
||||
format!("{}{}:{}", reg, repo, tag)
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
/// Sorts the update vector alphabetically and where Some(true) > Some(false) > None
|
||||
pub fn sort_update_vec(updates: &[(String, Option<bool>)]) -> Vec<(String, Option<bool>)> {
|
||||
let mut sorted_updates = updates.to_vec();
|
||||
sorted_updates.sort_unstable_by(|a, b| match (a.1, b.1) {
|
||||
(Some(a), Some(b)) => (!a).cmp(&!b),
|
||||
(Some(c), Some(d)) => {
|
||||
if c == d {
|
||||
a.0.cmp(&b.0)
|
||||
} else {
|
||||
(!c).cmp(&!d)
|
||||
}
|
||||
}
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
(None, None) => a.0.cmp(&b.0),
|
||||
});
|
||||
sorted_updates.to_vec()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user