mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-17 09:33:38 -05:00
Complete rewrite
This commit is contained in:
84
src/docker.rs
Normal file
84
src/docker.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use bollard::{
|
||||
secret::ImageSummary,
|
||||
ClientVersion, Docker,
|
||||
};
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
use bollard::secret::ImageInspect;
|
||||
|
||||
use crate::{error, image::Image, utils::split_image};
|
||||
|
||||
fn create_docker_client(socket: Option<String>) -> Docker {
|
||||
let client: Result<Docker, bollard::errors::Error> = match socket {
|
||||
Some(sock) => Docker::connect_with_local(
|
||||
&sock,
|
||||
120,
|
||||
&ClientVersion {
|
||||
major_version: 1,
|
||||
minor_version: 44,
|
||||
},
|
||||
),
|
||||
None => Docker::connect_with_local_defaults(),
|
||||
};
|
||||
|
||||
match client {
|
||||
Ok(d) => d,
|
||||
Err(e) => error!("Failed to connect to docker socket!\n{}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_images_from_docker_daemon(socket: Option<String>) -> Vec<Image> {
|
||||
let client: Docker = create_docker_client(socket);
|
||||
let images: Vec<ImageSummary> = match client.list_images::<String>(None).await {
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
error!("Failed to retrieve list of images available!\n{}", e)
|
||||
}
|
||||
};
|
||||
let mut result: Vec<Image> = Vec::new();
|
||||
for image in images {
|
||||
if !image.repo_tags.is_empty() && image.repo_digests.len() == 1 {
|
||||
for t in &image.repo_tags {
|
||||
let (registry, repository, tag) = split_image(t);
|
||||
result.push(Image {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
digest: Some(
|
||||
image.repo_digests[0]
|
||||
.clone()
|
||||
.split('@')
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
pub async fn get_image_from_docker_daemon(socket: Option<String>, name: &str) -> Image {
|
||||
let client: Docker = create_docker_client(socket);
|
||||
let image: ImageInspect = match client.inspect_image(name).await {
|
||||
Ok(i) => i,
|
||||
Err(e) => error!("Failed to retrieve image {} from daemon\n{}", name, e),
|
||||
};
|
||||
match image.repo_tags {
|
||||
Some(_) => (),
|
||||
None => error!("Image has no tags"), // I think this is actually unreachable
|
||||
}
|
||||
match image.repo_digests {
|
||||
Some(d) => {
|
||||
let (registry, repository, tag) = split_image(&image.repo_tags.unwrap()[0]);
|
||||
Image {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
digest: Some(d[0].clone().split('@').collect::<Vec<&str>>()[1].to_string()),
|
||||
}
|
||||
}
|
||||
None => error!("No digests found for image {}", name),
|
||||
}
|
||||
}
|
||||
94
src/formatting.rs
Normal file
94
src/formatting.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use json::object;
|
||||
|
||||
use crate::utils::sort_update_vec;
|
||||
|
||||
pub fn print_updates(updates: &[(String, Option<bool>)], icons: &bool) {
|
||||
let sorted_updates = sort_update_vec(updates);
|
||||
let term_width: usize = termsize::get()
|
||||
.unwrap_or(termsize::Size { rows: 24, cols: 80 })
|
||||
.cols as usize;
|
||||
for update in sorted_updates {
|
||||
let description = match update.1 {
|
||||
Some(true) => "Update available",
|
||||
Some(false) => "Up to date",
|
||||
None => "Unknown",
|
||||
};
|
||||
let icon = if *icons {
|
||||
match update.1 {
|
||||
Some(true) => "\u{f0aa} ",
|
||||
Some(false) => "\u{f058} ",
|
||||
None => "\u{f059} ",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let color = match update.1 {
|
||||
Some(true) => "\u{001b}[38;5;12m",
|
||||
Some(false) => "\u{001b}[38;5;2m",
|
||||
None => "\u{001b}[38;5;8m",
|
||||
};
|
||||
let dynamic_space =
|
||||
" ".repeat(term_width - description.len() - icon.len() - update.0.len());
|
||||
println!(
|
||||
"{}{}{}{}{}",
|
||||
color, icon, update.0, dynamic_space, description
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_raw_updates(updates: &[(String, Option<bool>)]) {
|
||||
let mut result = json::Array::new();
|
||||
for update in updates {
|
||||
result.push(object! {image: update.0.clone(), has_update: update.1});
|
||||
}
|
||||
println!("{}", json::stringify(result));
|
||||
}
|
||||
|
||||
pub fn print_update(name: &str, has_update: &Option<bool>) {
|
||||
let color = match has_update {
|
||||
Some(true) => "\u{001b}[38;5;12m",
|
||||
Some(false) => "\u{001b}[38;5;2m",
|
||||
None => "\u{001b}[38;5;8m",
|
||||
};
|
||||
let description = match has_update {
|
||||
Some(true) => "has an update available",
|
||||
Some(false) => "is up to date",
|
||||
None => "wasn't found",
|
||||
};
|
||||
println!("{}{} {}", color, name, description);
|
||||
}
|
||||
|
||||
pub fn print_raw_update(name: &str, has_update: &Option<bool>) {
|
||||
let result = object!{image: name, has_update: *has_update};
|
||||
println!("{}", json::stringify(result));
|
||||
}
|
||||
|
||||
pub struct Spinner {
|
||||
spinner: ProgressBar,
|
||||
}
|
||||
|
||||
impl Spinner {
|
||||
pub fn new() -> Spinner {
|
||||
let spinner = ProgressBar::new_spinner();
|
||||
let style: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let progress_style = ProgressStyle::default_spinner();
|
||||
|
||||
spinner.set_style(ProgressStyle::tick_strings(progress_style, style));
|
||||
|
||||
spinner.set_message("Checking...");
|
||||
spinner.enable_steady_tick(Duration::from_millis(50));
|
||||
|
||||
Spinner { spinner }
|
||||
}
|
||||
pub fn succeed(&self) {
|
||||
const CHECKMARK: &str = "\u{001b}[32;1m\u{2713}\u{001b}[0m";
|
||||
|
||||
let success_message = format!("{} Done!", CHECKMARK);
|
||||
self.spinner
|
||||
.set_style(ProgressStyle::with_template("{msg}").unwrap());
|
||||
self.spinner.finish_with_message(success_message);
|
||||
}
|
||||
}
|
||||
7
src/image.rs
Normal file
7
src/image.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Image {
|
||||
pub registry: String,
|
||||
pub repository: String,
|
||||
pub tag: String,
|
||||
pub digest: Option<String>,
|
||||
}
|
||||
1
src/index.css
Normal file
1
src/index.css
Normal file
File diff suppressed because one or more lines are too long
163
src/main.rs
Normal file
163
src/main.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
#[cfg(feature = "cli")]
|
||||
use docker::get_image_from_docker_daemon;
|
||||
use docker::get_images_from_docker_daemon;
|
||||
#[cfg(feature = "cli")]
|
||||
use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner};
|
||||
use image::Image;
|
||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||
#[cfg(feature = "cli")]
|
||||
use registry::get_latest_digest;
|
||||
use registry::{check_auth, get_latest_digests, get_token};
|
||||
#[cfg(feature = "server")]
|
||||
use server::serve;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Mutex,
|
||||
};
|
||||
use utils::unsplit_image;
|
||||
|
||||
pub mod docker;
|
||||
#[cfg(feature = "cli")]
|
||||
pub mod formatting;
|
||||
pub mod image;
|
||||
pub mod registry;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
pub mod utils;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[arg(short, long, default_value = None)]
|
||||
socket: Option<String>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
#[cfg(feature = "cli")]
|
||||
Check {
|
||||
#[arg(default_value = None)]
|
||||
image: Option<String>,
|
||||
#[arg(short, long, default_value_t = false, help = "Enable icons")]
|
||||
icons: bool,
|
||||
#[arg(short, long, default_value_t = false, help = "Output JSON instead of formatted text")]
|
||||
raw: bool,
|
||||
},
|
||||
#[cfg(feature = "server")]
|
||||
Serve {
|
||||
#[arg(short, long, default_value_t = 8000, help = "Use a different port for the server")]
|
||||
port: u16,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait Unique<T> {
|
||||
// So we can filter vecs for duplicates
|
||||
fn unique(&mut self);
|
||||
}
|
||||
|
||||
impl<T> Unique<T> for Vec<T>
|
||||
where
|
||||
T: Clone + Eq + std::hash::Hash,
|
||||
{
|
||||
fn unique(self: &mut Vec<T>) {
|
||||
let mut seen: HashSet<T> = HashSet::new();
|
||||
self.retain(|item| seen.insert(item.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
match &cli.command {
|
||||
#[cfg(feature = "cli")]
|
||||
Some(Commands::Check { image, icons, raw }) => match image {
|
||||
Some(name) => {
|
||||
let has_update = get_update(name, cli.socket).await;
|
||||
match raw {
|
||||
true => print_raw_update(name, &has_update),
|
||||
false => print_update(name, &has_update),
|
||||
};
|
||||
}
|
||||
None => {
|
||||
match raw {
|
||||
true => print_raw_updates(&get_all_updates(cli.socket).await),
|
||||
false => {
|
||||
let spinner = Spinner::new();
|
||||
let updates = get_all_updates(cli.socket).await;
|
||||
spinner.succeed();
|
||||
print_updates(&updates, icons);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
#[cfg(feature = "server")]
|
||||
Some(Commands::Serve { port }) => {
|
||||
let updates = get_all_updates(cli.socket).await;
|
||||
let _ = serve(port, &updates).await;
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_all_updates(socket: Option<String>) -> Vec<(String, Option<bool>)> {
|
||||
let image_map_mutex: Mutex<HashMap<String, &Option<String>>> = Mutex::new(HashMap::new());
|
||||
let local_images = get_images_from_docker_daemon(socket).await;
|
||||
local_images.par_iter().for_each(|image| {
|
||||
let img = unsplit_image(&image.registry, &image.repository, &image.tag);
|
||||
image_map_mutex.lock().unwrap().insert(img, &image.digest);
|
||||
});
|
||||
let image_map = image_map_mutex.lock().unwrap().clone();
|
||||
let mut registries: Vec<&String> = local_images
|
||||
.par_iter()
|
||||
.map(|image| &image.registry)
|
||||
.collect();
|
||||
registries.unique();
|
||||
let mut remote_images: Vec<Image> = Vec::new();
|
||||
for registry in registries {
|
||||
let images: Vec<&Image> = local_images
|
||||
.par_iter()
|
||||
.filter(|image| &image.registry == registry)
|
||||
.collect();
|
||||
let mut latest_images = match check_auth(registry) {
|
||||
Some(auth_url) => {
|
||||
let token = get_token(images.clone(), &auth_url);
|
||||
get_latest_digests(images, Some(&token))
|
||||
}
|
||||
None => get_latest_digests(images, None),
|
||||
};
|
||||
remote_images.append(&mut latest_images);
|
||||
}
|
||||
let result_mutex: Mutex<Vec<(String, Option<bool>)>> = Mutex::new(Vec::new());
|
||||
remote_images.par_iter().for_each(|image| {
|
||||
let img = unsplit_image(&image.registry, &image.repository, &image.tag);
|
||||
match &image.digest {
|
||||
Some(d) => {
|
||||
let r = d != image_map.get(&img).unwrap().as_ref().unwrap();
|
||||
result_mutex.lock().unwrap().push((img, Some(r)))
|
||||
}
|
||||
None => result_mutex.lock().unwrap().push((img, None)),
|
||||
}
|
||||
});
|
||||
let result = result_mutex.lock().unwrap().clone();
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
async fn get_update(image: &str, socket: Option<String>) -> Option<bool> {
|
||||
let local_image = get_image_from_docker_daemon(socket, image).await;
|
||||
let token = match check_auth(&local_image.registry) {
|
||||
Some(auth_url) => get_token(vec![&local_image], &auth_url),
|
||||
None => String::new(),
|
||||
};
|
||||
let remote_image = match token.as_str() {
|
||||
"" => get_latest_digest(&local_image, None),
|
||||
_ => get_latest_digest(&local_image, Some(&token)),
|
||||
};
|
||||
match &remote_image.digest {
|
||||
Some(d) => Some(d != &local_image.digest.unwrap()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
122
src/registry.rs
Normal file
122
src/registry.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||
use ureq::Error;
|
||||
|
||||
use http_auth::parse_challenges;
|
||||
|
||||
use crate::{error, image::Image};
|
||||
|
||||
pub fn check_auth(registry: &str) -> Option<String> {
|
||||
let response = ureq::get(&format!("https://{}/v2/", registry)).call();
|
||||
match response {
|
||||
Ok(_) => None,
|
||||
Err(Error::Status(401, response)) => match response.header("www-authenticate") {
|
||||
Some(challenge) => Some(parse_www_authenticate(challenge)),
|
||||
None => error!("Server returned invalid response!"),
|
||||
},
|
||||
Err(e) => error!("{}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_latest_digest(image: &Image, token: Option<&String>) -> Image {
|
||||
let mut request = ureq::head(&format!(
|
||||
"https://{}/v2/{}/manifests/{}",
|
||||
&image.registry, &image.repository, &image.tag
|
||||
));
|
||||
if let Some(t) = token {
|
||||
request = request.set("Authorization", &format!("Bearer {}", t));
|
||||
}
|
||||
let raw_response = match request
|
||||
.set("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json")
|
||||
.call()
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(Error::Status(401, response)) => {
|
||||
if token.is_some() {
|
||||
error!("Failed to authenticate with given token!\n{}", token.unwrap())
|
||||
} else {
|
||||
return get_latest_digest(
|
||||
image,
|
||||
Some(&get_token(
|
||||
vec![image],
|
||||
&parse_www_authenticate(response.header("www-authenticate").unwrap()),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(Error::Status(_, _)) => {
|
||||
return Image {
|
||||
digest: None,
|
||||
..image.clone()
|
||||
}
|
||||
}
|
||||
Err(ureq::Error::Transport(e)) => error!("Failed to send request!\n{}", e),
|
||||
};
|
||||
match raw_response.header("docker-content-digest") {
|
||||
Some(digest) => Image {
|
||||
digest: Some(digest.to_string()),
|
||||
..image.clone()
|
||||
},
|
||||
None => error!("Server returned invalid response! No docker-content-digest!"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_latest_digests(images: Vec<&Image>, token: Option<&String>) -> Vec<Image> {
|
||||
let result: Mutex<Vec<Image>> = Mutex::new(Vec::new());
|
||||
images.par_iter().for_each(|&image| {
|
||||
let digest = get_latest_digest(image, token).digest;
|
||||
result.lock().unwrap().push(Image {
|
||||
digest,
|
||||
..image.clone()
|
||||
});
|
||||
});
|
||||
let r = result.lock().unwrap().clone();
|
||||
r
|
||||
}
|
||||
|
||||
pub fn get_token(images: Vec<&Image>, auth_url: &str) -> String {
|
||||
let mut final_url = auth_url.to_owned();
|
||||
for image in images {
|
||||
final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository);
|
||||
}
|
||||
let raw_response = match ureq::get(&final_url)
|
||||
.set("Accept", "application/vnd.oci.image.index.v1+json")
|
||||
.call()
|
||||
{
|
||||
Ok(response) => match response.into_string() {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
error!("Failed to parse response into string!\n{}", e)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Token request failed!\n{}", e)
|
||||
}
|
||||
};
|
||||
let parsed_token_response = match json::parse(&raw_response) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(e) => {
|
||||
error!("Failed to parse server response\n{}", e)
|
||||
}
|
||||
};
|
||||
parsed_token_response["token"].to_string()
|
||||
}
|
||||
|
||||
fn parse_www_authenticate(www_auth: &str) -> String {
|
||||
let challenges = parse_challenges(www_auth).unwrap();
|
||||
if !challenges.is_empty() {
|
||||
let challenge = &challenges[0];
|
||||
if challenge.scheme == "Bearer" {
|
||||
format!(
|
||||
"{}?service={}",
|
||||
challenge.params[0].1.as_escaped(),
|
||||
challenge.params[1].1.as_escaped()
|
||||
)
|
||||
} else {
|
||||
error!("Unsupported scheme {}", &challenge.scheme)
|
||||
}
|
||||
} else {
|
||||
error!("No challenge provided");
|
||||
}
|
||||
}
|
||||
90
src/server.rs
Normal file
90
src/server.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use liquid::{object, Object};
|
||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||
use xitca_web::{
|
||||
body::ResponseBody,
|
||||
handler::{handler_service, path::PathOwn, state::StateOwn},
|
||||
http::{Method, WebResponse},
|
||||
route::get,
|
||||
App,
|
||||
};
|
||||
|
||||
const RAW_TEMPLATE: &str = include_str!("template.liquid");
|
||||
const STYLE: &str = include_str!("index.css");
|
||||
|
||||
pub async fn serve(port: &u16, updates: &[(String, Option<bool>)]) -> std::io::Result<()> {
|
||||
println!("Serving on http://0.0.0.0:{}", port);
|
||||
App::new()
|
||||
.with_state(updates.to_owned())
|
||||
.at("/", get(handler_service(home)))
|
||||
.at("/json", get(handler_service(json)))
|
||||
.serve()
|
||||
.bind(format!("0.0.0.0:{}", port))?
|
||||
.run()
|
||||
.wait()
|
||||
}
|
||||
|
||||
async fn home(
|
||||
updates: StateOwn<Vec<(String, Option<bool>)>>,
|
||||
method: Method,
|
||||
path: PathOwn,
|
||||
) -> 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();
|
||||
println!("Received {} request on {}", method, path.0);
|
||||
WebResponse::new(ResponseBody::from(result))
|
||||
}
|
||||
|
||||
async fn json(
|
||||
updates: StateOwn<Vec<(String, Option<bool>)>>,
|
||||
method: Method,
|
||||
path: PathOwn,
|
||||
) -> 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());
|
||||
println!("Received {} request on {}", method, path.0);
|
||||
WebResponse::new(ResponseBody::from(result))
|
||||
}
|
||||
129
src/template.liquid
Normal file
129
src/template.liquid
Normal file
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<style>
|
||||
{{ style }}
|
||||
</style>
|
||||
<style>
|
||||
/* Kinda hacky, but thank you https://geary.co/internal-borders-css-grid/ */
|
||||
.gi {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gi::before,
|
||||
.gi::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: #e5e7eb;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.gi::before, .gi::after {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.gi::before {
|
||||
inline-size: 1px;
|
||||
block-size: 100vh;
|
||||
inset-inline-start: -0.125rem;
|
||||
}
|
||||
|
||||
.gi::after {
|
||||
inline-size: 100vw;
|
||||
block-size: 1px;
|
||||
inset-inline-start: 0;
|
||||
inset-block-start: -0.12rem;
|
||||
}
|
||||
</style>
|
||||
</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">
|
||||
<h1 class="text-6xl font-bold dark:text-white">Cup🥤</h1>
|
||||
<div class="shadow-sm bg-white dark:bg-gray-900 rounded-md my-8">
|
||||
<dl class="lg:grid-cols-4 md:grid-cols-2 gap-1 grid-cols-1 grid overflow-hidden *:relative">
|
||||
{% 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>
|
||||
<dd class="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl flex-none w-full">
|
||||
{{ metric.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="shadow-sm bg-white dark:bg-gray-900 rounded-md my-8">
|
||||
<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>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M21 16.008v-8.018a1.98 1.98 0 0 0 -1 -1.717l-7 -4.008a2.016 2.016 0 0 0 -2 0l-7 4.008c-.619 .355 -1 1.01 -1 1.718v8.018c0 .709 .381 1.363 1 1.717l7 4.008a2.016 2.016 0 0 0 2 0l7 -4.008c.619 -.355 1 -1.01 1 -1.718z" />
|
||||
<path d="M12 22v-10" />
|
||||
<path d="M12 12l8.73 -5.04" />
|
||||
<path d="M3.27 6.96l8.73 5.04" />
|
||||
</svg>
|
||||
{{ image.name }}
|
||||
{% if image.status == 'up-to-date' %}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="text-green-500 ml-auto"
|
||||
>
|
||||
<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 image.status == 'update-available' %}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="text-blue-500 ml-auto"
|
||||
>
|
||||
<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 image.status == 'unknown' %}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="text-gray-500 ml-auto"
|
||||
>
|
||||
<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 %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
77
src/utils.rs
Normal file
77
src/utils.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use fancy_regex::Regex;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! error {
|
||||
($($arg:tt)*) => ({
|
||||
eprintln!($($arg)*);
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
|
||||
// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest']. ONLY REGISTRIES THAT USE A / IN THE REPOSITORY ARE SUPPORTED CURRENTLY. THAT MEANS AZURE WILL NOT WORK.
|
||||
pub fn split_image(image: &str) -> (String, String, String) {
|
||||
static RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(
|
||||
r#"^(?P<registry>[\w.\-_]+((?::\d+|)(?=/[a-z0-9._-]+/[a-z0-9._-]+))|)(?:/|)(?P<repository>[a-z0-9.\-_]+(?:/[a-z0-9.\-_]+|))(:(?P<tag>[\w.\-_]{1,127})|)$"#, // From https://regex101.com/r/a98UqN/1
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
match RE.captures(image).unwrap() {
|
||||
Some(c) => {
|
||||
return (
|
||||
match c.name("registry") {
|
||||
Some(registry) => {
|
||||
let reg = registry.as_str().to_owned();
|
||||
if reg.is_empty() {
|
||||
String::from("registry-1.docker.io")
|
||||
} else {
|
||||
reg
|
||||
}
|
||||
}
|
||||
None => error!("Failed to parse image {}", image),
|
||||
},
|
||||
match c.name("repository") {
|
||||
Some(repository) => {
|
||||
let repo = repository.as_str().to_owned();
|
||||
if !repo.contains('/') {
|
||||
format!("library/{}", repo)
|
||||
} else {
|
||||
repo
|
||||
}
|
||||
}
|
||||
None => error!("Failed to parse image {}", image),
|
||||
},
|
||||
match c.name("tag") {
|
||||
Some(tag) => tag.as_str().to_owned(),
|
||||
None => String::from("latest"),
|
||||
},
|
||||
)
|
||||
}
|
||||
None => error!("Failed to parse image {}", image),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unsplit_image(registry: &str, repository: &str, tag: &str) -> String {
|
||||
let reg = match registry {
|
||||
"registry-1.docker.io" => "",
|
||||
r => &format!("{}/", r),
|
||||
};
|
||||
let repo = match repository.split('/').collect::<Vec<&str>>()[0] {
|
||||
"library" => repository.strip_prefix("library/").unwrap(),
|
||||
_ => repository,
|
||||
};
|
||||
format!("{}{}:{}", reg, repo, tag)
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
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(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
});
|
||||
sorted_updates.to_vec()
|
||||
}
|
||||
Reference in New Issue
Block a user