m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-08 05:03:49 -05:00

Added config, custom theming and moved update checking logic to check.rs

This commit is contained in:
Sergio
2024-07-15 14:11:46 +03:00
parent b9278ca010
commit 30c762ea83
9 changed files with 220 additions and 118 deletions

10
Cargo.lock generated
View File

@@ -355,6 +355,7 @@ dependencies = [
"bollard",
"chrono",
"clap",
"home",
"http-auth",
"indicatif",
"json",
@@ -555,6 +556,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "home"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "http"
version = "1.1.0"

View File

@@ -18,6 +18,7 @@ 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"] }
home = "0.5.9"
[features]
default = ["server", "cli"]

84
src/check.rs Normal file
View File

@@ -0,0 +1,84 @@
use std::{collections::{HashMap, HashSet}, sync::Mutex};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use crate::{docker::get_images_from_docker_daemon, image::Image, registry::{check_auth, get_token, get_latest_digests}, utils::unsplit_image};
#[cfg(feature = "cli")]
use crate::docker::get_image_from_docker_daemon;
#[cfg(feature = "cli")]
use crate::registry::get_latest_digest;
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()));
}
}
pub 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")]
pub 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,
}
}

View File

@@ -1,22 +1,14 @@
use check::{get_all_updates, get_update};
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;
use std::path::PathBuf;
use utils::load_config;
pub mod check;
pub mod docker;
#[cfg(feature = "cli")]
pub mod formatting;
@@ -31,6 +23,8 @@ pub mod utils;
struct Cli {
#[arg(short, long, default_value = None)]
socket: Option<String>,
#[arg(short, long, default_value_t = String::new(), help = "Config file path")]
config_path: String,
#[command(subcommand)]
command: Option<Commands>,
}
@@ -43,34 +37,34 @@ enum Commands {
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")]
#[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")]
#[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();
let cfg_path = match cli.config_path.as_str() {
"" => None,
path => Some(PathBuf::from(path)),
};
let config = load_config(cfg_path);
match &cli.command {
#[cfg(feature = "cli")]
Some(Commands::Check { image, icons, raw }) => match image {
@@ -95,68 +89,8 @@ async fn main() {
},
#[cfg(feature = "server")]
Some(Commands::Serve { port }) => {
let _ = serve(port, cli.socket).await;
let _ = serve(port, cli.socket, config).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,
}
}

View File

@@ -12,7 +12,7 @@ use xitca_web::{
App,
};
use crate::{get_all_updates, utils::sort_update_vec};
use crate::{check::get_all_updates, utils::{sort_update_vec, Config}};
const RAW_TEMPLATE: &str = include_str!("static/template.liquid");
const STYLE: &str = include_str!("static/index.css");
@@ -20,8 +20,8 @@ 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, socket: Option<String>) -> std::io::Result<()> {
let mut data = UpdateData::new(socket).await;
pub async fn serve(port: &u16, socket: Option<String>, config: Config) -> std::io::Result<()> {
let mut data = ServerData::new(socket, config).await;
data.refresh().await;
App::new()
.with_state(Arc::new(Mutex::new(data)))
@@ -38,15 +38,15 @@ pub async fn serve(port: &u16, socket: Option<String>) -> std::io::Result<()> {
.wait()
}
async fn home(data: StateRef<'_, Arc<Mutex<UpdateData>>>) -> WebResponse {
async fn home(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::new(ResponseBody::from(data.lock().unwrap().template.clone()))
}
async fn json(data: StateRef<'_, Arc<Mutex<UpdateData>>>) -> WebResponse {
async fn json(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::new(ResponseBody::from(data.lock().unwrap().json.clone()))
}
async fn refresh(data: StateRef<'_, Arc<Mutex<UpdateData>>>) -> WebResponse {
async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
data.lock().unwrap().refresh().await;
return WebResponse::new(ResponseBody::from("OK"));
}
@@ -63,32 +63,34 @@ async fn apple_touch_icon() -> WebResponse {
WebResponse::new(ResponseBody::from(APPLE_TOUCH_ICON))
}
struct UpdateData {
struct ServerData {
template: String,
raw: Vec<(String, Option<bool>)>,
raw_updates: Vec<(String, Option<bool>)>,
json: String,
socket: Option<String>,
config: Config,
}
impl UpdateData {
async fn new(socket: Option<String>) -> Self {
impl ServerData {
async fn new(socket: Option<String>, config: Config) -> Self {
return Self {
socket,
template: String::new(),
json: String::new(),
raw: Vec::new(),
raw_updates: Vec::new(),
config,
};
}
async fn refresh(self: &mut Self) {
let updates = sort_update_vec(&get_all_updates(self.socket.clone()).await);
self.raw = updates;
self.raw_updates = updates;
let template = liquid::ParserBuilder::with_stdlib()
.build()
.unwrap()
.parse(RAW_TEMPLATE)
.unwrap();
let images = self
.raw
.raw_updates
.iter()
.map(|(name, image)| match image {
Some(value) => {
@@ -121,11 +123,12 @@ impl UpdateData {
"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()
"last_updated": last_updated.to_string(),
"theme": self.config.theme
});
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 {
self.raw_updates.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),
});

File diff suppressed because one or more lines are too long

View File

@@ -21,14 +21,22 @@
.gi::after {
content: '';
position: absolute;
background-color: #e5e7eb;
z-index: 1;
{% if theme == "neutral" %}
background-color: #e5e5e5;
{% elsif theme == "gray" %}
background-color: #e5e7eb
{% endif %}
}
@media (prefers-color-scheme: dark) {
.gi::before,
.gi::after {
background-color: #1f2937;
{% if theme == "neutral" %}
background-color: #262626;
{% elsif theme == "gray" %}
background-color: #1f2937
{% endif %}
}
}
@@ -75,19 +83,19 @@
var button = event.currentTarget;
button.disabled = true;
let request = new XMLHttpRequest()
let request = new XMLHttpRequest();
request.onload = function () {
if (request.status === 200) {
window.location.reload();
}
};
request.open("GET", `${window.location.origin}/refresh`);
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="flex justify-center items-center min-h-screen bg-{{ theme }}-50 dark:bg-{{ theme }}-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">
@@ -130,12 +138,12 @@
</g>
</svg>
</div>
<div class="shadow-sm bg-white dark:bg-gray-900 rounded-md my-8">
<div class="shadow-sm bg-white dark:bg-{{ theme }}-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 flex gap-1 justify-between">
<dt class="text-{{ theme }}-500 dark:text-{{ theme }}-400 leading-6 font-medium flex gap-1 justify-between">
{{ metric.name }}
{% if metric.name == 'Monitored images' %}
<svg
@@ -179,7 +187,7 @@
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-gray-500 shrink-0"
class="size-6 text-{{ theme }}-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" />
@@ -194,8 +202,8 @@
{% endfor %}
</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">
<div class="shadow-sm bg-white dark:bg-{{ theme }}-900 rounded-md my-8">
<div class="flex justify-between items-center px-6 py-4 text-{{ theme }}-500">
<h3>Last checked: {{ last_updated }}</h3>
<button class="group" onclick="refresh(event)">
<svg
@@ -214,7 +222,7 @@
</svg>
</button>
</div>
<ul class="*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-gray-800 divide-y dark:text-white">
<ul class="*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-{{ theme }}-800 divide-y dark:text-white">
{% for image in images %}
<li>
<svg
@@ -266,7 +274,7 @@
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="text-gray-500 ml-auto"
class="text-{{ theme }}-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" />

View File

@@ -1,3 +1,6 @@
use std::{collections::HashMap, path::PathBuf};
use json::JsonValue;
use once_cell::sync::Lazy;
use regex::Regex;
@@ -82,3 +85,56 @@ pub fn sort_update_vec(updates: &[(String, Option<bool>)]) -> Vec<(String, Optio
});
sorted_updates.to_vec()
}
/// Tries to load the config from the path provided and perform basic validation
pub fn load_config(config_path: Option<PathBuf>) -> Config {
let raw_config = match &config_path {
Some(path) => std::fs::read_to_string(path),
None => Ok(String::from("{\"theme\":\"default\"}")),
};
if raw_config.is_err() {
panic!(
"Failed to read config file from {}. Are you sure the file exists?",
&config_path.unwrap().to_str().unwrap()
)
};
let config = match json::parse(&raw_config.unwrap()) {
Ok(v) => v,
Err(e) => panic!("Failed to parse config!\n{}", e),
};
// Very basic validation
const TOP_LEVEL_KEYS: [&str; 2] = ["authentication", "theme"];
let themes: JsonValue = json::object! {default: "neutral", blue: "gray"};
for (key, _) in config.entries() {
if !TOP_LEVEL_KEYS.contains(&key) {
error!("Config contains invalid key {}", key)
}
}
if config.has_key("authentication") && !config["authentication"].is_object() {
error!("\"{}\" must be an object", "authentication")
}
for (registry, token) in config["authentication"].entries() {
if !token.is_string() {
error!(
"Invalid token {} for registry {}. Must be a string",
token, registry
)
}
}
if !themes.has_key(&config["theme"].to_string()) {
error!(
"Invalid theme {}. Available themes are {:#?}",
config["theme"],
themes.entries().map(|(k, _)| k).collect::<Vec<&str>>()
)
}
return Config {
authentication: HashMap::new(),
theme: themes[config["theme"].to_string()].to_string(),
};
}
pub struct Config {
pub authentication: HashMap<String, String>,
pub theme: String,
}

View File

@@ -7,5 +7,11 @@ module.exports = {
extend: {},
},
plugins: [],
safelist: [
{
pattern: /(bg|text|divide)-(gray|neutral)-.+/,
variants: ["dark"]
}
]
}