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

15 Commits

Author SHA1 Message Date
Sergio
5867cb375f Bump version
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
2024-09-24 17:20:34 +03:00
Sergio
65b2bece03 fix issue #30 with raw returning invalid json 2024-09-24 17:19:59 +03:00
Sergio
6b15d8dfad Docs changes
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2024-09-17 17:00:31 +03:00
Sergio
5bf7269aca Better logging on server
Some checks are pending
CI / build-binary (push) Waiting to run
CI / build-image (push) Waiting to run
2024-09-16 17:42:02 +03:00
Sergio
0136850200 Fix CSS bug where statistics borders are invisible in static mode 2024-09-16 17:39:44 +03:00
Sergio
2afce016f3 Update README 2024-09-16 17:30:05 +03:00
Sergio
bc06c06cac Bump version and update README
Some checks are pending
CI / build-binary (push) Waiting to run
CI / build-image (push) Waiting to run
2024-09-15 20:26:02 +03:00
Sergio
bc86364e68 Fixed OpenSSL build errors on Alpine and changed the logging a bit 2024-09-15 20:21:13 +03:00
Sergio
663ca64cd7 Enable native-tls feature on reqwest so we can support alpine 2024-09-15 19:29:14 +03:00
Sergio
330b70752e Improve logging 2024-09-15 19:14:20 +03:00
Sergio
0c9ad61a4d Removed all threading and switched everything to async. >2x speedup 🚀 2024-09-15 18:47:00 +03:00
Sergio
38bf187a4a Update docs with community requested changes and bump version
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2024-09-09 11:42:04 +03:00
Sergio
2c120ffaff Added search 2024-09-09 11:18:28 +03:00
Sergio
572ca8858a Added tooltips, centralized theme declaration, fixed some eslint errors
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2024-09-07 18:57:57 +03:00
Sergio
2c4f2a1e05 Fix broken links in docs 2024-09-07 18:07:32 +03:00
38 changed files with 1201 additions and 429 deletions

View File

@@ -1 +0,0 @@
rust 1.79.0

