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

27 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
Sergio
b4ef92fdcc Update version
Some checks are pending
CI / build-binary (push) Waiting to run
CI / build-image (push) Waiting to run
2024-09-07 11:23:05 +03:00
Sergio
6d1b5d339a Remove colon from last checked 2024-09-07 11:03:53 +03:00
Sergio
50e2124d07 Update release.yml
Some checks are pending
CI / build-binary (push) Waiting to run
CI / build-image (push) Waiting to run
2024-09-06 22:33:37 +03:00
Sergio
3eb61969b3 Update nightly.yml
Fix typo
2024-09-06 22:04:07 +03:00
Sergio
b87ed202ea Update release.yml
Fix typo
2024-09-06 22:03:35 +03:00
Sergio
b5ebb33627 Optimize workflows (#25)
feat: optimize workflows

---------

Co-authored-by: Stavros <steveiliop56@gmail.com>
2024-09-06 21:46:43 +03:00
Sergio
d67ffbf387 Add liquid again for static rendering, fix #21 and make some small frontend changes 2024-09-06 21:13:38 +03:00
Sergio
b0eff24087 Remove irrelevant README section
Some checks failed
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-aarch64 os:ubuntu-latest release_for:linux-aarch64 target:aarch64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-x86_64 os:ubuntu-latest release_for:linux-x86_64 target:x86_64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-image (push) Has been cancelled
2024-09-01 20:13:50 +03:00
Sergio
1ba67c8af0 Rustfmt 2024-09-01 19:57:15 +03:00
Sergio
2f195f611c Changed frontend from Liquid to React, fixed bug where server would check for updates twice 2024-09-01 19:52:20 +03:00
Sergio
e7673c04db Move icons in statistics to bottom right
Some checks failed
Deploy github pages / build (push) Has been cancelled
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-aarch64 os:ubuntu-latest release_for:linux-aarch64 target:aarch64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-x86_64 os:ubuntu-latest release_for:linux-x86_64 target:x86_64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-image (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2024-08-31 22:07:38 +03:00
Sergio
7292ed3d1b Forgot to handle another potential error 2024-08-31 21:44:31 +03:00
62 changed files with 2261 additions and 792 deletions

47
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: CI
on:
pull_request:
push:
branches: "main"
jobs:
build-binary:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Bun
uses: oven-sh/setup-bun@v1
- name: Install deps
run: cd web && bun install
- name: Build
run: ./build.sh cargo build --verbose
build-image:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: false
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,46 +1,48 @@
name: Nightly Release name: Nightly Release
on: on:
push: workflow_dispatch:
branches: main
jobs: jobs:
build-binary: build-binaries:
strategy: runs-on: ubuntu-latest
matrix:
platform:
- release_for: linux-aarch64
os: ubuntu-latest
target: aarch64-unknown-linux-musl
bin: cup
name: cup-linux-aarch64
command: build
- release_for: linux-x86_64
os: ubuntu-latest
target: x86_64-unknown-linux-musl
bin: cup
name: cup-linux-x86_64
command: build
runs-on: ${{ matrix.platform.os }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup rust - name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install cross - name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build binary - name: Set up Node
run: cross ${{ matrix.platform.command }} --target ${{ matrix.platform.target }} uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Bun
uses: oven-sh/setup-bun@v1
- name: Upload CLI - name: Install deps
run: cd web && bun install
- name: Build amd64 binary
run: |
./build.sh cross build --target x86_64-unknown-linux-musl --release
mv target/x86_64-unknown-linux-musl/release/cup ./cup-linux-amd64
- name: Build arm64 binary
run: |
./build.sh cross build --target aarch64-unknown-linux-musl --release
mv target/aarch64-unknown-linux-musl/release/cup ./cup-linux-arm64
- name: Upload binaries
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.platform.name }} name: binaries
path: target/${{ matrix.platform.target }}/debug/${{ matrix.platform.bin }} path: |
cup-linux-amd64
cup-linux-arm64
build-image: build-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -69,4 +71,21 @@ jobs:
push: true push: true
tags: ghcr.io/sergi0g/cup:nightly tags: ghcr.io/sergi0g/cup:nightly
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
nightly-release:
runs-on: ubuntu-latest
needs: build-binaries
steps:
- name: Download binaries
uses: actions/download-artifact@v4
with:
name: binaries
path: binaries
- uses: pyTooling/Actions/releaser@r0
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag: nightly
rm: true
files: binaries/*

View File

@@ -1,7 +1,6 @@
name: Release name: Release
on: on:
push: workflow_dispatch:
tags: ["v*.*.*"]
jobs: jobs:
get-tag: get-tag:
@@ -9,37 +8,56 @@ jobs:
outputs: outputs:
tag: ${{ steps.tag.outputs.tag }} tag: ${{ steps.tag.outputs.tag }}
steps: steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get current tag - name: Get current tag
id: tag id: tag
run: | run: |
TAG=$(echo "$GITHUB_REF" | sed 's/^refs\/tags\///') TAG=v$(head -n 4 Cargo.toml | grep version | awk '{print $3}' | tr -d '"')
echo "Current tag: $TAG" echo "Current tag: $TAG"
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
build-binary:
strategy: build-binaries:
matrix:
arch:
- aarch64
- x86_64
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup rust - name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install cross - name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build binary - name: Set up Node
run: cross build --target ${{ matrix.arch }}-unknown-linux-musl --release uses: actions/setup-node@v4
with:
node-version: 20
- name: Upload binary - name: Set up Bun
uses: oven-sh/setup-bun@v1
- name: Install deps
run: cd web && bun install
- name: Build amd64 binary
run: |
./build.sh cross build --target x86_64-unknown-linux-musl --release
mv target/x86_64-unknown-linux-musl/release/cup ./cup-linux-amd64
- name: Build arm64 binary
run: |
./build.sh cross build --target aarch64-unknown-linux-musl --release
mv target/aarch64-unknown-linux-musl/release/cup ./cup-linux-arm64
- name: Upload binaries
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: cup-linux-${{ matrix.arch }} name: binaries
path: target/${{ matrix.arch }}-unknown-linux-musl/release/cup path: |
cup-linux-amd64
cup-linux-arm64
build-image: build-image:
needs: get-tag needs: get-tag
@@ -73,24 +91,13 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [get-tag, build-image, build-binary] needs: [get-tag, build-image, build-binaries]
steps: steps:
- name: Download arm64 binary - name: Download binaries
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: cup-linux-aarch64 name: binaries
path: cup-linux-aarch64 path: binaries
- name: Download x86 binary
uses: actions/download-artifact@v4
with:
name: cup-linux-x86_64
path: cup-linux-x86_64
# - name: Extract and rename binaries
# run: |
# unzip /home/runner/work/cup/cup/cup-linux-aarch64 && mv /home/runner/work/cup/cup/cup cup-linux-aarch64
# unzip /home/runner/work/cup/cup/cup-linux-x86_64 && mv /home/runner/work/cup/cup/cup cup-linux-x86_64
- name: Create release - name: Create release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -100,6 +107,4 @@ jobs:
prerelease: true prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }} tag_name: ${{ needs.get-tag.outputs.tag }}
name: ${{ needs.get-tag.outputs.tag }} name: ${{ needs.get-tag.outputs.tag }}
files: | files: binaries/*
cup-linux-aarch64
cup-linux-x86_64

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/docs/.next /docs/.next
/docs/node_modules /docs/node_modules
/docs/out /docs/out
/src/static
# In case I accidentally commit mine... # In case I accidentally commit mine...
cup.json cup.json

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!

614
Cargo.lock generated
View File

@@ -43,9 +43,9 @@ dependencies = [
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.14" version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"anstyle-parse", "anstyle-parse",
@@ -64,38 +64,55 @@ checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
[[package]] [[package]]
name = "anstyle-parse" name = "anstyle-parse"
version = "0.2.4" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb"
dependencies = [ dependencies = [
"utf8parse", "utf8parse",
] ]
[[package]] [[package]]
name = "anstyle-query" name = "anstyle-query"
version = "1.1.0" version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
name = "anstyle-wincon" name = "anstyle-wincon"
version = "3.0.3" version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"windows-sys 0.52.0", "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]] [[package]]
name = "anymap2" name = "anymap2"
version = "0.13.0" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" 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]] [[package]]
name = "atty" name = "atty"
version = "0.2.14" version = "0.2.14"
@@ -128,18 +145,18 @@ dependencies = [
"rustc-demangle", "rustc-demangle",
] ]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -155,7 +172,7 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aed08d3adb6ebe0eff737115056652670ae290f177759aac19c30456135f94c" checksum = "0aed08d3adb6ebe0eff737115056652670ae290f177759aac19c30456135f94c"
dependencies = [ dependencies = [
"base64 0.22.1", "base64",
"bollard-stubs", "bollard-stubs",
"bytes", "bytes",
"futures-core", "futures-core",
@@ -199,6 +216,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.6.0" version = "1.6.0"
@@ -272,9 +295,9 @@ checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.1" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
[[package]] [[package]]
name = "console" name = "console"
@@ -304,40 +327,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@@ -350,21 +339,23 @@ dependencies = [
[[package]] [[package]]
name = "cup" name = "cup"
version = "2.1.0" version = "2.3.1"
dependencies = [ dependencies = [
"bollard", "bollard",
"chrono", "chrono",
"clap", "clap",
"futures",
"http-auth", "http-auth",
"indicatif", "indicatif",
"json", "json",
"liquid", "liquid",
"once_cell", "once_cell",
"rayon",
"regex", "regex",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"termsize", "termsize",
"tokio", "tokio",
"ureq",
"xitca-web", "xitca-web",
] ]
@@ -412,16 +403,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -437,6 +418,21 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.30" version = "0.3.30"
@@ -444,6 +440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -452,6 +449,23 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" 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]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.30" version = "0.3.30"
@@ -481,9 +495,13 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro", "futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab", "slab",
@@ -506,8 +524,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -572,13 +592,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "643c9bbf6a4ea8a656d6b4cd53d34f79e3f841ad5203c1a55fb7d761923bc255" checksum = "643c9bbf6a4ea8a656d6b4cd53d34f79e3f841ad5203c1a55fb7d761923bc255"
dependencies = [ dependencies = [
"base64 0.21.7",
"digest",
"hex",
"md-5",
"memchr", "memchr",
"rand",
"sha2",
] ]
[[package]] [[package]]
@@ -650,6 +664,24 @@ dependencies = [
"winapi", "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]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.6" version = "0.1.6"
@@ -760,19 +792,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
] ]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "ipnet"
version = "1.70.0" version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [ dependencies = [
"either", "either",
] ]
@@ -800,9 +841,9 @@ checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd"
[[package]] [[package]]
name = "kstring" name = "kstring"
version = "2.0.0" version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3066350882a1cd6d950d055997f379ac37fd39f81cd4d8ed186032eb3c5747" checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1"
dependencies = [ dependencies = [
"serde", "serde",
"static_assertions", "static_assertions",
@@ -822,9 +863,9 @@ checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]] [[package]]
name = "liquid" name = "liquid"
version = "0.26.6" version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10929f201279ba14da3297b957dcda1e0bf7a6f3bb5115688be684aa8864e9cc" checksum = "7cdcc72b82748f47c2933c172313f5a9aea5b2c4eb3fa4c66b4ea55bb60bb4b1"
dependencies = [ dependencies = [
"doc-comment", "doc-comment",
"liquid-core", "liquid-core",
@@ -835,9 +876,9 @@ dependencies = [
[[package]] [[package]]
name = "liquid-core" name = "liquid-core"
version = "0.26.6" version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aef4b2160791f456eb880c990a97746f693746f92302ef5f1d06111cf14b768" checksum = "2752e978ffc53670f3f2e8b3ef09f348d6f7b5474a3be3f8a5befe5382e4effb"
dependencies = [ dependencies = [
"anymap2", "anymap2",
"itertools", "itertools",
@@ -853,9 +894,9 @@ dependencies = [
[[package]] [[package]]
name = "liquid-derive" name = "liquid-derive"
version = "0.26.5" version = "0.26.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915f6d0a2963a27cd5205c1902f32ddfe3bc035816afd268cf88c0fc0f8d287e" checksum = "3b51f1d220e3fa869e24cfd75915efe3164bd09bb11b3165db3f37f57bf673e3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -864,9 +905,9 @@ dependencies = [
[[package]] [[package]]
name = "liquid-lib" name = "liquid-lib"
version = "0.26.6" version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f48fc446873f74d869582f5c4b8cbf3248c93395e410a67af5809b3731e44a" checksum = "59b1a298d3d2287ee5b1e43840d885b8fdfc37d3f4e90d82aacfd04d021618da"
dependencies = [ dependencies = [
"itertools", "itertools",
"liquid-core", "liquid-core",
@@ -877,28 +918,34 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "log" name = "log"
version = "0.4.22" version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.4" version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.7.4" version = "0.7.4"
@@ -981,6 +1028,31 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@@ -989,9 +1061,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" checksum = "9c73c26c01b8c87956cea613c907c9d6ecffd8d18a2a5908e5de0adfaa185cea"
dependencies = [ dependencies = [
"memchr", "memchr",
"thiserror", "thiserror",
@@ -1000,9 +1072,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" checksum = "664d22978e2815783adbdd2c588b455b1bd625299ce36b2a99881ac9627e6d8d"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@@ -1010,9 +1082,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" checksum = "a2d5487022d5d33f4c30d91c22afa240ce2a644e87fe08caad974d4eab6badbe"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
@@ -1023,9 +1095,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" checksum = "0091754bbd0ea592c4deb3a122ce8ecbb0753b738aa82bc055fcc2eccc8d8174"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"pest", "pest",
@@ -1078,9 +1150,12 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.17" version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
@@ -1091,6 +1166,54 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.36" version = "1.0.36"
@@ -1131,23 +1254,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rayon" name = "redox_syscall"
version = "1.10.0" version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [ dependencies = [
"either", "bitflags",
"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",
] ]
[[package]] [[package]]
@@ -1179,6 +1291,93 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" 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]] [[package]]
name = "ring" name = "ring"
version = "0.17.8" version = "0.17.8"
@@ -1201,12 +1400,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]] [[package]]
name = "rustls" name = "rustc-hash"
version = "0.23.10" version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -1216,16 +1420,26 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pemfile"
version = "1.7.0" version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.102.5" version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9a6fccd794a42c2c105b513a2f62bc3fd8f3ba57a4593677ceb0bd035164d78" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -1238,6 +1452,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.204" version = "1.0.204"
@@ -1298,7 +1518,7 @@ version = "3.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e73139bc5ec2d45e6c5fd85be5a46949c1c39a4c18e56915f5eb4c12f975e377" checksum = "e73139bc5ec2d45e6c5fd85be5a46949c1c39a4c18e56915f5eb4c12f975e377"
dependencies = [ dependencies = [
"base64 0.22.1", "base64",
"chrono", "chrono",
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
@@ -1398,6 +1618,15 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "termsize" name = "termsize"
version = "0.1.8" version = "0.1.8"
@@ -1514,6 +1743,17 @@ dependencies = [
"syn", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.11" version = "0.7.11"
@@ -1652,9 +1892,9 @@ dependencies = [
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.11.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
@@ -1668,22 +1908,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 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]] [[package]]
name = "url" name = "url"
version = "2.5.2" version = "2.5.2"
@@ -1753,6 +1977,18 @@ dependencies = [
"wasm-bindgen-shared", "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]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.92" version = "0.2.92"
@@ -1783,10 +2019,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]] [[package]]
name = "webpki-roots" name = "wasm-timer"
version = "0.26.3" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -1822,6 +2083,36 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@@ -2048,6 +2339,27 @@ dependencies = [
"xitca-unsafe-collection", "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]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.1" version = "1.8.1"

View File

@@ -1,23 +1,25 @@
[package] [package]
name = "cup" name = "cup"
version = "2.1.0" version = "2.3.1"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
clap = { version = "4.5.7", features = ["derive"] } clap = { version = "4.5.7", features = ["derive"] }
indicatif = { version = "0.17.8", optional = true } indicatif = { version = "0.17.8", optional = true }
tokio = {version = "1.38.0", features = ["rt", "rt-multi-thread", "macros"]} tokio = {version = "1.38.0", features = ["macros"]}
ureq = { version = "2.9.7", features = ["tls"] }
rayon = "1.10.0"
xitca-web = { version = "0.5.0", optional = true, features = ["logger"] } xitca-web = { version = "0.5.0", optional = true, features = ["logger"] }
liquid = { version = "0.26.6", optional = true } liquid = { version = "0.26.6", optional = true }
bollard = "0.16.1" bollard = "0.16.1"
once_cell = "1.19.0" 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 } termsize = { version = "0.1.8", optional = true }
regex = "1.10.5" regex = "1.10.5"
chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true } chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true }
json = "0.12.4" 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] [features]
default = ["server", "cli"] default = ["server", "cli"]

View File

@@ -1,20 +1,42 @@
FROM rust:alpine AS build ### Build UI ###
WORKDIR / FROM node:20 AS web
# Install bun
RUN curl -fsSL https://bun.sh/install | bash
# Copy web folder
COPY ./web /web
WORKDIR /web
# Install requirements
RUN ~/.bun/bin/bun install
# Build frontend
RUN ~/.bun/bin/bun run build
### Build Cup ###
FROM rust:1.80.1-alpine AS build
# Requirements
RUN apk add musl-dev RUN apk add musl-dev
RUN USER=root cargo new --bin cup # Copy files
WORKDIR /cup WORKDIR /cup
COPY Cargo.toml Cargo.lock . COPY Cargo.toml .
RUN cargo build --release COPY Cargo.lock .
RUN rm -rf src/ COPY ./src ./src
COPY src src # Copy UI from web builder
# This is a very bad workaround, but cargo only triggers a rebuild this way for some reason COPY --from=web /web/dist src/static
RUN printf "\n" >> src/main.rs
# Build
RUN cargo build --release RUN cargo build --release
### Main ###
FROM scratch FROM scratch
# Copy binary
COPY --from=build /cup/target/release/cup /cup COPY --from=build /cup/target/release/cup /cup
ENTRYPOINT ["/cup"] ENTRYPOINT ["/cup"]

View File

@@ -4,6 +4,8 @@ Cup is the easiest way to check for container image updates.
![Demo](screenshots/cup.gif) ![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 ## Screenshots
![Cup web in light mode](screenshots/web_light.png) ![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 ## 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) - 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. - 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. - 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! - 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 ## Documentation
@@ -44,9 +46,7 @@ Here are some ideas to get you started:
- Help optimize Cup and make it even better! - Help optimize Cup and make it even better!
- Add more features to the web UI - 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)!
Note: If you update the UI, please make sure to recompile the CSS with `tailwindcss -mo src/static/index.css`. You need to have the Tailwind CSS CLI installed ([instructions here](https://tailwindcss.com/docs/installation))
## Support ## Support

22
build.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/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/
# Build
bun run build
# Copy UI to src folder
cp -r dist/ ../src/static
# Run command from argv
$@

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", "autoprefixer": "^10.4.19",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"tailwindcss": "^3.4.5" "tailwindcss": "^3.4.5"
} },
} "packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c"
}

View File

@@ -11,7 +11,13 @@
"usage": { "usage": {
"title": "Usage" "title": "Usage"
}, },
"community-resources": {
"title": "Community Resources"
},
"nightly": { "nightly": {
"title": "Using the latest version" "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 { asPath } = useRouter()
const { frontMatter } = useConfig() const { frontMatter } = useConfig()
const url = const url =
'https://sergi0g.github.io/cup' + 'https://sergi0g.github.io/cup/docs/' +
(`/${asPath}`); (`/${asPath}`);
return ( return (
@@ -36,9 +36,9 @@ export default {
<h1 className="font-bold ml-2">Cup</h1> <h1 className="font-bold ml-2">Cup</h1>
</div> </div>
), ),
logoLink: "sergi0g.github.io/cup", logoLink: "https://sergi0g.github.io/cup/docs/",
project: { project: {
link: "https://github.com/sergi0g/cup", link: "https://github.com/sergi0g/cup/",
}, },
navbar: { navbar: {
extraContent: <ThemeSwitch lite className="[&_span]:hidden" />, extraContent: <ThemeSwitch lite className="[&_span]:hidden" />,

View File

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

View File

@@ -1,12 +1,14 @@
use bollard::{ use bollard::{secret::ImageSummary, ClientVersion, Docker};
secret::ImageSummary,
ClientVersion, Docker,
};
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
use bollard::secret::ImageInspect; 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 { fn create_docker_client(socket: Option<String>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket { let client: Result<Docker, bollard::errors::Error> = match socket {
@@ -27,35 +29,24 @@ fn create_docker_client(socket: Option<String>) -> Docker {
} }
} }
pub async fn get_images_from_docker_daemon(socket: Option<String>) -> Vec<Image> { pub async fn get_images_from_docker_daemon(options: &CliConfig) -> Vec<Image> {
let client: Docker = create_docker_client(socket); let client: Docker = create_docker_client(options.socket.clone());
let images: Vec<ImageSummary> = match client.list_images::<String>(None).await { let images: Vec<ImageSummary> = match client.list_images::<String>(None).await {
Ok(images) => images, Ok(images) => images,
Err(e) => { Err(e) => {
error!("Failed to retrieve list of images available!\n{}", 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 { for image in images {
if !image.repo_tags.is_empty() && image.repo_digests.len() == 1 { handles.push(Image::from(image, options))
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 join_all(handles)
.await
.iter()
.filter(|img| img.is_some())
.map(|img| img.clone().unwrap())
.collect()
} }
#[cfg(feature = "cli")] #[cfg(feature = "cli")]

View File

@@ -58,7 +58,7 @@ pub fn print_update(name: &str, has_update: &Option<bool>) {
} }
pub fn print_raw_update(name: &str, has_update: &Option<bool>) { pub fn print_raw_update(name: &str, has_update: &Option<bool>) {
let result = object! {images: {[name]: *has_update}} ; let result = object! {images: {[name]: *has_update}};
println!("{}", result); println!("{}", result);
} }

View File

@@ -1,3 +1,10 @@
use bollard::secret::ImageSummary;
use crate::{
debug,
utils::{split_image, CliConfig},
};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Image { pub struct Image {
pub registry: String, pub registry: String,
@@ -5,3 +12,30 @@ pub struct Image {
pub tag: String, pub tag: String,
pub digest: Option<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,22 +1,23 @@
#[cfg(feature = "cli")]
use check::{get_all_updates, get_update};
use chrono::Local;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner}; use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner};
#[cfg(feature = "cli")]
use check::{get_all_updates, get_update};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use server::serve; use server::serve;
use std::path::PathBuf; use std::path::PathBuf;
use utils::load_config; use utils::{load_config, CliConfig};
pub mod check; pub mod check;
pub mod docker; pub mod docker;
pub mod image;
pub mod registry;
pub mod utils;
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
pub mod formatting; pub mod formatting;
pub mod image;
pub mod registry;
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod server; pub mod server;
pub mod utils;
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
@@ -25,6 +26,13 @@ struct Cli {
socket: Option<String>, socket: Option<String>,
#[arg(short, long, default_value_t = String::new(), help = "Config file path")] #[arg(short, long, default_value_t = String::new(), help = "Config file path")]
config_path: String, config_path: String,
#[arg(
short,
long,
default_value_t = false,
help = "Enable verbose (debug) logging"
)]
verbose: bool,
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
} }
@@ -64,32 +72,50 @@ async fn main() {
"" => None, "" => None,
path => Some(PathBuf::from(path)), 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 { match &cli.command {
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
Some(Commands::Check { image, icons, raw }) => match image { Some(Commands::Check { image, icons, raw }) => match image {
Some(name) => { Some(name) => {
let has_update = get_update(name, cli.socket, &config).await; let has_update = get_update(name, &cli_config).await;
match raw { match raw {
true => print_raw_update(name, &has_update), true => print_raw_update(name, &has_update),
false => print_update(name, &has_update), false => print_update(name, &has_update),
}; };
} }
None => { None => {
let start = Local::now().timestamp_millis();
match raw { 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 => { false => {
let spinner = Spinner::new(); let spinner = Spinner::new();
let updates = get_all_updates(cli.socket, &config).await; let updates = get_all_updates(&cli_config).await;
spinner.succeed(); spinner.succeed();
let end = Local::now().timestamp_millis();
print_updates(&updates, icons); print_updates(&updates, icons);
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
} }
}; };
} }
}, },
#[cfg(feature = "server")] #[cfg(feature = "server")]
Some(Commands::Serve { port }) => { Some(Commands::Serve { port }) => {
let _ = serve(port, cli.socket, config).await; let _ = serve(port, &cli_config).await;
} }
None => (), None => (),
} }

View File

@@ -1,131 +1,168 @@
use std::sync::Mutex; use futures::future::join_all;
use json::JsonValue; use json::JsonValue;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use ureq::{Error, ErrorKind};
use http_auth::parse_challenges; 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> { pub async fn check_auth(
let protocol = if config["insecure_registries"].contains(registry) { "http" } else { "https" }; registry: &str,
let response = ureq::get(&format!("{}://{}/v2/", protocol, registry)).call(); 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 = client
.get(format!("{}://{}/v2/", protocol, registry))
.send()
.await;
match response { match response {
Ok(_) => None, Ok(r) => {
Err(Error::Status(401, response)) => match response.header("www-authenticate") { let status = r.status().as_u16();
Some(challenge) => Some(parse_www_authenticate(challenge)), if status == 401 {
None => error!("Unauthorized to access registry {} and no way to authenticate was provided", registry), match r.headers().get("www-authenticate") {
}, Some(challenge) => Some(parse_www_authenticate(challenge.to_str().unwrap())),
Err(e) => error!("{}", e), 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())
}
}
} }
} }
pub fn get_latest_digest(image: &Image, token: Option<&String>, config: &JsonValue) -> Image { pub async fn get_latest_digest(
let protocol = if config["insecure_registries"].contains(json::JsonValue::from(image.registry.clone())) { "http" } else { "https" }; image: &Image,
let mut request = ureq::head(&format!( 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/{}", "{}://{}/v2/{}/manifests/{}",
protocol, &image.registry, &image.repository, &image.tag protocol, &image.registry, &image.repository, &image.tag
)); ));
if let Some(t) = token { if let Some(t) = token {
request = request.set("Authorization", &format!("Bearer {}", t)); request = request.header("Authorization", &format!("Bearer {}", t));
} }
let raw_response = match request 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") .header("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")
.call() .send().await
{ {
Ok(response) => response, Ok(response) => {
Err(Error::Status(401, response)) => { let status = response.status();
if token.is_some() { if status == 401 {
warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry, token.unwrap()); 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() } return Image { digest: None, ..image.clone() }
} else { } else {
return get_latest_digest( response
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()
} }
}, },
Err(Error::Transport(error)) => { Err(e) => {
match error.kind() { if e.is_connect() {
ErrorKind::Dns => { warn!("Connection to registry failed.");
warn!("Failed to lookup the IP of the registry, retrying."); return Image { digest: None, ..image.clone() }
return get_latest_digest(image, token, config) } else {
}, // If something goes really wrong, this can get stuck in a loop error!("Unexpected error: {}", e.to_string())
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)
} }
}, },
}; };
match raw_response.header("docker-content-digest") { match raw_response.headers().get("docker-content-digest") {
Some(digest) => Image { Some(digest) => Image {
digest: Some(digest.to_string()), digest: Some(digest.to_str().unwrap().to_string()),
..image.clone() ..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(images: Vec<&Image>, token: Option<&String>, config: &JsonValue) -> Vec<Image> { pub async fn get_latest_digests(
let result: Mutex<Vec<Image>> = Mutex::new(Vec::new()); images: Vec<&Image>,
images.par_iter().for_each(|&image| { token: Option<&String>,
let digest = get_latest_digest(image, token, config).digest; options: &CliConfig,
result.lock().unwrap().push(Image { client: &ClientWithMiddleware,
digest, ) -> Vec<Image> {
..image.clone() let mut handles = Vec::new();
}); for image in images {
}); handles.push(get_latest_digest(image, token, options, client))
let r = result.lock().unwrap().clone(); }
r 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(); let mut final_url = auth_url.to_owned();
for image in &images { for image in &images {
final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository); 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 { 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 None => base_request,
}; };
let raw_response = match base_request.call() let raw_response = match base_request.send().await {
{ Ok(response) => match response.text().await {
Ok(response) => match response.into_string() {
Ok(res) => res, Ok(res) => res,
Err(e) => { Err(e) => {
error!("Failed to parse response into string!\n{}", 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) => { 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) { let parsed_token_response: JsonValue = match json::parse(&raw_response) {

View File

@@ -6,7 +6,7 @@ use liquid::{object, Object};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use xitca_web::{ use xitca_web::{
body::ResponseBody, body::ResponseBody,
handler::{handler_service, state::StateRef}, handler::{handler_service, path::PathRef, state::StateRef},
http::WebResponse, http::WebResponse,
middleware::Logger, middleware::Logger,
route::get, route::get,
@@ -15,27 +15,27 @@ use xitca_web::{
use crate::{ use crate::{
check::get_all_updates, check::get_all_updates,
error, error, info,
utils::{sort_update_vec, to_json}, utils::{sort_update_vec, to_json, CliConfig},
}; };
const RAW_TEMPLATE: &str = include_str!("static/template.liquid"); const HTML: &str = include_str!("static/index.html");
const STYLE: &str = include_str!("static/index.css"); const JS: &str = include_str!("static/assets/index.js");
const CSS: &str = include_str!("static/assets/index.css");
const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico"); const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico");
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg"); const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png"); 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<()> { pub async fn serve(port: &u16, options: &CliConfig) -> std::io::Result<()> {
let mut data = ServerData::new(socket, config).await; info!("Starting server, please wait...");
data.refresh().await; let data = ServerData::new(options).await;
info!("Ready to start!");
App::new() App::new()
.with_state(Arc::new(Mutex::new(data))) .with_state(Arc::new(Mutex::new(data)))
.at("/", get(handler_service(home))) .at("/", get(handler_service(_static)))
.at("/json", get(handler_service(json))) .at("/json", get(handler_service(json)))
.at("/refresh", get(handler_service(refresh))) .at("/refresh", get(handler_service(refresh)))
.at("/favicon.ico", handler_service(favicon_ico)) // These aren't pretty but this is xitca-web... .at("/*", get(handler_service(_static)))
.at("/favicon.svg", handler_service(favicon_svg))
.at("/apple-touch-icon.png", handler_service(apple_touch_icon))
.enclosed(Logger::new()) .enclosed(Logger::new())
.serve() .serve()
.bind(format!("0.0.0.0:{}", port))? .bind(format!("0.0.0.0:{}", port))?
@@ -43,14 +43,46 @@ pub async fn serve(port: &u16, socket: Option<String>, config: JsonValue) -> std
.wait() .wait()
} }
async fn home(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse { async fn _static(data: StateRef<'_, Arc<Mutex<ServerData>>>, path: PathRef<'_>) -> WebResponse {
WebResponse::new(ResponseBody::from(data.lock().await.template.clone())) match path.0 {
"/" => WebResponse::builder()
.header("Content-Type", "text/html")
.body(ResponseBody::from(data.lock().await.template.clone()))
.unwrap(),
"/assets/index.js" => WebResponse::builder()
.header("Content-Type", "text/javascript")
.body(ResponseBody::from(JS.replace(
"=\"neutral\"",
&format!("=\"{}\"", data.lock().await.theme),
)))
.unwrap(),
"/assets/index.css" => WebResponse::builder()
.header("Content-Type", "text/css")
.body(ResponseBody::from(CSS))
.unwrap(),
"/favicon.ico" => WebResponse::builder()
.header("Content-Type", "image/vnd.microsoft.icon")
.body(ResponseBody::from(FAVICON_ICO))
.unwrap(),
"/favicon.svg" => WebResponse::builder()
.header("Content-Type", "image/svg+xml")
.body(ResponseBody::from(FAVICON_SVG))
.unwrap(),
"/apple-touch-icon.png" => WebResponse::builder()
.header("Content-Type", "image/png")
.body(ResponseBody::from(APPLE_TOUCH_ICON))
.unwrap(),
_ => WebResponse::builder()
.status(404)
.body(ResponseBody::from("Not found"))
.unwrap(),
}
} }
async fn json(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse { async fn json(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::new(ResponseBody::from( WebResponse::new(ResponseBody::from(json::stringify(
json::stringify(data.lock().await.json.clone()) data.lock().await.json.clone(),
)) )))
} }
async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse { async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
@@ -58,48 +90,42 @@ async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::new(ResponseBody::from("OK")) WebResponse::new(ResponseBody::from("OK"))
} }
async fn favicon_ico() -> WebResponse {
WebResponse::new(ResponseBody::from(FAVICON_ICO))
}
async fn favicon_svg() -> WebResponse {
WebResponse::new(ResponseBody::from(FAVICON_SVG))
}
async fn apple_touch_icon() -> WebResponse {
WebResponse::new(ResponseBody::from(APPLE_TOUCH_ICON))
}
struct ServerData { struct ServerData {
template: String, template: String,
raw_updates: Vec<(String, Option<bool>)>, raw_updates: Vec<(String, Option<bool>)>,
json: JsonValue, json: JsonValue,
socket: Option<String>, options: CliConfig,
config: JsonValue, theme: &'static str,
} }
impl ServerData { impl ServerData {
async fn new(socket: Option<String>, config: JsonValue) -> Self { async fn new(options: &CliConfig) -> Self {
let mut s = Self { let mut s = Self {
socket, options: options.clone(),
template: String::new(), template: String::new(),
json: json::object! { json: json::object! {
metrics: json::object! {}, metrics: json::object! {},
images: json::object! {}, images: json::object! {},
}, },
raw_updates: Vec::new(), raw_updates: Vec::new(),
config, theme: "neutral",
}; };
s.refresh().await; s.refresh().await;
s s
} }
async fn refresh(&mut self) { 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; self.raw_updates = updates;
let template = liquid::ParserBuilder::with_stdlib() let template = liquid::ParserBuilder::with_stdlib()
.build() .build()
.unwrap() .unwrap()
.parse(RAW_TEMPLATE) .parse(HTML)
.unwrap(); .unwrap();
let images = self let images = self
.raw_updates .raw_updates
@@ -110,8 +136,12 @@ impl ServerData {
}) })
.collect::<Vec<Object>>(); .collect::<Vec<Object>>();
self.json = to_json(&self.raw_updates); self.json = to_json(&self.raw_updates);
let last_updated = Local::now().format("%Y-%m-%d %H:%M:%S"); let last_updated = Local::now();
let 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 { Some(t) => match *t {
"default" => "neutral", "default" => "neutral",
"blue" => "gray", "blue" => "gray",
@@ -125,9 +155,8 @@ impl ServerData {
let globals = object!({ let globals = object!({
"metrics": [{"name": "Monitored images", "value": self.json["metrics"]["monitored_images"].as_usize()}, {"name": "Up to date", "value": self.json["metrics"]["up_to_date"].as_usize()}, {"name": "Updates available", "value": self.json["metrics"]["update_available"].as_usize()}, {"name": "Unknown", "value": self.json["metrics"]["unknown"].as_usize()}], "metrics": [{"name": "Monitored images", "value": self.json["metrics"]["monitored_images"].as_usize()}, {"name": "Up to date", "value": self.json["metrics"]["up_to_date"].as_usize()}, {"name": "Updates available", "value": self.json["metrics"]["update_available"].as_usize()}, {"name": "Unknown", "value": self.json["metrics"]["unknown"].as_usize()}],
"images": images, "images": images,
"style": STYLE, "last_updated": last_updated.format("%Y-%m-%d %H:%M:%S").to_string(),
"last_updated": last_updated.to_string(), "theme": &self.theme
"theme": theme
}); });
self.template = template.render(&globals).unwrap(); self.template = template.render(&globals).unwrap();
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,292 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<meta name="theme-color" content="#ffffff">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Cup</title>
<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;
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 {
{% if theme == "neutral" %}
background-color: #262626;
{% elsif theme == "gray" %}
background-color: #1f2937
{% endif %}
}
}
.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;
}
@supports (scrollbar-color: auto) {
html {
scrollbar-color: #707070 #343840;
}
}
@supports selector(::-webkit-scrollbar) {
html::-webkit-scrollbar {
width: 10px;
}
html::-webkit-scrollbar-track {
background: #343840;
}
html::-webkit-scrollbar-thumb {
background: #707070;
border-radius: 0.375rem;
}
html::-webkit-scrollbar-thumb:hover {
background: #b5b5b5;
}
}
</style>
<script>
function refresh(event) {
var button = event.currentTarget;
button.disabled = true;
let request = new XMLHttpRequest();
request.onload = function () {
if (request.status === 200) {
window.location.reload();
}
};
request.open('GET', `${window.location.origin}/refresh`);
request.send();
}
</script>
</head>
<body>
<div class="flex justify-center items-center min-h-screen bg-{{ 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">
<h1 class="text-5xl lg:text-6xl font-bold dark:text-white">Cup</h1>
<svg
version="1.1"
id="Layer_2"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 128 128"
style="enable-background:new 0 0 128 128;"
xml:space="preserve"
class="size-16"
>
<path style="fill:#A6CFD6;" d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95
c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06
C97.82,30.66,94.2,18.43,65.12,17.55z"/>
<path style="fill:#DCEDF6;" d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16
c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z"/>
<path style="fill:#6CA4AE;" d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7
S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z"/>
<path style="fill:#DC0D27;" d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06
s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73
C110.47,3.47,86.08,11.74,84.85,13.1z"/>
<path style="fill:#8A1F0F;" d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47
c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z"/>
<g>
<path style="fill:#8A1F0F;" d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97
c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z"/>
</g>
<g>
<path style="fill:#8A1F0F;" d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99
c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z"/>
</g>
<g>
<path style="fill:#8A1F0F;" d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96
c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z"/>
</g>
</svg>
</div>
<div class="shadow-sm bg-white dark:bg-{{ theme }}-900 rounded-md my-8">
<dl class="lg:grid-cols-4 grid-cols-2 gap-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-{{ theme }}-500 dark:text-{{ theme }}-400 leading-6 font-medium flex gap-1 justify-between">
{{ metric.name }}
{% if metric.name == 'Monitored images' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-black dark:text-white shrink-0"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 4c4.29 0 7.863 2.429 10.665 7.154l.22 .379l.045 .1l.03 .083l.014 .055l.014 .082l.011 .1v.11l-.014 .111a.992 .992 0 0 1 -.026 .11l-.039 .108l-.036 .075l-.016 .03c-2.764 4.836 -6.3 7.38 -10.555 7.499l-.313 .004c-4.396 0 -8.037 -2.549 -10.868 -7.504a1 1 0 0 1 0 -.992c2.831 -4.955 6.472 -7.504 10.868 -7.504zm0 5a3 3 0 1 0 0 6a3 3 0 0 0 0 -6z" />
</svg>
{% elsif metric.name == 'Up to date' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-green-500 shrink-0"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z" />
</svg>
{% elsif metric.name == 'Updates available' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-blue-500 shrink-0"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-4.98 3.66l-.163 .01l-.086 .016l-.142 .045l-.113 .054l-.07 .043l-.095 .071l-.058 .054l-4 4l-.083 .094a1 1 0 0 0 1.497 1.32l2.293 -2.293v5.586l.007 .117a1 1 0 0 0 1.993 -.117v-5.585l2.293 2.292l.094 .083a1 1 0 0 0 1.32 -1.497l-4 -4l-.082 -.073l-.089 -.064l-.113 -.062l-.081 -.034l-.113 -.034l-.112 -.02l-.098 -.006z" />
</svg>
{% elsif metric.name == 'Unknown' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-{{ 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" />
</svg>
{% endif %}
</dt>
<dd class="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl flex-none w-full">
{{ metric.value }}
</dd>
</div>
</div>
{% endfor %}
</dl>
</div>
<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
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="group-disabled:animate-spin"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" /><path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
</svg>
</button>
</div>
<ul class="*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-{{ theme }}-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.has_update == 'false' %}
<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.has_update == 'true' %}
<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.has_update == 'null' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
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" />
</svg>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,40 +1,27 @@
use std::path::PathBuf; use std::path::PathBuf;
use crate::{error, image::Image};
use json::{object, JsonValue}; use json::{object, JsonValue};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; 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) static RE: Lazy<Regex> = Lazy::new(|| {
#[macro_export] Regex::new(
macro_rules! error { 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
($($arg:tt)*) => ({ )
eprintln!($($arg)*); .unwrap()
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)*));
})
}
/// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest']. /// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest'].
pub fn split_image(image: &str) -> (String, String, String) { 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) { match RE.captures(image) {
Some(c) => { Some(c) => {
let registry = match c.name("registry") { let registry = match c.name("registry") {
Some(registry) => registry.as_str().to_owned(), Some(registry) => registry.as_str().to_owned(),
None => String::from("registry-1.docker.io"), None => String::from("registry-1.docker.io"),
}; };
return ( return (
registry.clone(), registry.clone(),
match c.name("repository") { match c.name("repository") {
@@ -52,29 +39,29 @@ pub fn split_image(image: &str) -> (String, String, String) {
Some(tag) => tag.as_str().to_owned(), Some(tag) => tag.as_str().to_owned(),
None => String::from("latest"), None => String::from("latest"),
}, },
) );
} }
None => error!("Failed to parse image {}", image), None => error!("Failed to parse image {}", image),
} }
} }
/// 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 /// 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 { pub fn unsplit_image(image: &Image) -> String {
let reg = match registry { let reg = match image.registry.as_str() {
"registry-1.docker.io" => String::new(), "registry-1.docker.io" => String::new(),
r => format!("{}/", r), r => format!("{}/", r),
}; };
let repo = match repository.split('/').collect::<Vec<&str>>()[0] { let repo = match image.repository.split('/').collect::<Vec<&str>>()[0] {
"library" => { "library" => {
if reg.is_empty() { if reg.is_empty() {
repository.strip_prefix("library/").unwrap() image.repository.strip_prefix("library/").unwrap()
} else { } 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 /// 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 let up_to_date = updates
.iter() .iter()
.filter(|&(_, value)| *value == Some(false)) .filter(|&(_, value)| *value == Some(false))
.collect::<Vec<&(String, Option<bool>)>>() .count();
.len();
let update_available = updates let update_available = updates
.iter() .iter()
.filter(|&(_, value)| *value == Some(true)) .filter(|&(_, value)| *value == Some(true))
.collect::<Vec<&(String, Option<bool>)>>() .count();
.len(); let unknown = updates.iter().filter(|&(_, value)| value.is_none()).count();
let unknown = updates
.iter()
.filter(|&(_, value)| value.is_none())
.collect::<Vec<&(String, Option<bool>)>>()
.len();
let _ = json_data["metrics"].insert("monitored_images", updates.len()); 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("up_to_date", up_to_date);
let _ = json_data["metrics"].insert("update_available", update_available); let _ = json_data["metrics"].insert("update_available", update_available);
let _ = json_data["metrics"].insert("unknown", unknown); let _ = json_data["metrics"].insert("unknown", unknown);
json_data 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,17 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"src/static/template.liquid"
],
theme: {
extend: {},
},
plugins: [],
safelist: [
{
pattern: /(bg|text|divide)-(gray|neutral)-.+/,
variants: ["dark"]
}
]
}

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
web/.tool-versions Normal file
View File

@@ -0,0 +1 @@
nodejs 22.8.0

9
web/README.md Normal file
View File

@@ -0,0 +1,9 @@
# Cup web frontend
This is the Cup web frontend, built with Vite and React. Once it's built, Cup modifies a few things (notably the theme) and sends the result to the client.
# Development
Requirements: Bun, Node.js 20+
Install dependencies with `bun install` and start the development server with `bun dev`.

BIN
web/bun.lockb Executable file

Binary file not shown.

36
web/eslint.config.js Normal file
View File

@@ -0,0 +1,36 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);

273
web/index.html Normal file
View File

@@ -0,0 +1,273 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf8" />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Cup</title>
</head>
<body>
<div id="root">
<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">
<h1 class="text-5xl lg:text-6xl font-bold dark:text-white">
Cup
</h1>
<svg
version="1.1"
id="Layer_2"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 128 128"
style="enable-background: new 0 0 128 128"
xml:space="preserve"
class="size-14 lg:size-16"
>
<path
style="fill: #a6cfd6"
d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95
c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06
C97.82,30.66,94.2,18.43,65.12,17.55z"
/>
<path
style="fill: #dcedf6"
d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16
c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z"
/>
<path
style="fill: #6ca4ae"
d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7
S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z"
/>
<path
style="fill: #dc0d27"
d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06
s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73
C110.47,3.47,86.08,11.74,84.85,13.1z"
/>
<path
style="fill: #8a1f0f"
d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47
c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z"
/>
<g>
<path
style="fill: #8a1f0f"
d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97
c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z"
/>
</g>
<g>
<path
style="fill: #8a1f0f"
d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99
c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z"
/>
</g>
<g>
<path
style="fill: #8a1f0f"
d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96
c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z"
/>
</g>
</svg>
</div>
<div
class="shadow-sm bg-white dark:bg-{{ theme }}-900 rounded-md my-8"
>
<dl
class="lg:grid-cols-4 grid-cols-2 gap-1 grid overflow-hidden *:relative"
>
{% for metric in metrics %}
<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"
>
<dt
class="text-{{ theme }}-500 dark:text-{{ theme }}-400 leading-6 font-medium"
>
{{ metric.name }}
</dt>
<div class="flex gap-1 justify-between items-center">
<dd
class="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl w-full"
>
{{ metric.value }}
</dd>
{% if metric.name == 'Monitored images' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-black dark:text-white shrink-0"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M12 4c4.29 0 7.863 2.429 10.665 7.154l.22 .379l.045 .1l.03 .083l.014 .055l.014 .082l.011 .1v.11l-.014 .111a.992 .992 0 0 1 -.026 .11l-.039 .108l-.036 .075l-.016 .03c-2.764 4.836 -6.3 7.38 -10.555 7.499l-.313 .004c-4.396 0 -8.037 -2.549 -10.868 -7.504a1 1 0 0 1 0 -.992c2.831 -4.955 6.472 -7.504 10.868 -7.504zm0 5a3 3 0 1 0 0 6a3 3 0 0 0 0 -6z"
/>
</svg>
{% elsif metric.name == 'Up to date' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-green-500 shrink-0"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z"
/>
</svg>
{% elsif metric.name == 'Updates available' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-blue-500 shrink-0"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-4.98 3.66l-.163 .01l-.086 .016l-.142 .045l-.113 .054l-.07 .043l-.095 .071l-.058 .054l-4 4l-.083 .094a1 1 0 0 0 1.497 1.32l2.293 -2.293v5.586l.007 .117a1 1 0 0 0 1.993 -.117v-5.585l2.293 2.292l.094 .083a1 1 0 0 0 1.32 -1.497l-4 -4l-.082 -.073l-.089 -.064l-.113 -.062l-.081 -.034l-.113 -.034l-.112 -.02l-.098 -.006z"
/>
</svg>
{% elsif metric.name == 'Unknown' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6 text-{{ 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"
/>
</svg>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</dl>
</div>
<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
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="group-disabled:animate-spin"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" />
<path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
</svg>
</button>
</div>
<ul
class="*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-{{ theme }}-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.has_update == 'false' %}
<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.has_update == 'true' %}
<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.has_update == 'null' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
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"
/>
</svg>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
web/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"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",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/node": "^22.5.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.42",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

61
web/src/App.tsx Normal file
View File

@@ -0,0 +1,61 @@
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 [searchQuery, setSearchQuery] = useState("");
if (!data) return <Loading onLoad={setData} />;
return (
<div
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
>
<div className="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full">
<div className="flex flex-col max-w-[48rem] mx-auto h-full my-8">
<div className="flex items-center gap-1">
<h1 className="text-5xl lg:text-6xl font-bold dark:text-white">
Cup
</h1>
<Logo />
</div>
<div
className={`shadow-sm bg-white dark:bg-${theme}-900 rounded-md my-8`}
>
<dl className="lg:grid-cols-4 md:grid-cols-2 gap-1 grid-cols-1 grid overflow-hidden *:relative">
{Object.entries(data.metrics).map(([name, value]) => (
<Statistic name={name} value={value} key={name} />
))}
</dl>
</div>
<div
className={`shadow-sm bg-white dark:bg-${theme}-900 rounded-md my-8`}
>
<div
className={`flex justify-between items-center px-6 py-4 text-${theme}-500`}
>
<LastChecked datetime={data.last_updated} />
<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).filter(([name]) => name.includes(searchQuery)).map(([name, status]) => (
<Image name={name} status={status} key={name} />
))}
</ul>
</div>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,46 @@
import {
IconCircleArrowUpFilled,
IconCircleCheckFilled,
IconCube,
IconHelpCircleFilled,
} from "@tabler/icons-react";
import { WithTooltip } from "./Tooltip";
export default function Image({
name,
status,
}: {
name: string;
status: boolean | null;
}) {
return (
<li className="break-all">
<IconCube className="size-6 shrink-0" />
{name}
{status == false && (
<WithTooltip
text="Up to date"
className="text-green-500 ml-auto size-6 shrink-0"
>
<IconCircleCheckFilled />
</WithTooltip>
)}
{status == true && (
<WithTooltip
text="Update available"
className="text-blue-500 ml-auto size-6 shrink-0"
>
<IconCircleArrowUpFilled />
</WithTooltip>
)}
{status == null && (
<WithTooltip
text="Unknown"
className="text-gray-500 ml-auto size-6 shrink-0"
>
<IconHelpCircleFilled />
</WithTooltip>
)}
</li>
);
}

View File

@@ -0,0 +1,6 @@
import { intlFormatDistance } from "date-fns/intlFormatDistance";
export function LastChecked({ datetime }: { datetime: string }) {
const date = intlFormatDistance(new Date(datetime), new Date());
return <h3>Last checked {date}</h3>;
}

View File

@@ -0,0 +1,34 @@
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 }) {
fetch(
process.env.NODE_ENV === "production"
? "/json"
: `http://${window.location.hostname}:8000/json`,
).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`}
>
<div className="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full absolute overflow-hidden">
<div className="flex flex-col max-w-[48rem] mx-auto h-full my-8">
<div className="flex items-center gap-1">
<h1 className="text-5xl lg:text-6xl font-bold dark:text-white">
Cup
</h1>
<Logo />
</div>
<div
className={`h-full flex justify-center
items-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
>
Loading <IconLoader2 className="animate-spin" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
export default function Logo() {
return (
<svg viewBox="0 0 128 128" className="size-14 lg:size-16">
<path
style={{ fill: "#A6CFD6" }}
d="M65.12,17.55c-17.6-0.53-34.75,5.6-34.83,14.36c-0.04,5.2,1.37,18.6,3.62,48.68s2.25,33.58,3.5,34.95
c1.25,1.37,10.02,8.8,25.75,8.8s25.93-6.43,26.93-8.05c0.48-0.78,1.83-17.89,3.5-37.07c1.81-20.84,3.91-43.9,3.99-45.06
C97.82,30.66,94.2,18.43,65.12,17.55z"
/>
<path
style={{ fill: "#DCEDF6" }}
d="M41.4,45.29c-0.12,0.62,1.23,24.16,2.32,27.94c1.99,6.92,9.29,7.38,10.23,4.16
c0.9-3.07-0.38-29.29-0.38-29.29s-3.66-0.3-6.43-0.84C44,46.63,41.4,45.29,41.4,45.29z"
/>
<path
style={{ fill: "#6CA4AE" }}
d="M33.74,32.61c-0.26,8.83,20.02,12.28,30.19,12.22c13.56-0.09,29.48-4.29,29.8-11.7
S79.53,21.1,63.35,21.1C49.6,21.1,33.96,25.19,33.74,32.61z"
/>
<path
style={{ fill: "#DC0D27" }}
d="M84.85,13.1c-0.58,0.64-9.67,30.75-9.67,30.75s2.01-0.33,4-0.79c2.63-0.61,3.76-1.06,3.76-1.06
s7.19-22.19,7.64-23.09c0.45-0.9,21.61-7.61,22.31-7.93c0.7-0.32,1.39-0.4,1.46-0.78c0.06-0.38-2.34-6.73-3.11-6.73
C110.47,3.47,86.08,11.74,84.85,13.1z"
/>
<path
style={{ fill: "#8A1F0F" }}
d="M110.55,7.79c1.04,2.73,2.8,3.09,3.55,2.77c0.45-0.19,1.25-1.84,0.01-4.47
c-0.99-2.09-2.17-2.74-2.93-2.61C110.42,3.6,109.69,5.53,110.55,7.79z"
/>
<g>
<path
style={{ fill: "#8A1F0F" }}
d="M91.94,18.34c-0.22,0-0.44-0.11-0.58-0.3l-3.99-5.77c-0.22-0.32-0.14-0.75,0.18-0.97
c0.32-0.22,0.76-0.14,0.97,0.18l3.99,5.77c0.22,0.32,0.14,0.75-0.18,0.97C92.21,18.3,92.07,18.34,91.94,18.34z"
/>
</g>
<g>
<path
style={{ fill: "#8A1F0F" }}
d="M90.28,19.43c-0.18,0-0.35-0.07-0.49-0.2l-5.26-5.12c-0.28-0.27-0.28-0.71-0.01-0.99
c0.27-0.28,0.71-0.28,0.99-0.01l5.26,5.12c0.28,0.27,0.28,0.71,0.01,0.99C90.64,19.36,90.46,19.43,90.28,19.43z"
/>
</g>
<g>
<path
style={{ fill: "#8A1F0F" }}
d="M89.35,21.22c-0.12,0-0.25-0.03-0.36-0.1l-5.6-3.39c-0.33-0.2-0.44-0.63-0.24-0.96
c0.2-0.33,0.63-0.44,0.96-0.24l5.6,3.39c0.33,0.2,0.44,0.63,0.24,0.96C89.82,21.1,89.59,21.22,89.35,21.22z"
/>
</g>
</svg>
);
}

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

@@ -0,0 +1,48 @@
import {
IconCircleArrowUpFilled,
IconCircleCheckFilled,
IconEyeFilled,
IconHelpCircleFilled,
} from "@tabler/icons-react";
import { theme } from "../theme";
export default function Statistic({
name,
value,
}: {
name: string;
value: number;
}) {
name = name.replaceAll("_", " ");
name = name.slice(0, 1).toUpperCase() + name.slice(1); // Capitalize name
return (
<div
className={`before:bg-${theme}-200 before:dark:bg-${theme}-800 after:bg-${theme}-200 after:dark:bg-${theme}-800 gi`}
>
<div className="xl:px-8 px-6 py-4 gap-y-2 gap-x-4 justify-between align-baseline flex flex-col h-full">
<dt
className={`text-${theme}-500 dark:text-${theme}-400 leading-6 font-medium`}
>
{name}
</dt>
<div className="flex gap-1 justify-between items-center">
<dd className="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl w-full">
{value}
</dd>
{name == "Monitored images" && (
<IconEyeFilled className="size-6 text-black dark:text-white shrink-0" />
)}
{name == "Up to date" && (
<IconCircleCheckFilled className="size-6 text-green-500 shrink-0" />
)}
{name == "Update available" && (
<IconCircleArrowUpFilled className="size-6 text-blue-500 shrink-0" />
)}
{name == "Unknown" && (
<IconHelpCircleFilled className="size-6 text-gray-500 shrink-0" />
)}
</div>
</div>
</div>
);
}

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 };

54
web/src/index.css Normal file
View File

@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Kinda hacky, but thank you https://geary.co/internal-borders-css-grid/ */
.gi {
position: relative;
height: 100%;
}
.gi::before,
.gi::after {
content: "";
position: absolute;
z-index: 1;
}
.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;
}
@supports (scrollbar-color: auto) {
html {
scrollbar-color: #707070 #343840;
}
}
@supports selector(::-webkit-scrollbar) {
html::-webkit-scrollbar {
width: 10px;
}
html::-webkit-scrollbar-track {
background: #343840;
}
html::-webkit-scrollbar-thumb {
background: #707070;
border-radius: 0.375rem;
}
html::-webkit-scrollbar-thumb:hover {
background: #b5b5b5;
}
}

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

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

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

10
web/src/types.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface Data {
metrics: {
monitored_images: number;
up_to_date: number;
update_available: number;
unknown: number;
};
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));
}

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

45
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/App.tsx", "./src/components/*.tsx"],
theme: {
extend: {},
},
plugins: [require("tailwindcss-animate")],
safelist: [
// Generate minimum extra CSS
{
pattern: /bg-(gray|neutral)-50/,
},
{
pattern: /bg-(gray|neutral)-(900|950)/,
variants: ["dark"],
},
{
pattern: /bg-(gray|neutral)-200/,
variants: ["before", "after"],
},
{
pattern: /bg-(gray|neutral)-800/,
variants: ["before:dark", "after:dark"],
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["hover"]
},
{
pattern: /text-(gray|neutral)-500/,
variants: ["dark", "placeholder"],
},
{
pattern: /divide-(gray|neutral)-800/,
variants: ["dark"],
},
{
pattern: /border-(gray|neutral)-200/,
},
{
pattern: /border-(gray|neutral)-700/,
variants: ["dark"],
},
],
};

24
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

17
web/vite.config.ts Normal file
View File

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