51
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,51 @@
# Contributing
First of all, thanks for taking time to contribute to Cup! This guide will help you set up a development environment and make your first contribution.
## Setting up a development environment
Requirements:
- A computer running Linux
- Rust (usually installed from https://rustup.rs/)
- Node.js 22+ and Bun 1+
1. Fork the repository. This is where you'll be pushing your changes before you create a pull request. Make sure to _create a new branch_ for your changes.
2. Clone your fork with `git clone https://github.com/<YOUR_USERNAME>/cup` (if you use SSH, `git clone git@github.com:<YOUR_USERNAME>/cup`) and open your editor
3. Switch to your newly created branch (e.g. if your branch is called `improve-logging`, run `git checkout improve-logging`)
4. Run `bun install` in `web/` and `./build.sh` to set up the frontend
You're ready to go!
## Project architecture
Cup can be run in 2 modes: CLI and server.
All CLI specific functionality is located in `src/formatting.rs` and some other files in functions prefixed with `#[cfg(feature = "cli")]`.
All server specific functionality is located in `src/server.rs` and `web/`.
## Important notes
- When making any changes, always make sure to write optimize your code for:
+ Performance: You should always benchmark Cup before making changes and after your changes to make sure there is none (or a very small) difference in time. Profiling old and new code is also good.
+ Readability: Include comments describing any new functions you create, give descriptive names to variables and when making a design decision or a compromise, ALWAYS include a comment explaining what you did and why.
- If you plan on developing the frontend without making backend changes, it is highly recommended to run `cup serve` in the background and start the frontend in development mode from `web/` with `bun dev`.
- If you make changes to the frontend, always remember to prefix your build command with the `build.sh` script which takes care of rebuilding the frontend. For example: `./build.sh cargo build -r`
- When adding new features to Cup (e.g. configuration options), make sure to update the documentation (located in `docs/`). Refer to other pages in the documentation, or to the [official docs](https://nextra.site) for any questions you may have. The docs use `pnpm` as their package manager.
- If you need help with finishing something (e.g. you've made some commits and need help with writing docs, you want some feedback about a design decision, etc.), you can open a draft PR and ask for help there.
## Submitting a PR
To have your changes included in Cup, you will need to create a pull request.
Before doing so, please make sure you have run `cargo clippy` and resolved all warnings related to your changes and have formatted your code with `cargo fmt`. This ensures Cup's codebase is consistent and uses good practices for code.
After you're done with that, commit your changes and push them to your branch.
Next, open your fork on Github and create a pull request. Make sure to include the changes you made, which issues it addresses (if any) and any other info you think is important.
Happy contributing!

574
Cargo.lock generated
View File

@@ -43,9 +43,9 @@ dependencies = [
[[package]]
name = "anstream"
version = "0.6.14"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -64,38 +64,55 @@ checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
[[package]]
name = "anstyle-parse"
version = "0.2.4"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391"
checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.3"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
dependencies = [
"anstyle",
"windows-sys 0.52.0",
]
[[package]]
name = "anyhow"
version = "1.0.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6"
[[package]]
name = "anymap2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
[[package]]
name = "async-trait"
version = "0.1.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atty"
version = "0.2.14"
@@ -128,18 +145,18 @@ dependencies = [
"rustc-demangle",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -155,7 +172,7 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aed08d3adb6ebe0eff737115056652670ae290f177759aac19c30456135f94c"
dependencies = [
"base64 0.22.1",
"base64",
"bollard-stubs",
"bytes",
"futures-core",
@@ -199,6 +216,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.6.0"
@@ -272,9 +295,9 @@ checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
[[package]]
name = "colorchoice"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
[[package]]
name = "console"
@@ -304,40 +327,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -350,21 +339,23 @@ dependencies = [
[[package]]
name = "cup"
version = "2.2.1"
version = "2.3.1"
dependencies = [
"bollard",
"chrono",
"clap",
"futures",
"http-auth",
"indicatif",
"json",
"liquid",
"once_cell",
"rayon",
"regex",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"termsize",
"tokio",
"ureq",
"xitca-web",
]
@@ -412,16 +403,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "flate2"
version = "1.0.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -437,6 +418,21 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.30"
@@ -444,6 +440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -452,6 +449,23 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
[[package]]
name = "futures-executor"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
[[package]]
name = "futures-macro"
version = "0.3.30"
@@ -481,9 +495,13 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
@@ -506,8 +524,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -572,13 +592,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "643c9bbf6a4ea8a656d6b4cd53d34f79e3f841ad5203c1a55fb7d761923bc255"
dependencies = [
"base64 0.21.7",
"digest",
"hex",
"md-5",
"memchr",
"rand",
"sha2",
]
[[package]]
@@ -650,6 +664,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "hyper-rustls"
version = "0.27.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333"
dependencies = [
"futures-util",
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.6"
@@ -760,13 +792,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.0"
name = "ipnet"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
@@ -877,28 +918,34 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "lock_api"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.7.4"
@@ -981,6 +1028,31 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "parking_lot"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
dependencies = [
"cfg-if",
"instant",
"libc",
"redox_syscall",
"smallvec",
"winapi",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -1078,9 +1150,12 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
@@ -1091,6 +1166,54 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684"
dependencies = [
"bytes",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "quinn-proto"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6"
dependencies = [
"bytes",
"rand",
"ring",
"rustc-hash",
"rustls",
"slab",
"thiserror",
"tinyvec",
"tracing",
]
[[package]]
name = "quinn-udp"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285"
dependencies = [
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.36"
@@ -1131,23 +1254,12 @@ dependencies = [
]
[[package]]
name = "rayon"
version = "1.10.0"
name = "redox_syscall"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
"bitflags",
]
[[package]]
@@ -1179,6 +1291,93 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
name = "reqwest"
version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"windows-registry",
]
[[package]]
name = "reqwest-middleware"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562ceb5a604d3f7c885a792d42c199fd8af239d0a51b2fa6a78aafa092452b04"
dependencies = [
"anyhow",
"async-trait",
"http",
"reqwest",
"serde",
"thiserror",
"tower-service",
]
[[package]]
name = "reqwest-retry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a83df1aaec00176d0fabb65dea13f832d2a446ca99107afc17c5d2d4981221d0"
dependencies = [
"anyhow",
"async-trait",
"futures",
"getrandom",
"http",
"hyper",
"parking_lot",
"reqwest",
"reqwest-middleware",
"retry-policies",
"tokio",
"tracing",
"wasm-timer",
]
[[package]]
name = "retry-policies"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c"
dependencies = [
"rand",
]
[[package]]
name = "ring"
version = "0.17.8"
@@ -1201,12 +1400,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustls"
version = "0.23.10"
name = "rustc-hash"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402"
checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152"
[[package]]
name = "rustls"
version = "0.23.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -1216,16 +1420,26 @@ dependencies = [
]
[[package]]
name = "rustls-pki-types"
version = "1.7.0"
name = "rustls-pemfile"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d"
checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425"
dependencies = [
"base64",
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0"
[[package]]
name = "rustls-webpki"
version = "0.102.5"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9a6fccd794a42c2c105b513a2f62bc3fd8f3ba57a4593677ceb0bd035164d78"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
@@ -1238,6 +1452,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.204"
@@ -1298,7 +1518,7 @@ version = "3.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e73139bc5ec2d45e6c5fd85be5a46949c1c39a4c18e56915f5eb4c12f975e377"
dependencies = [
"base64 0.22.1",
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
@@ -1398,6 +1618,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
dependencies = [
"futures-core",
]
[[package]]
name = "termsize"
version = "0.1.8"
@@ -1514,6 +1743,17 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
dependencies = [
"rustls",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.11"
@@ -1652,9 +1892,9 @@ dependencies = [
[[package]]
name = "unicode-segmentation"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
@@ -1668,22 +1908,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72139d247e5f97a3eff96229a7ae85ead5328a39efe76f8bf5a06313d505b6ea"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots",
]
[[package]]
name = "url"
version = "2.5.2"
@@ -1753,6 +1977,18 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
@@ -1783,10 +2019,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "webpki-roots"
version = "0.26.3"
name = "wasm-timer"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd"
checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f"
dependencies = [
"futures",
"js-sys",
"parking_lot",
"pin-utils",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bd24728e5af82c6c4ec1b66ac4844bdf8156257fccda846ec58b42cd0cdbe6a"
dependencies = [
"rustls-pki-types",
]
@@ -1822,6 +2083,36 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-registry"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0"
dependencies = [
"windows-result",
"windows-strings",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -2048,6 +2339,27 @@ dependencies = [
"xitca-unsafe-collection",
]
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zeroize"
version = "1.8.1"

View File

@@ -1,23 +1,25 @@
[package]
name = "cup"
version = "2.2.1"
version = "2.3.1"
edition = "2021"
[dependencies]
clap = { version = "4.5.7", features = ["derive"] }
indicatif = { version = "0.17.8", optional = true }
tokio = {version = "1.38.0", features = ["rt", "rt-multi-thread", "macros"]}
ureq = { version = "2.9.7", features = ["tls"] }
rayon = "1.10.0"
tokio = {version = "1.38.0", features = ["macros"]}
xitca-web = { version = "0.5.0", optional = true, features = ["logger"] }
liquid = { version = "0.26.6", optional = true }
bollard = "0.16.1"
once_cell = "1.19.0"
http-auth = { version = "0.1.9", features = [] }
http-auth = { version = "0.1.9", default-features = false, features = [] }
termsize = { version = "0.1.8", optional = true }
regex = "1.10.5"
chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true }
json = "0.12.4"
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls"] }
futures = "0.3.30"
reqwest-retry = "0.6.1"
reqwest-middleware = "0.3.3"
[features]
default = ["server", "cli"]

View File

@@ -4,6 +4,8 @@ Cup is the easiest way to check for container image updates.
![Demo](screenshots/cup.gif)
_If you like this project and/or use Cup, please consider starring the project ⭐. It motivates me to continue working on it and imrpoving it. Plus, you get updates for new releases!_
## Screenshots
![Cup web in light mode](screenshots/web_light.png)
@@ -11,11 +13,11 @@ Cup is the easiest way to check for container image updates.
## Features
- Extremely fast. Cup takes full advantage of your CPU and is hightly optimized, resulting in lightning fast speed. On my test machine, it took ~6 seconds for 70 images.
- Extremely fast. Cup takes full advantage of your CPU and is hightly optimized, resulting in lightning fast speed. On my test machine, it took ~12 seconds for ~95 images.
- Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives)
- Doesn't exhaust any rate limits. This is the original reason I created Cup. It was inspired by [What's up docker?](https://github.com/fmartinou/whats-up-docker) which would always use it up.
- Beautiful CLI and web interface for checking on your containers any time.
- The binary is tiny! At the time of writing it's just 4.7 MB. No more pulling 100+ MB docker images for a such a simple program.
- The binary is tiny! At the time of writing it's just 5.1 MB. No more pulling 100+ MB docker images for a such a simple program.
- JSON output for both the CLI and web interface so you can connect Cup to integrations. It's easy to parse and makes webhooks and pretty dashboards simple to set up!
## Documentation
@@ -44,7 +46,7 @@ Here are some ideas to get you started:
- Help optimize Cup and make it even better!
- Add more features to the web UI
To contribute, fork the repository, make your changes and the submit a pull request.
For more information, check the [docs](https://sergi0g.github.io/cup/docs/contributing)!
## Support

View File

@@ -1,7 +1,13 @@
#!/bin/sh
# Exit on error
set -e
# This is kind of like a shim that makes sure the frontend is rebuilt when running a build. For example you can run `./build.sh cargo build --release`
# Remove old files
rm -rf src/static
# Frontend
cd web/

View File

@@ -1 +1 @@
nodejs 21.6.2
nodejs 22.8.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -16,5 +16,6 @@
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.5"
}
}
},
"packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c"
}

View File

@@ -11,7 +11,13 @@
"usage": {
"title": "Usage"
},
"community-resources": {
"title": "Community Resources"
},
"nightly": {
"title": "Using the latest version"
},
"contributing": {
"title": "Contributing"
}
}

View File

@@ -0,0 +1,23 @@
# Docker Compose
Many users find it useful to run Cup with Docker Compose, as it enables them to have it constantly running in the background and easily control it. Cup's lightweight resource usae makes it ideal for this use case.
There have been requests for an official Docker Compose file, but I believe you should customize it to your needs.
Here is an example of what I would use (by [@ioverho](https://github.com/ioverho)):
```yaml
services:
cup:
image: ghcr.io/sergi0g/cup:latest
container_name: cup # Optional
restart: unless-stopped
command: -c /config/cup.json serve
ports:
- 8000:8000
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./cup.json:/config/cup.json
```
This can be customized further of course, if you choose to use a different port, another config location, or would like to change something else. Have fun!

View File

@@ -0,0 +1,75 @@
import Image from 'next/image';
import widget1 from '../../../assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png'
import widget2 from '../../../assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg'
# Homepage Widget
Some users have asked for a homepage widget.
## Docker Compose with the widget configured via labels:
```yaml
services:
cup:
image: ghcr.io/sergi0g/cup
container_name: cup
command: -c /config/cup.json serve -p 8000
volumes:
- ./config/cup.json:/config/cup.json
- /var/run/docker.sock:/var/run/docker.sock
ports:
- 8000:8000
restart: unless-stopped
labels:
homepage.group: Network
homepage.name: Cup
homepage.icon: /icons/cup-with-straw.png
homepage.href: http://myserver:8000
homepage.ping: http://myserver:8000
homepage.description: Checks for container updates
homepage.widget.type: customapi
homepage.widget.url: http://myserver:8000/json
homepage.widget.mappings[0].label: Monitoring
homepage.widget.mappings[0].field.metrics: monitored_images
homepage.widget.mappings[0].format: number
homepage.widget.mappings[1].label: Up to date
homepage.widget.mappings[1].field.metrics: up_to_date
homepage.widget.mappings[1].format: number
homepage.widget.mappings[2].label: Updates
homepage.widget.mappings[2].field.metrics: update_available
homepage.widget.mappings[2].format: number
```
Preview:
<Image src={widget1}/>
Credit: [@agrmohit](https://github.com/agrmohit)
## Widget in Homepage's config file format:
```yaml
widget:
type: customapi
url: http://<SERVER_IP>:9000/json
refreshInterval: 10000
method: GET
mappings:
- field:
metrics: monitored_images
label: Monitored images
format: number
- field:
metrics: up_to_date
label: Up to date
format: number
- field:
metrics: update_available
label: Available updates
format: number
- field:
metrics: unknown
label: Unknown
format: number
```
Preview:
<Image src={widget2}/>
Credit: [@remussamoila](https://github.com/remussamoila)

View File

@@ -0,0 +1,51 @@
# Contributing
First of all, thanks for taking time to contribute to Cup! This guide will help you set up a development environment and make your first contribution.
## Setting up a development environment
Requirements:
- A computer running Linux
- Rust (usually installed from https://rustup.rs/)
- Node.js 22+ and Bun 1+
1. Fork the repository. This is where you'll be pushing your changes before you create a pull request. Make sure to _create a new branch_ for your changes.
2. Clone your fork with `git clone https://github.com/<YOUR_USERNAME>/cup` (if you use SSH, `git clone git@github.com:<YOUR_USERNAME>/cup`) and open your editor
3. Switch to your newly created branch (e.g. if your branch is called `improve-logging`, run `git checkout improve-logging`)
4. Run `bun install` in `web/` and `./build.sh` to set up the frontend
You're ready to go!
## Project architecture
Cup can be run in 2 modes: CLI and server.
All CLI specific functionality is located in `src/formatting.rs` and some other files in functions prefixed with `#[cfg(feature = "cli")]`.
All server specific functionality is located in `src/server.rs` and `web/`.
## Important notes
- When making any changes, always make sure to write optimize your code for:
+ Performance: You should always benchmark Cup before making changes and after your changes to make sure there is none (or a very small) difference in time. Profiling old and new code is also good.
+ Readability: Include comments describing any new functions you create, give descriptive names to variables and when making a design decision or a compromise, ALWAYS include a comment explaining what you did and why.
- If you plan on developing the frontend without making backend changes, it is highly recommended to run `cup serve` in the background and start the frontend in development mode from `web/` with `bun dev`.
- If you make changes to the frontend, always remember to prefix your build command with the `build.sh` script which takes care of rebuilding the frontend. For example: `./build.sh cargo build -r`
- When adding new features to Cup (e.g. configuration options), make sure to update the documentation (located in `docs/`). Refer to other pages in the documentation, or to the [official docs](https://nextra.site) for any questions you may have. The docs use `pnpm` as their package manager.
- If you need help with finishing something (e.g. you've made some commits and need help with writing docs, you want some feedback about a design decision, etc.), you can open a draft PR and ask for help there.
## Submitting a PR
To have your changes included in Cup, you will need to create a pull request.
Before doing so, please make sure you have run `cargo clippy` and resolved all warnings related to your changes and have formatted your code with `cargo fmt`. This ensures Cup's codebase is consistent and uses good practices for code.
After you're done with that, commit your changes and push them to your branch.
Next, open your fork on Github and create a pull request. Make sure to include the changes you made, which issues it addresses (if any) and any other info you think is important.
Happy contributing!

View File

@@ -16,7 +16,7 @@ export default {
const { asPath } = useRouter()
const { frontMatter } = useConfig()
const url =
'https://sergi0g.github.io/cup' +
'https://sergi0g.github.io/cup/docs/' +
(`/${asPath}`);
return (
@@ -36,9 +36,9 @@ export default {
<h1 className="font-bold ml-2">Cup</h1>
</div>
),
logoLink: "sergi0g.github.io/cup",
logoLink: "https://sergi0g.github.io/cup/docs/",
project: {
link: "https://github.com/sergi0g/cup",
link: "https://github.com/sergi0g/cup/",
},
navbar: {
extraContent: <ThemeSwitch lite className="[&_span]:hidden" />,

View File

@@ -1,16 +1,11 @@
use std::{
collections::{HashMap, HashSet},
sync::Mutex,
};
use json::JsonValue;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use std::collections::{HashMap, HashSet};
use crate::{
debug,
docker::get_images_from_docker_daemon,
image::Image,
registry::{check_auth, get_latest_digests, get_token},
utils::unsplit_image,
utils::{new_reqwest_client, unsplit_image, CliConfig},
};
#[cfg(feature = "cli")]
@@ -33,70 +28,76 @@ where
}
}
pub async fn get_all_updates(
socket: Option<String>,
config: &JsonValue,
) -> 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();
pub async fn get_all_updates(options: &CliConfig) -> Vec<(String, Option<bool>)> {
let local_images = get_images_from_docker_daemon(options).await;
let mut image_map: HashMap<String, Option<String>> = HashMap::with_capacity(local_images.len());
for image in &local_images {
let img = unsplit_image(image);
image_map.insert(img, image.digest.clone());
}
let mut registries: Vec<&String> = local_images.iter().map(|image| &image.registry).collect();
registries.unique();
let mut remote_images: Vec<Image> = Vec::new();
let mut remote_images: Vec<Image> = Vec::with_capacity(local_images.len());
let client = new_reqwest_client();
for registry in registries {
if options.verbose {
debug!("Checking images from registry {}", registry)
}
let images: Vec<&Image> = local_images
.par_iter()
.iter()
.filter(|image| &image.registry == registry)
.collect();
let credentials = config["authentication"][registry]
let credentials = options.config["authentication"][registry]
.clone()
.take_string()
.or(None);
let mut latest_images = match check_auth(registry, config) {
let mut latest_images = match check_auth(registry, options, &client).await {
Some(auth_url) => {
let token = get_token(images.clone(), &auth_url, &credentials);
get_latest_digests(images, Some(&token), config)
let token = get_token(images.clone(), &auth_url, &credentials, &client).await;
if options.verbose {
debug!("Using token {}", token);
}
get_latest_digests(images, Some(&token), options, &client).await
}
None => get_latest_digests(images, None, config),
None => get_latest_digests(images, None, options, &client).await,
};
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);
if options.verbose {
debug!("Collecting results")
}
let mut result: Vec<(String, Option<bool>)> = Vec::new();
remote_images.iter().for_each(|image| {
let img = unsplit_image(image);
match &image.digest {
Some(d) => {
let r = d != image_map.get(&img).unwrap().as_ref().unwrap();
result_mutex.lock().unwrap().push((img, Some(r)))
result.push((img, Some(r)))
}
None => result_mutex.lock().unwrap().push((img, None)),
None => result.push((img, None)),
}
});
let result = result_mutex.lock().unwrap().clone();
result
}
#[cfg(feature = "cli")]
pub async fn get_update(image: &str, socket: Option<String>, config: &JsonValue) -> Option<bool> {
let local_image = get_image_from_docker_daemon(socket, image).await;
let credentials = config["authentication"][&local_image.registry]
pub async fn get_update(image: &str, options: &CliConfig) -> Option<bool> {
let local_image = get_image_from_docker_daemon(options.socket.clone(), image).await;
let credentials = options.config["authentication"][&local_image.registry]
.clone()
.take_string()
.or(None);
let token = match check_auth(&local_image.registry, config) {
Some(auth_url) => get_token(vec![&local_image], &auth_url, &credentials),
let client = new_reqwest_client();
let token = match check_auth(&local_image.registry, options, &client).await {
Some(auth_url) => get_token(vec![&local_image], &auth_url, &credentials, &client).await,
None => String::new(),
};
if options.verbose {
debug!("Using token {}", token);
};
let remote_image = match token.as_str() {
"" => get_latest_digest(&local_image, None, config),
_ => get_latest_digest(&local_image, Some(&token), config),
"" => get_latest_digest(&local_image, None, options, &client).await,
_ => get_latest_digest(&local_image, Some(&token), options, &client).await,
};
match &remote_image.digest {
Some(d) => Some(d != &local_image.digest.unwrap()),

View File

@@ -2,8 +2,13 @@ use bollard::{secret::ImageSummary, ClientVersion, Docker};
#[cfg(feature = "cli")]
use bollard::secret::ImageInspect;
use futures::future::join_all;
use crate::{error, image::Image, utils::split_image};
use crate::{
error,
image::Image,
utils::{split_image, CliConfig},
};
fn create_docker_client(socket: Option<String>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket {
@@ -24,35 +29,24 @@ fn create_docker_client(socket: Option<String>) -> Docker {
}
}
pub async fn get_images_from_docker_daemon(socket: Option<String>) -> Vec<Image> {
let client: Docker = create_docker_client(socket);
pub async fn get_images_from_docker_daemon(options: &CliConfig) -> Vec<Image> {
let client: Docker = create_docker_client(options.socket.clone());
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();
let mut handles = Vec::new();
for image in images {
if !image.repo_tags.is_empty() && !image.repo_digests.is_empty() {
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(),
),
});
}
}
handles.push(Image::from(image, options))
}
result
join_all(handles)
.await
.iter()
.filter(|img| img.is_some())
.map(|img| img.clone().unwrap())
.collect()
}
#[cfg(feature = "cli")]

View File

@@ -1,3 +1,10 @@
use bollard::secret::ImageSummary;
use crate::{
debug,
utils::{split_image, CliConfig},
};
#[derive(Clone, Debug)]
pub struct Image {
pub registry: String,
@@ -5,3 +12,30 @@ pub struct Image {
pub tag: String,
pub digest: Option<String>,
}
impl Image {
pub async fn from(image: ImageSummary, options: &CliConfig) -> Option<Self> {
if !image.repo_tags.is_empty() && !image.repo_digests.is_empty() {
let (registry, repository, tag) = split_image(&image.repo_tags[0]);
let image = Image {
registry,
repository,
tag,
digest: Some(
image.repo_digests[0]
.clone()
.split('@')
.collect::<Vec<&str>>()[1]
.to_string(),
),
};
return Some(image);
} else if options.verbose {
debug!(
"Skipped an image\nTags: {:#?}\nDigests: {:#?}",
image.repo_tags, image.repo_digests
)
}
None
}
}

View File

@@ -1,12 +1,13 @@
#[cfg(feature = "cli")]
use check::{get_all_updates, get_update};
use chrono::Local;
use clap::{Parser, Subcommand};
#[cfg(feature = "cli")]
use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner};
#[cfg(feature = "server")]
use server::serve;
use std::path::PathBuf;
use utils::load_config;
use utils::{load_config, CliConfig};
pub mod check;
pub mod docker;
@@ -25,6 +26,13 @@ struct Cli {
socket: Option<String>,
#[arg(short, long, default_value_t = String::new(), help = "Config file path")]
config_path: String,
#[arg(
short,
long,
default_value_t = false,
help = "Enable verbose (debug) logging"
)]
verbose: bool,
#[command(subcommand)]
command: Option<Commands>,
}
@@ -64,32 +72,50 @@ async fn main() {
"" => None,
path => Some(PathBuf::from(path)),
};
let config = load_config(cfg_path);
if cli.verbose {
debug!("CLI options:");
debug!("Config path: {:?}", cfg_path);
debug!("Socket: {:?}", &cli.socket)
}
let cli_config = CliConfig {
socket: cli.socket,
verbose: cli.verbose,
config: load_config(cfg_path),
};
if cli.verbose {
debug!("Config: {}", cli_config.config)
}
match &cli.command {
#[cfg(feature = "cli")]
Some(Commands::Check { image, icons, raw }) => match image {
Some(name) => {
let has_update = get_update(name, cli.socket, &config).await;
let has_update = get_update(name, &cli_config).await;
match raw {
true => print_raw_update(name, &has_update),
false => print_update(name, &has_update),
};
}
None => {
let start = Local::now().timestamp_millis();
match raw {
true => print_raw_updates(&get_all_updates(cli.socket, &config).await),
true => {
let updates = get_all_updates(&cli_config).await;
print_raw_updates(&updates);
}
false => {
let spinner = Spinner::new();
let updates = get_all_updates(cli.socket, &config).await;
let updates = get_all_updates(&cli_config).await;
spinner.succeed();
let end = Local::now().timestamp_millis();
print_updates(&updates, icons);
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
}
};
}
},
#[cfg(feature = "server")]
Some(Commands::Serve { port }) => {
let _ = serve(port, cli.socket, config).await;
let _ = serve(port, &cli_config).await;
}
None => (),
}

View File

@@ -1,160 +1,168 @@
use std::sync::Mutex;
use futures::future::join_all;
use json::JsonValue;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use ureq::{Error, ErrorKind};
use http_auth::parse_challenges;
use reqwest_middleware::ClientWithMiddleware;
use crate::{error, image::Image, warn};
use crate::{debug, error, image::Image, utils::CliConfig, warn};
pub fn check_auth(registry: &str, config: &JsonValue) -> Option<String> {
let protocol = if config["insecure_registries"].contains(registry) {
pub async fn check_auth(
registry: &str,
options: &CliConfig,
client: &ClientWithMiddleware,
) -> Option<String> {
let protocol = if options.config["insecure_registries"].contains(registry) {
if options.verbose {
debug!(
"{} is configured as an insecure registry. Downgrading to HTTP",
registry
);
};
"http"
} else {
"https"
};
let response = ureq::get(&format!("{}://{}/v2/", protocol, registry)).call();
let response = client
.get(format!("{}://{}/v2/", protocol, registry))
.send()
.await;
match response {
Ok(_) => None,
Err(Error::Status(401, response)) => match response.header("www-authenticate") {
Some(challenge) => Some(parse_www_authenticate(challenge)),
None => error!(
"Unauthorized to access registry {} and no way to authenticate was provided",
registry
),
},
Err(Error::Transport(error)) => {
match error.kind() {
ErrorKind::Dns => {
warn!("Failed to lookup the IP of the registry, retrying.");
return check_auth(registry, config);
} // If something goes really wrong, this can get stuck in a loop
ErrorKind::ConnectionFailed => {
warn!("Connection probably timed out, retrying.");
return check_auth(registry, config);
} // Same here
_ => error!("{}", error),
Ok(r) => {
let status = r.status().as_u16();
if status == 401 {
match r.headers().get("www-authenticate") {
Some(challenge) => Some(parse_www_authenticate(challenge.to_str().unwrap())),
None => error!(
"Unauthorized to access registry {} and no way to authenticate was provided",
registry
),
}
} else if status == 200 {
None
} else {
warn!(
"Received unexpected status code {}\nResponse: {}",
status,
r.text().await.unwrap()
);
None
}
}
Err(e) => {
if e.is_connect() {
warn!("Connection to registry {} failed.", &registry);
None
} else {
error!("Unexpected error: {}", e.to_string())
}
}
Err(e) => error!("{}", e),
}
}
pub fn get_latest_digest(image: &Image, token: Option<&String>, config: &JsonValue) -> Image {
let protocol =
if config["insecure_registries"].contains(json::JsonValue::from(image.registry.clone())) {
"http"
} else {
"https"
};
let mut request = ureq::head(&format!(
pub async fn get_latest_digest(
image: &Image,
token: Option<&String>,
options: &CliConfig,
client: &ClientWithMiddleware,
) -> Image {
let protocol = if options.config["insecure_registries"]
.contains(json::JsonValue::from(image.registry.clone()))
{
"http"
} else {
"https"
};
let mut request = client.head(format!(
"{}://{}/v2/{}/manifests/{}",
protocol, &image.registry, &image.repository, &image.tag
));
if let Some(t) = token {
request = request.set("Authorization", &format!("Bearer {}", t));
request = request.header("Authorization", &format!("Bearer {}", t));
}
let raw_response = match request
.set("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")
.call()
.header("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")
.send().await
{
Ok(response) => response,
Err(Error::Status(401, response)) => {
if token.is_some() {
warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry, token.unwrap());
Ok(response) => {
let status = response.status();
if status == 401 {
if token.is_some() {
warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry, token.unwrap());
} else {
warn!("Registry requires authentication");
}
return Image { digest: None, ..image.clone() }
} else if status == 404 {
warn!("Image {:?} not found", &image);
return Image { digest: None, ..image.clone() }
} else {
return get_latest_digest(
image,
Some(&get_token(
vec![image],
&parse_www_authenticate(response.header("www-authenticate").unwrap()),
&None // I think?
)),
config
);
}
}
Err(Error::Status(_, _)) => {
return Image {
digest: None,
..image.clone()
response
}
},
Err(Error::Transport(error)) => {
match error.kind() {
ErrorKind::Dns => {
warn!("Failed to lookup the IP of the registry, retrying.");
return get_latest_digest(image, token, config)
}, // If something goes really wrong, this can get stuck in a loop
ErrorKind::ConnectionFailed => {
warn!("Connection probably timed out, retrying.");
return get_latest_digest(image, token, config)
}, // Same here
_ => error!("Failed to retrieve image digest\n{}!", error)
Err(e) => {
if e.is_connect() {
warn!("Connection to registry failed.");
return Image { digest: None, ..image.clone() }
} else {
error!("Unexpected error: {}", e.to_string())
}
},
};
match raw_response.header("docker-content-digest") {
match raw_response.headers().get("docker-content-digest") {
Some(digest) => Image {
digest: Some(digest.to_string()),
digest: Some(digest.to_str().unwrap().to_string()),
..image.clone()
},
None => error!("Server returned invalid response! No docker-content-digest!"),
None => error!(
"Server returned invalid response! No docker-content-digest!\n{:#?}",
raw_response
),
}
}
pub fn get_latest_digests(
pub async fn get_latest_digests(
images: Vec<&Image>,
token: Option<&String>,
config: &JsonValue,
options: &CliConfig,
client: &ClientWithMiddleware,
) -> Vec<Image> {
let result: Mutex<Vec<Image>> = Mutex::new(Vec::new());
images.par_iter().for_each(|&image| {
let digest = get_latest_digest(image, token, config).digest;
result.lock().unwrap().push(Image {
digest,
..image.clone()
});
});
let r = result.lock().unwrap().clone();
r
let mut handles = Vec::new();
for image in images {
handles.push(get_latest_digest(image, token, options, client))
}
join_all(handles).await
}
pub fn get_token(images: Vec<&Image>, auth_url: &str, credentials: &Option<String>) -> String {
pub async fn get_token(
images: Vec<&Image>,
auth_url: &str,
credentials: &Option<String>,
client: &ClientWithMiddleware,
) -> String {
let mut final_url = auth_url.to_owned();
for image in &images {
final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository);
}
let mut base_request =
ureq::get(&final_url).set("Accept", "application/vnd.oci.image.index.v1+json"); // Seems to be unnecesarry. Will probably remove in the future
let mut base_request = client
.get(&final_url)
.header("Accept", "application/vnd.oci.image.index.v1+json"); // Seems to be unnecessary. Will probably remove in the future
base_request = match credentials {
Some(creds) => base_request.set("Authorization", &format!("Basic {}", creds)),
Some(creds) => base_request.header("Authorization", &format!("Basic {}", creds)),
None => base_request,
};
let raw_response = match base_request.call() {
Ok(response) => match response.into_string() {
let raw_response = match base_request.send().await {
Ok(response) => match response.text().await {
Ok(res) => res,
Err(e) => {
error!("Failed to parse response into string!\n{}", e)
}
},
Err(Error::Transport(error)) => {
match error.kind() {
ErrorKind::Dns => {
warn!("Failed to lookup the IP of the registry, retrying.");
return get_token(images, auth_url, credentials);
} // If something goes really wrong, this can get stuck in a loop
ErrorKind::ConnectionFailed => {
warn!("Connection probably timed out, retrying.");
return get_token(images, auth_url, credentials);
} // Same here
_ => error!("Token request failed\n{}!", error),
}
}
Err(e) => {
error!("Token request failed!\n{}", e)
if e.is_connect() {
error!("Connection to registry failed.");
} else {
error!("Token request failed!\n{}", e.to_string())
}
}
};
let parsed_token_response: JsonValue = match json::parse(&raw_response) {

View File

@@ -15,8 +15,8 @@ use xitca_web::{
use crate::{
check::get_all_updates,
error,
utils::{sort_update_vec, to_json},
error, info,
utils::{sort_update_vec, to_json, CliConfig},
};
const HTML: &str = include_str!("static/index.html");
@@ -26,9 +26,10 @@ 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>, config: JsonValue) -> std::io::Result<()> {
println!("Starting server, please wait...");
let data = ServerData::new(socket, config).await;
pub async fn serve(port: &u16, options: &CliConfig) -> std::io::Result<()> {
info!("Starting server, please wait...");
let data = ServerData::new(options).await;
info!("Ready to start!");
App::new()
.with_state(Arc::new(Mutex::new(data)))
.at("/", get(handler_service(_static)))
@@ -93,31 +94,33 @@ struct ServerData {
template: String,
raw_updates: Vec<(String, Option<bool>)>,
json: JsonValue,
socket: Option<String>,
config: JsonValue,
options: CliConfig,
theme: &'static str,
}
impl ServerData {
async fn new(socket: Option<String>, config: JsonValue) -> Self {
async fn new(options: &CliConfig) -> Self {
let mut s = Self {
socket,
options: options.clone(),
template: String::new(),
json: json::object! {
metrics: json::object! {},
images: json::object! {},
},
raw_updates: Vec::new(),
config,
theme: "neutral",
};
s.refresh().await;
s
}
async fn refresh(&mut self) {
let updates = sort_update_vec(
&get_all_updates(self.socket.clone(), &self.config["authentication"]).await,
);
let start = Local::now().timestamp_millis();
if !self.raw_updates.is_empty() {
info!("Refreshing data");
}
let updates = sort_update_vec(&get_all_updates(&self.options).await);
let end = Local::now().timestamp_millis();
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
self.raw_updates = updates;
let template = liquid::ParserBuilder::with_stdlib()
.build()
@@ -134,8 +137,11 @@ impl ServerData {
.collect::<Vec<Object>>();
self.json = to_json(&self.raw_updates);
let last_updated = Local::now();
self.json["last_updated"] = last_updated.to_rfc3339_opts(chrono::SecondsFormat::Secs, true).to_string().into();
self.theme = match &self.config["theme"].as_str() {
self.json["last_updated"] = last_updated
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string()
.into();
self.theme = match &self.options.config["theme"].as_str() {
Some(t) => match *t {
"default" => "neutral",
"blue" => "gray",

View File

@@ -1,34 +1,21 @@
use std::path::PathBuf;
use crate::{error, image::Image};
use json::{object, JsonValue};
use once_cell::sync::Lazy;
use regex::Regex;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
/// 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)*) => ({
eprintln!($($arg)*);
std::process::exit(1);
})
}
// A small macro to print in yellow as a warning
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => ({
eprintln!("\x1b[93m{}\x1b[0m", format!($($arg)*));
})
}
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"^(?P<name>(?:(?P<registry>(?:(?:localhost|[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)/)?(?P<repository>[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*))(?::(?P<tag>[\w][\w.-]{0,127}))?$"#, // From https://regex101.com/r/nmSDPA/1
)
.unwrap()
});
/// 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(
r#"^(?P<name>(?:(?P<registry>(?:(?:localhost|[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)/)?(?P<repository>[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*))(?::(?P<tag>[\w][\w.-]{0,127}))?$"#, // From https://regex101.com/r/nmSDPA/1
)
.unwrap()
});
match RE.captures(image) {
Some(c) => {
let registry = match c.name("registry") {
@@ -59,22 +46,22 @@ 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 {
pub fn unsplit_image(image: &Image) -> String {
let reg = match image.registry.as_str() {
"registry-1.docker.io" => String::new(),
r => format!("{}/", r),
};
let repo = match repository.split('/').collect::<Vec<&str>>()[0] {
let repo = match image.repository.split('/').collect::<Vec<&str>>()[0] {
"library" => {
if reg.is_empty() {
repository.strip_prefix("library/").unwrap()
image.repository.strip_prefix("library/").unwrap()
} else {
repository
image.repository.as_str()
}
}
_ => repository,
_ => image.repository.as_str(),
};
format!("{}{}:{}", reg, repo, tag)
format!("{}{}:{}", reg, repo, image.tag)
}
/// Sorts the update vector alphabetically and where Some(true) > Some(false) > None
@@ -124,21 +111,64 @@ pub fn to_json(updates: &[(String, Option<bool>)]) -> JsonValue {
let up_to_date = updates
.iter()
.filter(|&(_, value)| *value == Some(false))
.collect::<Vec<&(String, Option<bool>)>>()
.len();
.count();
let update_available = updates
.iter()
.filter(|&(_, value)| *value == Some(true))
.collect::<Vec<&(String, Option<bool>)>>()
.len();
let unknown = updates
.iter()
.filter(|&(_, value)| value.is_none())
.collect::<Vec<&(String, Option<bool>)>>()
.len();
.count();
let unknown = updates.iter().filter(|&(_, value)| value.is_none()).count();
let _ = json_data["metrics"].insert("monitored_images", updates.len());
let _ = json_data["metrics"].insert("up_to_date", up_to_date);
let _ = json_data["metrics"].insert("update_available", update_available);
let _ = json_data["metrics"].insert("unknown", unknown);
json_data
}
/// Struct to hold some config values to avoid having to pass them all the time
#[derive(Clone)]
pub struct CliConfig {
pub socket: Option<String>,
pub verbose: bool,
pub config: JsonValue,
}
// Logging
/// 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)*) => ({
eprintln!("\x1b[41m ERROR \x1b[0m {}", format!($($arg)*));
std::process::exit(1);
})
}
// A small macro to print in yellow as a warning
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => ({
eprintln!("\x1b[103m WARN \x1b[0m {}", format!($($arg)*));
})
}
#[macro_export]
macro_rules! info {
($($arg:tt)*) => ({
println!("\x1b[44m INFO \x1b[0m {}", format!($($arg)*));
})
}
#[macro_export]
macro_rules! debug {
($($arg:tt)*) => ({
println!("\x1b[48:5:57m DEBUG \x1b[0m {}", format!($($arg)*));
})
}
pub fn new_reqwest_client() -> ClientWithMiddleware {
ClientBuilder::new(reqwest::Client::new())
.with(RetryTransientMiddleware::new_with_policy(
ExponentialBackoff::builder().build_with_max_retries(3),
))
.build()
}

View File

@@ -1 +1 @@
nodejs 21.6.2
nodejs 22.8.0

Binary file not shown.

View File

@@ -89,7 +89,7 @@
class="lg:grid-cols-4 grid-cols-2 gap-1 grid overflow-hidden *:relative"
>
{% for metric in metrics %}
<div class="gi">
<div class="before:bg-{{ theme }}-200 before:dark:bg-{{ theme }}-800 after:bg-{{ theme }}-200 after:dark:bg-{{ theme }}-800 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"
>

View File

@@ -11,10 +11,14 @@
"fmt": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-tooltip": "^1.1.2",
"@tabler/icons-react": "^3.14.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.9.0",

View File

@@ -1,33 +1,18 @@
import { MouseEvent, useState } from "react";
import { useState } from "react";
import Logo from "./components/Logo";
import Statistic from "./components/Statistic";
import Image from "./components/Image";
import { LastChecked } from "./components/LastChecked";
import Loading from "./components/Loading";
import { Data } from "./types";
import { theme } from "./theme";
import RefreshButton from "./components/RefreshButton";
import Search from "./components/Search";
function App() {
const [data, setData] = useState<Data | null>(null);
const theme = "neutral"; // Stupid, I know but I want both the dev and prod to work easily.
const [searchQuery, setSearchQuery] = useState("");
if (!data) return <Loading onLoad={setData} />;
const refresh = (event: MouseEvent) => {
const btn = event.currentTarget as HTMLButtonElement;
btn.disabled = true;
let request = new XMLHttpRequest();
request.onload = () => {
if (request.status === 200) {
window.location.reload();
}
};
request.open(
"GET",
process.env.NODE_ENV === "production"
? "/refresh"
: `http://${window.location.hostname}:8000/refresh`
);
request.send();
};
return (
<div
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
@@ -56,29 +41,13 @@ function App() {
className={`flex justify-between items-center px-6 py-4 text-${theme}-500`}
>
<LastChecked datetime={data.last_updated} />
<button className="group" onClick={refresh}>
<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"
className="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>
<RefreshButton />
</div>
<Search onChange={setSearchQuery}/>
<ul
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-${theme}-800 divide-y dark:text-white`}
>
{Object.entries(data.images).map(([name, status]) => (
{Object.entries(data.images).filter(([name]) => name.includes(searchQuery)).map(([name, status]) => (
<Image name={name} status={status} key={name} />
))}
</ul>

View File

@@ -4,6 +4,7 @@ import {
IconCube,
IconHelpCircleFilled,
} from "@tabler/icons-react";
import { WithTooltip } from "./Tooltip";
export default function Image({
name,
@@ -17,13 +18,28 @@ export default function Image({
<IconCube className="size-6 shrink-0" />
{name}
{status == false && (
<IconCircleCheckFilled className="text-green-500 ml-auto size-6 shrink-0" />
<WithTooltip
text="Up to date"
className="text-green-500 ml-auto size-6 shrink-0"
>
<IconCircleCheckFilled />
</WithTooltip>
)}
{status == true && (
<IconCircleArrowUpFilled className="text-blue-500 ml-auto size-6 shrink-0" />
<WithTooltip
text="Update available"
className="text-blue-500 ml-auto size-6 shrink-0"
>
<IconCircleArrowUpFilled />
</WithTooltip>
)}
{status == null && (
<IconHelpCircleFilled className="text-gray-500 ml-auto size-6 shrink-0" />
<WithTooltip
text="Unknown"
className="text-gray-500 ml-auto size-6 shrink-0"
>
<IconHelpCircleFilled />
</WithTooltip>
)}
</li>
);

View File

@@ -1,14 +1,14 @@
import { IconLoader2 } from "@tabler/icons-react";
import { Data } from "../types";
import Logo from "./Logo";
import { theme } from "../theme";
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
const theme = "neutral";
fetch(
process.env.NODE_ENV === "production"
? "/json"
: `http://${window.location.hostname}:8000/json`,
).then((response) => response.json().then((data) => onLoad(data)));
).then((response) => response.json().then((data) => {onLoad(data as Data)}));
return (
<div
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}

View File

@@ -0,0 +1,45 @@
import { MouseEvent } from "react";
import { WithTooltip } from "./Tooltip";
export default function RefreshButton() {
const refresh = (event: MouseEvent) => {
const btn = event.currentTarget as HTMLButtonElement;
btn.disabled = true;
const request = new XMLHttpRequest();
request.onload = () => {
if (request.status === 200) {
window.location.reload();
}
};
request.open(
"GET",
process.env.NODE_ENV === "production"
? "/refresh"
: `http://${window.location.hostname}:8000/refresh`,
);
request.send();
};
return (
<WithTooltip text="Reload">
<button className="group" onClick={refresh}>
<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"
className="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>
</WithTooltip>
);
}

View File

@@ -0,0 +1,51 @@
import { ChangeEvent, useState } from "react";
import { theme } from "../theme";
import { IconSearch, IconX } from "@tabler/icons-react";
export default function Search({
onChange,
}: {
onChange: (value: string) => void;
}) {
const [searchQuery, setSearchQuery] = useState("");
const [showClear, setShowClear] = useState(false);
const handleChange = (event: ChangeEvent) => {
const value = (event.target as HTMLInputElement).value;
setSearchQuery(value);
onChange(value);
if (value !== "") {
setShowClear(true);
} else setShowClear(false);
};
const handleClear = () => {
setShowClear(false);
setSearchQuery("");
onChange("");
};
return (
<div className={`w-full px-6 text-${theme}-500`}>
<div
className={`flex items-center w-full rounded-md border border-${theme}-200 dark:border-${theme}-700 px-2 gap-1 bg-${theme}-800 flex-nowrap peer`}
>
<IconSearch className="size-5" />
<div className="w-full">
<input
className={`w-full h-10 text-sm text-${theme}-400 focus:outline-none peer bg-transparent placeholder:text-${theme}-500`}
placeholder="Search"
onChange={handleChange}
value={searchQuery}
></input>
</div>
{showClear && (
<button onClick={handleClear} className={`hover:text-${theme}-400`}>
<IconX className="size-5" />
</button>
)}
</div>
<div
className="relative -translate-y-[8px] h-[8px] border-b-blue-600 border-b-2 w-0 peer-has-[:focus]:w-full transition-all duration-200 rounded-md left-1/2 -translate-x-1/2"
style={{ clipPath: "inset(calc(100% - 2px) 0 0 0)" }}
></div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import {
IconEyeFilled,
IconHelpCircleFilled,
} from "@tabler/icons-react";
import { theme } from "../theme";
export default function Statistic({
name,
@@ -12,7 +13,6 @@ export default function Statistic({
name: string;
value: number;
}) {
const theme = "neutral";
name = name.replaceAll("_", " ");
name = name.slice(0, 1).toUpperCase() + name.slice(1); // Capitalize name
return (

View File

@@ -0,0 +1,46 @@
import { Provider, Root, Trigger, Content } from "@radix-ui/react-tooltip";
import { cn } from "../utils";
import { forwardRef, ReactNode } from "react";
import { theme } from "../theme";
const TooltipContent = forwardRef<
React.ElementRef<typeof Content>,
React.ComponentPropsWithoutRef<typeof Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<Content
ref={ref}
sideOffset={sideOffset}
className={cn(
`z-50 overflow-hidden rounded-md border border-${theme}-200 bg-white px-3 py-1.5 text-sm text-${theme}-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-${theme}-800 dark:bg-${theme}-950 dark:text-${theme}-50`,
className,
)}
{...props}
/>
));
TooltipContent.displayName = Content.displayName;
const WithTooltip = ({
children,
text,
className,
}: {
children: ReactNode;
text: string;
className?: string;
}) => {
return (
<Provider>
<Root>
<Trigger className={className} asChild>
{children}
</Trigger>
<TooltipContent>
<p className="text-black dark:text-white">{text}</p>
</TooltipContent>
</Root>
</Provider>
);
};
export { WithTooltip };

1
web/src/theme.ts Normal file
View File

@@ -0,0 +1 @@
export const theme = "neutral"; // Will be modified by server at runtime

View File

@@ -1,12 +1,10 @@
export type Data = {
export interface Data {
metrics: {
monitored_images: number;
up_to_date: number;
update_available: number;
unknown: number;
};
images: {
[key: string]: boolean | null;
};
images: Record<string, boolean | null>;
last_updated: string;
};

6
web/src/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -4,7 +4,7 @@ export default {
theme: {
extend: {},
},
plugins: [],
plugins: [require("tailwindcss-animate")],
safelist: [
// Generate minimum extra CSS
{
@@ -24,14 +24,22 @@ export default {
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["hover"]
},
{
pattern: /text-(gray|neutral)-500/,
variants: ["dark"],
variants: ["dark", "placeholder"],
},
{
pattern: /divide-(gray|neutral)-800/,
variants: ["dark"],
},
{
pattern: /border-(gray|neutral)-200/,
},
{
pattern: /border-(gray|neutral)-700/,
variants: ["dark"],
},
],
};

View File

@@ -5,7 +5,8 @@ import react from "@vitejs/plugin-react-swc";
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: { // https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943
rollupOptions: {
// https://stackoverflow.com/q/69614671/vite-without-hash-in-filename#75344943
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,