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

162 Commits

Author SHA1 Message Date
Sergio
6dc1030a3b chore: bump project version 2025-05-21 19:30:30 +03:00
Sergio
d2c1651761 docs: update automatic refresh docs to mention TZ configuration 2025-05-21 11:39:41 +03:00
Sergio
8b3cf73f65 docs: add docs for environment variables 2025-05-21 11:34:16 +03:00
Sergio
6d88036914 feat: add timezone support 2025-05-21 11:13:27 +03:00
Sergio
a06266264d refactor: avoid a clone if extra images are empty 2025-05-20 17:46:09 +03:00
Sergio
c260874459 feat: add support for configuring through environment variables 2025-05-20 17:03:36 +03:00
Sergio
3e42ac338a fix: errors in http.rs 2025-05-10 20:55:14 +03:00
Sergio
15784eb4f1 fix: errors in http.rs 2025-05-10 20:52:04 +03:00
Sergio
2623f52a20 fix: handle 502 gracefully
Fixes #104
2025-05-10 20:48:22 +03:00
Sergio
8a5b0555f7 feat: add new filters 2025-05-09 13:19:42 +03:00
Sergio
2e1b0945e0 chore: bump project version 2025-05-09 09:51:16 +03:00
Raphaël C.
c8229d7370 feat: add new filter for in use images to web ui 2025-05-08 20:54:26 +03:00
Sergio
4b3bf9bd8f chore: update gitignore 2025-05-02 19:54:14 +03:00
Sergio
3ac990abce fix: clippy lints 2025-05-02 19:54:14 +03:00
Raphaël Catarino
5ea924c5ad refactor: better data fetching (#100) 2025-05-02 19:12:19 +03:00
Raphaël Catarino
2ac036d353 refactor: remove node and use only bun (#101) 2025-04-26 13:13:30 +03:00
Seow Alex
80a295680d feat: add ability to ignore certain update types (#91) 2025-04-12 09:37:28 +03:00
Sergio
efea81ef39 chore: bump project version 2025-04-10 19:22:19 +03:00
Pavel
d3cb5af225 Fix refresh button when using custom base url (#89) 2025-04-07 07:11:44 +03:00
Sergio
5904c2d2e2 fix: ignore version info when tags are equal
Even though some images had newer digests, they weren't being taken into
consideration when checking for updates. Should resolve #85 (further
testing needed to confirm).
2025-04-06 20:10:05 +03:00
Sergio
674bc3d614 fix: misaligned table columns in CLI
Reported in #85
2025-04-04 16:09:31 +03:00
Seow Alex
e4a07f9810 fix: use default registry for docker.io (#86) 2025-04-03 22:17:50 +03:00
dependabot[bot]
4e0f3c3eb9 chore(deps): bump next from 15.2.3 to 15.2.4 in /docs (#87)
Bumps [next](https://github.com/vercel/next.js) from 15.2.3 to 15.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.2.3...v15.2.4)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.2.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-03 16:32:54 +03:00
Sergio
ba20dd3086 docs: mention seconds are required in cron pattern 2025-04-03 15:24:50 +03:00
Sergio
86d5b0465c chore: bump project version 2025-03-26 17:52:24 +02:00
Sergio
9d358ca6b2 fix: prevent wrapping text in badges 2025-03-26 17:51:21 +02:00
Sergio
f886601185 fix: check extra references specified in config
Fixes #81
2025-03-26 16:57:33 +02:00
Sergio
806364f01d ci: fix incorrect nightly workflow 2025-03-25 16:44:10 +02:00
Sergio
d35759ec66 chore: bump project version 2025-03-25 15:44:10 +02:00
Sergio
ffefe1db38 fix: specify color scheme in the web UI
Fixes a bug when displaying dark mode in Chrome
2025-03-25 15:44:10 +02:00
dependabot[bot]
2f9efe22d4 chore(deps): bump next from 15.1.5 to 15.2.3 in /docs (#80)
Bumps [next](https://github.com/vercel/next.js) from 15.1.5 to 15.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.1.5...v15.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-22 09:35:06 +02:00
Thomas McWork
bbfb3c63ea docs: add instructions to find the docker group id to the compose docs 2025-03-21 18:47:59 +02:00
Sergio
6800f1ae27 refactor: specify the exposed port in the CI Dockerfile 2025-03-21 18:38:37 +02:00
Thomas McWork
402d72c85b refactor: specify the exposed port in the Dockerfile 2025-03-21 18:36:52 +02:00
Sergio
4f54301467 docs: fix incorrect edit this page URL
Closes #79
2025-03-21 18:20:14 +02:00
Sergio
be99438123 refactor: search component 2025-03-21 18:17:05 +02:00
Sergio
71164417a0 style: format code 2025-03-21 16:55:28 +02:00
Sergio
59ca170592 refactor: fix lint error in web UI 2025-03-21 16:54:52 +02:00
Sergio
b37b7ed060 refactor: load web UI assets with a relative URL to allow for hosting under a different path.
Might fix #53.
2025-03-21 16:32:23 +02:00
Sergio
dd68c5097a feat: add badges to web UI to quickly show which version is running and which the user will upgrade to
This is an example of a bad, long commit message.
2025-03-21 15:57:49 +02:00
Sergio
5fbbba32f1 refactor: remove dbg and use a proper panic when parsing a reference 2025-03-19 19:28:31 +02:00
Sergio
b10af38df4 chore: format code 2025-03-19 19:24:19 +02:00
Sergio
77a07013a9 refactor: use array slices instead of vectors wherever possible 2025-03-19 19:20:29 +02:00
Sergio
ccf825df24 refactor: use Bytes to store constant blobs in the server
Might also improve performance.
2025-03-19 13:56:47 +02:00
Sergio
e26f941c59 chore: bump project version 2025-03-16 18:42:06 +02:00
Sergio
c411fc4bad fix: ignore invalid digests instead of panicking 2025-03-16 18:40:10 +02:00
Sergio
e965380133 fix: improve error handling in get_latest_tag when an image has no tags
This commit is mostly for debugging #68, but it's good to have more
error info just in case.
2025-03-16 18:33:19 +02:00
Sergio
ef849b624f fix: don't pass empty parameters when making auth request (#69) 2025-03-16 18:26:04 +02:00
Sergio
8db7e2e12b fix: improve error handling when scheduling automatic refresh 2025-03-15 12:28:39 +02:00
Sergio
54e1998032 docs: fix incorrect cron schedule example 2025-03-15 12:28:39 +02:00
Sergio
9f142ab81c fix: add error message when app fails to bind to port 2025-03-15 12:28:39 +02:00
Sergio
ffd4d6267c chore: update readme
forgot to add a link
2025-03-14 10:20:51 +02:00
Sergio
242029db22 chore: update readme 2025-03-14 10:15:38 +02:00
Sergio
b6562ef76f chore: bump project version 2025-03-13 17:16:22 +02:00
Sergio
846b24bf2d feat: add experimental docker swarm support 2025-03-13 17:09:20 +02:00
Sergio
d7f766f1f5 chore(readme): Add discord link 2025-03-11 17:15:11 +02:00
Sergio
60096792a9 chore(docs): update JSON API reference schema 2025-03-11 17:08:42 +02:00
Sergio
a599d4e084 feat: detect image url from label 2025-03-11 17:06:00 +02:00
Sergio
a5a1f12899 chore: remove unneeded file 2025-03-11 17:05:46 +02:00
Sergio
766a20ccac chore: add badges to readme 2025-03-11 16:32:59 +02:00
Sergio
fe66ba842a docs: add docs about refresh endpoint 2025-03-11 16:26:48 +02:00
Sergio
c06c20394f docs: add page about home assistant integration 2025-03-11 16:26:31 +02:00
Sergio
98dafb8ba4 chore: bump project version 2025-03-10 16:49:07 +02:00
Sergio
2addfca1b4 fix: code block component copying was working incorrectly (#62) 2025-03-10 16:47:47 +02:00
dependabot[bot]
e3b05923ae Bump ring from 0.17.11 to 0.17.13 (#60)
Bumps [ring](https://github.com/briansmith/ring) from 0.17.11 to 0.17.13.
- [Changelog](https://github.com/briansmith/ring/blob/main/RELEASES.md)
- [Commits](https://github.com/briansmith/ring/commits)

---
updated-dependencies:
- dependency-name: ring
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-07 19:26:28 +02:00
Sergio
aa4195f8d6 chore: bump project version 2025-03-07 18:25:00 +02:00
Sergio
1b94629c79 fix: overflowing image references in web UI 2025-03-07 18:01:32 +02:00
Sergio
8cd9cce94e chore: bump project version 2025-03-07 17:38:20 +02:00
Sergio
ddabd8c102 fix: strip hash when parsing image references 2025-03-07 17:37:56 +02:00
Sergio
0b0028ab6d Fix search in docs 2025-03-07 16:40:59 +02:00
Sergio
75509550b1 Fix CI Dockerfile 2025-03-03 11:35:58 +02:00
Sergio
9716d1a351 Fix CI Dockerfile 2025-03-03 11:30:09 +02:00
Sergio
d5a2556768 Fix CI workflows 2025-03-03 11:23:26 +02:00
Sergio
e7f1921620 I'm stupid 2025-03-03 11:14:16 +02:00
Sergio
7ea6ae6de5 Let's try again 2025-03-03 10:31:42 +02:00
Sergio
d7c2e6436c Fix CI workflows 2025-03-03 10:23:08 +02:00
Sergio
fde61ee07d Fix CI workflows 2025-03-03 10:18:11 +02:00
Sergio
c4de3961a0 Optimize CI for docker image 2025-03-03 10:05:18 +02:00
Sergio
404c574c2c Update Rust version in Dockerfile 2025-03-02 13:08:43 +02:00
Sergio
6d4df20f54 Ignore registries before retrieving auth tokens 2025-03-02 13:05:23 +02:00
Sergio
7b3745d095 Fix errors and revert reqwest-middleware to v0.3.3 2025-03-02 13:04:31 +02:00
Sergio
f9aa516da7 Update dependencies. Also fixes a security vulnerability in rustls 2025-03-02 12:45:29 +02:00
Sergio
0f9c5d1466 V3
Many many many changes, honestly just read the release notes
2025-02-28 20:43:49 +02:00
Kamil Essekkat
b12acba745 Add note about non-root with docker compose to docs (#56)
Some checks failed
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2025-01-10 16:05:58 +02:00
James Bogosian
d26f57758a Fix typo in docs
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-12-26 16:04:11 +02:00
dependabot[bot]
566b02ca4c Bump nanoid from 3.3.7 to 3.3.8 in /docs (#49)
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
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8 due to a security vulnerability.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-17 15:49:40 +02:00
Sergio
06b0d65b41 idk how to spell
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
2024-11-02 11:20:27 +02:00
Sergio
3772ec29eb Update nightly.yml
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
2024-10-27 20:24:52 +02:00
Sergio
018239632e Update nightly.yml
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
2024-10-25 17:03:05 +03:00
Sergio
186f44c65a Update nightly.yml 2024-10-25 16:34:36 +03:00
Sergio
e1eaf63f1c Update links to WUD since repo was moved
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-10-23 16:11:25 +03:00
dependabot[bot]
e82e59de36 Bump mermaid from 10.9.1 to 10.9.3 in /docs (#43)
Some checks are pending
CI / build-binary (push) Waiting to run
CI / build-image (push) Waiting to run
Deploy github pages / build (push) Waiting to run
Deploy github pages / deploy (push) Blocked by required conditions
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 10.9.1 to 10.9.3.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Changelog](https://github.com/mermaid-js/mermaid/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mermaid-js/mermaid/compare/v10.9.1...v10.9.3)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-22 21:34:19 +03:00
Sergio
cd0c6ee299 Update README.md
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
2024-10-17 20:41:06 +03:00
Sergio
3654f21bd3 Update bug_report.md
Some checks failed
CI / build-binary (push) Has been cancelled
CI / build-image (push) Has been cancelled
2024-10-12 16:07:44 +03:00
Sergio
f430d6bfb7 Update issue templates 2024-10-12 16:06:10 +03:00
Sergio
2bfb1b69db Update docs
Some checks failed
CI / build-binary (push) Waiting to run
CI / build-image (push) Waiting to run
Deploy github pages / build (push) Has been cancelled
Deploy github pages / deploy (push) Has been cancelled
2024-10-11 22:45:43 +03:00
dependabot[bot]
875fcce0d5 Bump micromatch from 4.0.7 to 4.0.8 in /docs (#33)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.7 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.7...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-11 21:26:33 +03:00
dependabot[bot]
f40af342ec Bump next from 14.2.5 to 14.2.10 in /docs (#34)
Bumps [next](https://github.com/vercel/next.js) from 14.2.5 to 14.2.10.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.5...v14.2.10)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-11 21:26:09 +03:00
Sergio
88885aa1dd Refactor (#32) 2024-10-11 21:22:39 +03:00
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
Sergio
def2efa0d1 Add better error handling
(mostly because I have bad internet)
2024-08-31 21:24:56 +03:00
Sergio
21c110011f Update README.md 2024-08-31 20:29:40 +03:00
Sergio
c969ded188 Update version 2024-08-31 19:54:51 +03:00
Sergio
53f32958fc Update build.yml
Attempt "I've lost count"
2024-08-31 19:36:56 +03:00
Sergio
82ec9b6e52 Update build.yml
Attempt 5. I just hate the fact that you can't properly debug this thing.
2024-08-31 19:22:28 +03:00
Sergio
8ad5cbb127 Update build.yml
Attempt 4
2024-08-31 19:16:59 +03:00
Sergio
7ea4c63322 Update build.yml
Attempt 3
2024-08-31 19:10:52 +03:00
Sergio
30b8e943c0 Update build.yml
(Hopefully) fixed CI errors
2024-08-31 19:07:19 +03:00
Sergio
0f7245dbf4 Update build.yml
Test
2024-08-31 18:54:52 +03:00
Sergio
2549ed7801 Add support for private registries/images 2024-08-31 18:43:30 +03:00
Sergio
90239f83e9 New version
Some checks are pending
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) Waiting to run
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) Waiting to run
Nightly Release / build-image (push) Waiting to run
2024-08-30 19:30:28 +03:00
Sergio
dc7a981930 Merge pull request #22 from sergi0g/set-correct-headers
Set correct headers when requesting a manifest to prevent getting a v1 manifest
2024-08-30 19:19:11 +03:00
Sergio
8d2740dc7d Set correct headers when requesting a manifest to prevent getting a v1 manifest 2024-08-30 19:17:43 +03:00
Sergio
fb674acf96 Update gitignore
Some checks are pending
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) Waiting to run
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) Waiting to run
Nightly Release / build-image (push) Waiting to run
2024-08-30 11:32:33 +03:00
Sergio
e9160334d9 Update README.md
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
Fix broken docs link
2024-08-21 16:23:28 +03:00
Sergio
ca6ffea29c Fix sort order in docs and a typo
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-07-17 17:28:05 +03:00
Sergio
923e81d75d Update release workflow and fix bug with binaries not being uploaded
Some checks are pending
Deploy github pages / build (push) Waiting to run
Deploy github pages / deploy (push) Blocked by required conditions
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) Waiting to run
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) Waiting to run
Nightly Release / build-image (push) Waiting to run
2024-07-17 16:15:26 +03:00
Sergio
1cf4cf2394 Switch back to json 2024-07-17 15:28:32 +03:00
Sergio
d1cb62304d docs: Change basePath only in production 2024-07-17 15:10:18 +03:00
Sergio
935517d3f8 Fix docs basepath 2024-07-17 14:57:18 +03:00
Sergio
710abd5277 Make final documentation changes 2024-07-17 14:50:56 +03:00
Sergio
7b4bf6c8e3 Update docs.yml 2024-07-17 14:40:06 +03:00
Sergio
03e260bf56 Update docs.yml 2024-07-17 14:38:19 +03:00
Sergio
0d4aa17a4f Update docs workflow 2024-07-17 14:36:00 +03:00
Sergio
bea4e67021 Add manual trigger to docs workflow 2024-07-17 14:32:19 +03:00
Sergio
35f96d1e3c Fix documentation workflow to setup pnpm 2024-07-17 14:31:24 +03:00
Sergio
a822a616be Add documentation 2024-07-17 14:28:49 +03:00
Sergio
0fac554fae Added metrics to JSON, switched from json to serde_json, extracted all JSON related functionality into a function in utils.rs
Some checks are pending
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) Waiting to run
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) Waiting to run
Nightly Release / build-image (push) Waiting to run
2024-07-16 19:40:34 +03:00
Sergio
f09f8762c2 Switch stats to 2 columns on smaller screens (see #5)
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-07-15 14:25:12 +03:00
Sergio
a84b3eab02 Update README.md
Add section about roadmap
2024-07-15 14:16:18 +03:00
Sergio
30c762ea83 Added config, custom theming and moved update checking logic to check.rs 2024-07-15 14:11:46 +03:00
Sergio
b9278ca010 Refactor and simplify server code, UI updates requested in #5 and #6
Some checks failed
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-aarch64 os:ubuntu-latest release_for:linux-aarch64 target:aarch64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-binary (map[bin:cup command:build name:cup-linux-x86_64 os:ubuntu-latest release_for:linux-x86_64 target:x86_64-unknown-linux-musl]) (push) Has been cancelled
Nightly Release / build-image (push) Has been cancelled
2024-07-13 15:25:39 +03:00
Sergio
814e0b8133 Add favicon.ico to head as well
Some checks are pending
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) Waiting to run
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) Waiting to run
Nightly Release / build-image (push) Waiting to run
2024-07-13 11:37:15 +03:00
Sergio
75ebda03db Simplify nightly workflow 2024-07-12 14:30:12 +03:00
Sergio
698061e8c8 Update package version 2024-07-12 13:30:24 +03:00
Sergio
0a01c667da Fix UI scaling and update docs 2024-07-12 13:26:40 +03:00
154 changed files with 10391 additions and 1693 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Bug report
about: Is something not working properly? Report it here.
title: "[BUG] <TITLE>"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System info (please complete the following information):**
- OS: [e.g. Ubuntu]
- Docker daemon version: [Engine version in the output of `docker version`]
- Cup version: [Output of `cup --version`]
**Additional context**
Add any other info that you think may be useful here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FR] <TITLE>"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

13
.github/actions/build-image/Dockerfile vendored Normal file
View File

@@ -0,0 +1,13 @@
FROM --platform=$BUILDPLATFORM alpine AS builder
ARG TARGETARCH
ARG TARGETOS
COPY binaries/* /
RUN mv cup-$TARGETOS-$TARGETARCH cup
RUN chmod +x cup
FROM scratch
COPY --from=builder /cup /cup
EXPOSE 8000
ENTRYPOINT ["/cup"]

52
.github/actions/build-image/action.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Build Image
inputs:
tags:
description: "Docker image tags"
required: true
gh-token:
description: "Github token"
required: true
runs:
using: "composite"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download binaries
uses: actions/download-artifact@v4
with:
path: .
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/sergi0g/cup
tags: ${{ inputs.tags }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: sergi0g
password: ${{ inputs.gh-token }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./.github/actions/build-image/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,111 +0,0 @@
name: Release
on:
push:
tags: ["v*.*.*"]
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Get current tag
id: tag
run: |
TAG=$(echo "$GITHUB_REF" | sed 's/^refs\/tags\///')
echo "Current tag: $TAG"
echo "tag=$TAG" >> $GITHUB_OUTPUT
build-binary:
strategy:
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:
- name: Checkout
uses: actions/checkout@v4
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build binary
run: cross ${{ matrix.platform.command }} --target ${{ matrix.platform.target }} --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}
build-image:
needs: get-tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64, linux/arm64
push: true
tags: ghcr.io/sergi0g/cup:${{ needs.get-tag.outputs.tag }},ghcr.io/sergi0g/cup:latest
cache-from: type=gha
cache-to: type=gha,mode=max
release:
runs-on: ubuntu-latest
needs: [get-tag, build-image, build-binary]
steps:
- name: Download arm64 binary
uses: actions/download-artifact@v4
with:
name: cup-linux-aarch64
path: cup-linux-aarch64
- name: Download x86 binary
uses: actions/download-artifact@v4
with:
name: cup-linux-x86_64
path: cup-linux-x86_64
- name: Create release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }}
name: ${{ needs.get-tag.outputs.tag }}
files: |
cup-linux-aarch64/cup-linux-aarch64
cup-linux-x86_64/cup-linux-x86_64

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

@@ -0,0 +1,45 @@
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 Bun
uses: oven-sh/setup-bun@v2
- name: Install deps
run: cd web && bun install
- name: Build
run: ./build.sh cargo build --verbose
- name: Test
run: cargo test
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

41
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Deploy github pages
on:
push:
paths:
- 'docs/**'
workflow_dispatch:
jobs:
build:
defaults:
run:
working-directory: docs
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Build
run: bun run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/out/
deploy:
if: ${{ github.ref == 'refs/heads/main' }}
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -1,123 +1,91 @@
name: Nightly Release
on:
workflow_dispatch:
inputs:
tag:
description: "Nightly version tag"
required: true
jobs:
create-tag:
get-tag:
runs-on: ubuntu-latest
outputs:
tagname: ${{ steps.get_tag.outputs.tagname }}
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Checkout code
- name: Checkout
uses: actions/checkout@v4
- name: Get tag from Cargo.toml
id: get_tag
- name: Get Docker image tag
id: tag
run: |
VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version')
echo "tagname=v$VERSION-nightly.${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
- uses: rickstaa/action-create-tag@v1
with:
tag: ${{ steps.get_tag.outputs.tagname }}
build-binary:
needs: create-tag
strategy:
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 }}
if [ "${GITHUB_REF_NAME}" == "main" ]; then
TAG="nightly"
else
TAG="${GITHUB_REF_NAME}-nightly"
fi
echo "Using tag $TAG"
echo "tag=$TAG" >> $GITHUB_OUTPUT
build-binaries:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup rust
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross
- name: Build binary
run: cross ${{ matrix.platform.command }} --target ${{ matrix.platform.target }}
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- 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
with:
name: ${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/debug/${{ matrix.platform.bin }}
name: binaries
path: |
cup-linux-amd64
cup-linux-arm64
build-image:
needs: create-tag
needs:
- get-tag
- build-binaries
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
- uses: ./.github/actions/build-image
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64, linux/arm64
push: true
tags: ghcr.io/sergi0g/cup:${{ needs.create-tag.outputs.tagname }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ needs.get-tag.outputs.tag }}
gh-token: ${{ secrets.GITHUB_TOKEN }}
nightly-release:
runs-on: ubuntu-latest
needs: [create-tag, build-image, build-binary]
needs:
- get-tag
- build-binaries
- build-image
steps:
- name: Download arm64 binary
- name: Download binaries
uses: actions/download-artifact@v4
with:
name: cup-linux-aarch64
path: cup-linux-aarch64
name: binaries
path: binaries
- name: Download x86 binary
uses: actions/download-artifact@v4
- uses: pyTooling/Actions/releaser@r0
with:
name: cup-linux-x86_64
path: cup-linux-x86_64
- name: Create nightly release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
prerelease: true
tag_name: ${{ needs.create-tag.outputs.tagname }}
name: ${{ needs.create-tag.outputs.tagname }}
files: |
cup-linux-aarch64/cup-linux-aarch64
cup-linux-x86_64/cup-linux-x86_64
token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ needs.get-tag.outputs.tag }}
rm: true
files: binaries/*

90
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: Release
on:
workflow_dispatch:
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get current tag
id: tag
run: |
TAG=v$(head -n 4 Cargo.toml | grep version | awk '{print $3}' | tr -d '"')
echo "Current tag: $TAG"
echo "tag=$TAG" >> $GITHUB_OUTPUT
build-binaries:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install cross
run: RUSTFLAGS="" cargo install cross --git https://github.com/cross-rs/cross
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- 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
with:
name: binaries
path: |
cup-linux-amd64
cup-linux-arm64
build-image:
needs:
- get-tag
- build-binaries
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: ./.github/actions/build-image
with:
tags: |
${{ needs.get-tag.outputs.tag }}
latest
gh-token: ${{ secrets.GITHUB_TOKEN }}
release:
runs-on: ubuntu-latest
needs: [get-tag, build-image, build-binaries]
steps:
- name: Download binaries
uses: actions/download-artifact@v4
with:
name: binaries
path: binaries
- name: Create release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }}
name: ${{ needs.get-tag.outputs.tag }}
files: binaries/*

10
.gitignore vendored
View File

@@ -1 +1,11 @@
/target
/docs/.next
/docs/node_modules
/docs/out
/src/static
# In case I accidentally commit mine...
cup.json
# Profiling results don't need to be present in the repo
profile.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/)
- 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!

1778
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,36 @@
[package]
name = "cup"
version = "1.1.2"
version = "3.4.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.7", features = ["derive"] }
indicatif = { version = "0.17.8", optional = true }
tokio = {version = "1.38.0", features = ["rt", "rt-multi-thread", "macros"]}
ureq = { version = "2.9.7", features = ["tls"] }
json = "0.12.4"
rayon = "1.10.0"
xitca-web = { version = "0.5.0", optional = true, features = ["logger"] }
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
xitca-web = { version = "0.6.2", optional = true }
liquid = { version = "0.26.6", optional = true }
bollard = "0.16.1"
bollard = "0.18.1"
once_cell = "1.19.0"
http-auth = { version = "0.1.9", features = [] }
http-auth = { version = "0.1.9", default-features = false }
termsize = { version = "0.1.8", optional = true }
regex = "1.10.5"
regex = { version = "1.10.5", default-features = false, features = ["perf"] }
chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true }
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls"] }
futures = "0.3.30"
reqwest-retry = "0.7.0"
reqwest-middleware = "0.3.3"
rustc-hash = "2.0.0"
http-link = "1.0.1"
itertools = "0.14.0"
serde_json = "1.0.133"
serde = "1.0.215"
tokio-cron-scheduler = { version = "0.13.0", default-features = false, optional = true }
envy = "0.4.2"
chrono-tz = "0.10.3"
[features]
default = ["server", "cli"]
server = ["dep:xitca-web", "dep:liquid"]
server = ["dep:xitca-web", "dep:liquid", "dep:chrono", "dep:tokio-cron-scheduler"]
cli = ["dep:indicatif", "dep:termsize"]
[profile.release]
@@ -28,4 +38,4 @@ opt-level = "z"
strip = "symbols"
panic = "abort"
lto = "fat"
codegen-units = 1
codegen-units = 1

View File

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

166
README.md
View File

@@ -1,151 +1,45 @@
# Cup 🥤
![GitHub License](https://img.shields.io/github/license/sergi0g/cup)
![CI Status](https://img.shields.io/github/actions/workflow/status/sergi0g/cup/.github%2Fworkflows%2Fci.yml?label=CI)
![GitHub last commit](https://img.shields.io/github/last-commit/sergi0g/cup)
![GitHub Release](https://img.shields.io/github/v/release/sergi0g/cup)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/sergi0g/cup)
[![Discord](https://img.shields.io/discord/1337705080518086658)](https://discord.gg/jmh5ctzwNG)
Cup is the easiest way to check for container image updates.
![Demo](screenshots/cup.gif)
## Screenshots
![Cup web in light mode](screenshots/web_light.png)
![Cup web in dark mode](screenshots/web_dark.png)
## Features
_If you like this project and/or use Cup, please consider starring the project ⭐. It motivates me to continue working on it and improving it. Plus, you get updates for new releases!_
- 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.
## Screenshots 📷
![Cup web in light mode](screenshots/web_light.png)
![Cup's CLI](screenshots/cup.gif)
## Features ✨
- Extremely fast. Cup takes full advantage of your CPU and is hightly optimized, resulting in lightning fast speed. On my Raspberry Pi 5, it took 3.7 seconds for 58 images!
- Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives)
- Doesn't exhaust any rate limits. This is the original reason I created Cup. It was inspired by [What's up docker?](https://github.com/fmartinou/whats-up-docker) which would always use it up.
- Doesn't exhaust any rate limits. This is the original reason I created Cup. I feel that this feature is especially relevant now with [Docker Hub reducing its pull limits for unauthenticated users](https://docs.docker.com/docker-hub/usage/).
- 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.4 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!
## Installation
## Documentation 📘
You can install Cup in 2 ways: as a docker container (recommended) or as a binary.
### With docker
Requirements: [Docker](https://docs.docker.com/engine/install/)
Difficulty: Easy
- Open a terminal and run `docker pull ghcr.io/sergi0g/cup`. If you're not in the `docker` group, make sure to prefix the command as root.
That's it! Now head over to the Usage section!
### From source
Requirements: [Rust](https://rustup.rs)
Difficulty: Moderate
1. Open a terminal and clone the git repository
```
$ git clone https://github.com/sergi0g/cup
```
2. Change your directory to the repository you just cloned
```
$ cd cup
```
3. Build Cup
```
$ cargo build --release
```
This will build cup with all features by default. If you want to build just the server, you can append `--no-default-features --features cli` or `--no-default-features --features server` depending one what you need.
4. Add the binary to your path for easy access
Consult your shell's documentation on how to do this. For Bash, the following will likely work:
```
$ mv /target/release/cup ~/.local/bin
```
Make sure to reload your shell.
You're now ready to use Cup!
## Usage
### CLI
Cup's CLI provides the `cup check` command.
Basic usage:
```
$ cup check
nginx:alpine Update available
redis:7 Update available
redis:alpine Update available
...
centos:7 Up to date
mcr.microsoft.com/devcontainers/go:0-1.19-bullseye Up to date
rockylinux:9-minimal Up to date
rabbitmq:3.11.9-management Up to date
...
some/deleted:image Unknown
```
If you have a [Nerd Font](https://nerdfonts.com) installed, you can also add icons with the `-i` flag: `cup check -i`
If you want the output to be JSON, use the `-r` flag:
```
$ cup check -r
[{"image":"nginx:alpine","has_update":true},{"image":"rockylinux:9-minimal","has_update":false},{"image":"some/deleted:image","has_update":null}]
```
Checking a single image is also possible:
```
$ cup check node:latest
node:latest has an update available
```
If using the docker image:
```
$ docker run -v /var/run/docker.sock:/var/run/docker.sock cup check
```
### Server
The server provides the `cup serve` command.
Basic usage:
```
$ cup serve
Serving on http://0.0.0.0:8000
Received GET request on /
Received GET request on /json
```
This will launch the server on port 8000. To access it, visit `http://<YOUR_IP>:8000` (replace `<YOUR_IP>` with the IP address of the machine running Cup.)
The URL `http://<YOUR_IP>:8000/json` is also available for usage with integrations.
If you want to use a custom port, use the `-p` flag:
```
$ cup check -p 9000
Serving on http://0.0.0.0:9000
Received GET request on /
Received GET request on /json
```
If using the docker image (replace `<PORT>` with the port you're using (default 8000)):
```
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -p <PORT>:<PORT> cup serve
```
Take a look at https://cup.sergi0g.dev/docs!
## Limitations
Cup is a work in progress. It might not have as many features as What's up Docker. If one of these features is really important for you, please consider using another tool.
Cup is a work in progress. It might not have as many features as other alternatives. If one of these features is really important for you, please consider using another tool.
- ~~Cup currently doesn't support registries which use repositories without slashes. This includes Azure. This problem may sound a bit weird, but it's due to the regex that's used at the moment. This will (hopefully) be fixed in the future.~~
- Cup doesn't support private images. This is on the roadmap. Currently, it just returns unknown for those images.
- Cup cannot trigger your integrations. If you want that to happen automatically, please use What's up docker instead. Cup was created to be simple. The data is there, and it's up to you to retrieve it (e.g. by running `cup check -r` with a cronjob or periodically requesting the `/json` url from the server)
- Cup cannot directly trigger your integrations. If you want that to happen automatically, please use What's up Docker instead. Cup was created to be simple. The data is there, and it's up to you to retrieve it (e.g. by running `cup check -r` with a cronjob or periodically requesting the `/api/v3/json` url from the server).
## Roadmap
Take a sneak peek at what's coming up in future releases on the [roadmap](https://github.com/users/sergi0g/projects/2)!
## Contributing
@@ -158,16 +52,14 @@ Here are some ideas to get you started:
- Help optimize Cup and make it even better!
- Add more features to the web UI
To contribute, fork the repository, make your changes and the submit a pull request.
Note: If you update the UI, please make sure to recompile the CSS with `tailwindcss -mo src/index.css`. You need to have the Tailwind CSS CLI installed ([instructions here](https://tailwindcss.com/docs/installation))
For more information, check the [docs](https://cup.sergi0g.dev/docs/contributing)!
## Support
If you have any questions about Cup, feel free to ask in the [discussions](https://github.com/sergi0g/cup/discussions)!
If you have any questions about Cup, feel free to ask in the [discussions](https://github.com/sergi0g/cup/discussions)! You can also join our [discord server](https://discord.gg/jmh5ctzwNG).
If you find a bug, or want to propose a feature, search for it in the [issues](https://github.com/sergi0g/cup/issues). If there isn't already an open issue, please open one.
## Acknowledgements
Thanks to [What's up Docker?](https://github.com/fmartinou/whats-up-docker) for inspiring this project.
Thanks to [What's up Docker?](https://github.com/getwud/wud) for inspiring this project.

25
build.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/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
# Go back
cd ../
# Run command from argv
$@

97
cup.schema.json Normal file
View File

@@ -0,0 +1,97 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/sergi0g/cup/main/cup.schema.json",
"title": "Cup",
"description": "A schema for Cup's config file",
"type": "object",
"properties": {
"version": {
"type": "integer",
"minimum": 3,
"maximum": 3
},
"agent": {
"type": "boolean",
"description": "Whether or not to enable agent mode. When agent mode is enabled, the server only exposes the API and the web interface is unavailable."
},
"ignore_update_type": {
"type": "string",
"description": "The types of updates to ignore. Ignoring an update type also implies ignoring all update types less specific than it. For example, ignoring patch updates also implies ignoring major and minor updates.",
"enum": [
"none",
"major",
"minor",
"patch"
]
},
"images": {
"type": "object",
"description": "Configuration options for specific images",
"properties": {
"extra": {
"type": "array",
"description": "Extra image references you want Cup to check",
"minItems": 1
},
"exclude": {
"type": "array",
"description": "Image references that should be excluded from the check",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
}
}
},
"refresh_interval": {
"type": "string",
"description": "The interval at which Cup should check for updates. Must be a valid cron expression. Seconds are not optional. Reference: https://github.com/Hexagon/croner-rust#pattern",
"minLength": 11
},
"registries": {
"type": "object",
"description": "Configuration options for specific registries",
"additionalProperties": {
"authentication": {
"description": "An authentication token provided by the registry",
"type": "string",
"minLength": 1
},
"insecure": {
"description": "Whether Cup should connect to the registry insecurely (HTTP) or not. Enable this only if you really need to.",
"type": "boolean"
},
"ignore": {
"description": "Whether or not the registry should be ignored when running Cup",
"type": "boolean"
}
}
},
"socket": {
"type": "string",
"description": "The path to the unix socket you would like Cup to use for communication with the Docker daemon. Useful if you're trying to use Cup with Podman.",
"minLength": 1
},
"servers": {
"type": "object",
"description": "Additional servers to connect to and fetch update data from",
"additionalProperties": {
"type": "string",
"minLength": 1
},
"minProperties": 1
},
"theme": {
"type": "string",
"description": "The theme used by the web UI",
"enum": [
"default",
"blue"
]
}
},
"required": [
"version"
]
}

2
docs/.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
.next
.node_modules

10
docs/.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"overrides": [
{
"files": "src/content/docs/integrations.mdx",
"options": {
"tabWidth": 4
}
}
]
}

37
docs/README.md Normal file
View File

@@ -0,0 +1,37 @@
# Cup Documentation
## Architecture
The docs are built with [Nextra](https://nextra.site). We use [Bun](https://bun.sh) as a package manager and Node.js as a runtime (Next.js and Bun don't play well together at the moment). Docs pages are written in [MDX](https://mdxjs.com) and any custom components are written in TypeScript with TSX.
## Development
Prerequisites:
- A recent Node.js version (22 recommended)
- [Bun](https://bun.sh)
```bash
git clone https://github.com/sergi0g/cup
cd cup/docs
bun install
```
You're ready to go!
## Scripts
The available scripts are:
- `bun dev` starts the development server. Note that making changes to MDX pages will probably require a full reload.
- `bun run build` creates a static production build, ready to be deployed.
- `bun lint` checks for errors in your code.
- `bun fmt` formats your code with Prettier, so it becomes... prettier.
## Contributing
Our documentation is always evolving, so, we constantly need to update this repository with new guides and configuration options. If you have any ideas of a guide or suggestions on how to improve them, feel free to open a pull request or create an issue. All contributions are welcome!
## License
The documentation is licensed under the MIT License. TL;DR — You are free to use, copy, modify, merge, publish, distribute, sublicense, and sell copies of the software. However, the software is provided "as is," without warranty of any kind. You must include the original license in all copies or substantial portions of the software.

1661
docs/bun.lock Normal file

File diff suppressed because it is too large Load Diff

21
docs/eslint.config.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"import/no-anonymous-default-export": "off",
},
},
];
export default eslintConfig;

5
docs/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

20
docs/next.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import nextra from "nextra";
const withNextra = nextra({
defaultShowCopyCode: true,
});
export default withNextra({
output: "export",
transpilePackages: ["geist"],
images: {
unoptimized: true,
remotePatterns: [
{
protocol: "https",
hostname: "raw.githubusercontent.com",
},
],
},
basePath: "",
});

36
docs/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "cup-docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build && pagefind --site out --output-path out/_pagefind",
"start": "next start",
"lint": "next lint",
"fmt": "bun prettier --write ."
},
"dependencies": {
"@tabler/icons-react": "^3.29.0",
"geist": "^1.3.1",
"next": "15.2.4",
"nextra": "^4.1.0",
"nextra-theme-docs": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/postcss": "^4.0.1",
"@types/bun": "^1.2.10",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"eslint": "^9.18.0",
"eslint-config-next": "15.1.5",
"pagefind": "^1.3.0",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.1",
"typescript": "^5.7.3"
}
}

8
docs/postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

BIN
docs/public/cup-og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,27 @@
import { generateStaticParamsFor, importPage } from "nextra/pages";
import { useMDXComponents } from "@/mdx-components";
export const generateStaticParams = generateStaticParamsFor("mdxPath");
interface Props {
params: Promise<{ mdxPath: string[] }>;
}
export async function generateMetadata(props: Props) {
const params = await props.params;
const { metadata } = await importPage(params.mdxPath);
return metadata;
}
/* eslint-disable-next-line */
const Wrapper = useMDXComponents({}).wrapper;
export default async function Page(props: Props) {
const params = await props.params;
const result = await importPage(params.mdxPath);
const { default: MDXContent, toc, metadata } = result;
return (
<Wrapper toc={toc} metadata={metadata}>
<MDXContent {...props} params={params} />
</Wrapper>
);
}

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
docs/src/app/assets/cup.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
import { Icon as IconType } from "@tabler/icons-react";
export function Card({
name,
icon: Icon,
description,
}: {
name: string;
icon: IconType;
description: string;
}) {
return (
<div className="p-4 bg-white dark:bg-black group">
<Icon className="text-black size-7 group-hover:size-9 dark:text-white inline mr-2 transition-[width,height] duration-200" />
<span className="align-middle text-2xl font-bold text-black dark:text-white">
{name}
</span>
<p className="text-xl font-semibold text-neutral-500 dark:text-neutral-500">
{description}
</p>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from "react";
import { clsx } from "clsx";
export function GradientText({
text,
innerClassName,
className,
blur,
}: {
text: string;
innerClassName: string;
className?: string;
blur: number;
}) {
return (
<div className={clsx("relative", className)}>
<p
className={clsx("bg-clip-text text-transparent w-fit", innerClassName)}
>
{text}
</p>
<p
className={clsx(
"pointer-events-none absolute top-0 hidden select-none bg-clip-text text-transparent dark:block",
innerClassName,
)}
style={{ filter: `blur(${blur}px)` }}
>
{text}
</p>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useId } from "react";
const SIZE = 36;
export function GridPattern() {
const id = useId();
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 bottom-0 left-0 right-0 top-0 h-full w-full -z-10 bg-white stroke-neutral-200 dark:stroke-white/10 dark:bg-black"
>
<defs>
<pattern
id={id}
width={SIZE}
height={SIZE}
patternUnits="userSpaceOnUse"
x={-1}
y={-1}
>
<path
d={`M.5 ${SIZE}V.5H${SIZE}`}
fill="none"
/>
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
</svg>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { Head as NextraHead } from "nextra/components";
export function Head() {
return (
<NextraHead>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="#ffffff"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="#111111"
/>
<meta
name="og:image"
content="https://raw.githubusercontent.com/sergi0g/cup/main/docs/public/cup-og.png"
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="https://cup.sergi0g.dev" />
<meta name="apple-mobile-web-app-title" content="Cup" />
</NextraHead>
);
}

View File

@@ -0,0 +1,57 @@
export default function Logo() {
return (
<svg
viewBox="0 0 128 128"
style={{ height: "calc(var(--nextra-navbar-height) * 0.6)" }}
>
<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,116 @@
import React from "react";
import "./styles.css";
import { Browser } from "../Browser";
import { Card } from "../Card";
import {
IconAdjustments,
IconArrowRight,
IconBarrierBlockOff,
IconBolt,
IconFeather,
IconGitMerge,
IconPuzzle,
IconServer,
IconTerminal,
} from "@tabler/icons-react";
import { GitHubIcon } from "nextra/icons";
import { GridPattern } from "../GridPattern";
import { GradientText } from "../GradientText";
import Link from "next/link";
export default async function Home() {
return (
<>
<div className="relative home bg-radial-[ellipse_at_center] from-transparent from-20% to-white dark:to-black">
<GridPattern />
<div className="px-4 pt-16 pb-8 sm:pt-24 lg:px-8">
<div className="flex w-full flex-col items-center justify-between">
<div>
<h1 className="mx-auto max-w-2xl text-center text-6xl leading-none font-extrabold tracking-tighter text-black sm:text-7xl dark:text-white">
The easiest way to manage your
<GradientText
text="container updates."
className="mx-auto w-fit"
innerClassName="bg-linear-to-r/oklch from-blue-500 to-green-500"
blur={30}
/>
</h1>
<h3 className="mx-auto mt-6 max-w-3xl text-center text-xl leading-tight font-medium text-neutral-500 dark:text-neutral-400">
Cup is a small utility with a big impact. Simplify your
container management workflow with fast and efficient update
checking, a full-featured CLI and web interface, and more.
</h3>
</div>
<div className="mt-8 grid w-fit grid-cols-2 gap-4 *:flex *:items-center *:gap-2 *:rounded-lg *:px-3 *:py-2">
<Link
href="/docs"
className="hide-focus group h-full bg-black text-white dark:bg-white dark:text-black"
>
Get started
<IconArrowRight className="ml-auto mr-1 transition-transform duration-300 ease-out group-hover:translate-x-1 group-focus:translate-x-1 dark:!text-black" />
</Link>
<a
href="https://github.com/sergi0g/cup"
target="_blank"
className="hide-focus h-full bg-white dark:bg-black text-nowrap border border-black/15 transition-colors duration-200 ease-in-out hover:border-black/40 dark:border-white/15 hover:dark:border-white/40 hover:dark:shadow-sm focus:dark:border-white/30"
>
Star on GitHub
<GitHubIcon className="ml-auto size-4 md:size-5" />
</a>
</div>
</div>
</div>
<div className="py-10 flex translate-y-32 justify-center" id="hero">
<Browser />
</div>
</div>
<div className="bg-white dark:bg-black py-12 px-8 w-full">
<div className="flex h-full w-full items-center justify-center">
<div className="grid md:grid-cols-2 md:grid-rows-4 lg:grid-cols-4 lg:grid-rows-2 w-full max-w-7xl gap-px border border-transparent bg-black/10 dark:bg-white/10">
<Card
name="Built for speed."
icon={IconBolt}
description="Cup is written in Rust and every release goes through extensive profiling to squeeze out every last drop of performance."
/>
<Card
name="Configurable."
icon={IconAdjustments}
description="Make Cup yours with the extensive configuration options available. Customize and tailor it to your needs."
/>
<Card
name="Extend it."
icon={IconPuzzle}
description="JSON output enables you to connect Cup with your favorite integrations, build automations and more."
/>
<Card
name="CLI available."
icon={IconTerminal}
description="Do you like terminals? Cup has a CLI. Check for updates quickly without spinning up a server."
/>
<Card
name="Multiple servers."
icon={IconServer}
description="Run multiple Cup instances and effortlessly check on them through one web interface."
/>
<Card
name="Unstoppable."
icon={IconBarrierBlockOff}
description="Cup is designed to check for updates without using up any rate limits. 10 images per hour won't be a problem, even with 100 images."
/>
<Card
name="Lightweight."
icon={IconFeather}
description="No need for a powerful server and endless storage. The tiny 5.4 MB binary won't hog your CPU and memory."
/>
<Card
name="Open source."
icon={IconGitMerge}
description="All source code is publicly available in our GitHub repository. We're looking for contributors!"
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,26 @@
article:has(.home) {
padding-inline: 0;
padding-top: 0;
padding-bottom: 0;
}
article div.x\:mt-16:last-child:empty {
margin-top: 0;
}
#hero {
animation-name: hero;
animation-duration: 1500ms;
animation-delay: 500ms;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}
@keyframes hero {
from {
translate: 0 8rem;
}
to {
translate: 0 0;
}
}

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

17
docs/src/app/globals.css Normal file
View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
.nextra-card .tabler-icon:hover {
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.nextra-card .tabler-icon {
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.nextra-card .tabler-icon:is(.dark *) {
color: rgb(229 229 229 / var(--tw-text-opacity));
}
.nextra-card .tabler-icon:is(.dark *):hover {
color: rgb(250 250 250 / var(--tw-text-opacity));
}

55
docs/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { Footer, Layout, Navbar, ThemeSwitch } from "nextra-theme-docs";
import { getPageMap } from "nextra/page-map";
import { GeistSans } from "geist/font/sans";
import "nextra-theme-docs/style.css";
import "./globals.css";
import { Head } from "./components/Head";
import Logo from "./components/Logo";
export const metadata: Metadata = {
title: "Cup",
description: "The easiest way to manage your container updates",
};
const logo = (
<div className="flex items-center">
<Logo />
<h1 className="ml-2 font-bold">Cup</h1>
</div>
);
const navbar = (
<Navbar logo={logo} projectLink="https://github.com/sergi0g/cup" chatLink="https://discord.gg/jmh5ctzwNG">
<ThemeSwitch lite className="cursor-pointer" />
</Navbar>
);
const footer = <Footer> </Footer>;
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
dir="ltr"
suppressHydrationWarning
className={`${GeistSans.className} antialiased`}
>
<Head />
<body>
<Layout
navbar={navbar}
pageMap={await getPageMap()}
footer={footer}
docsRepositoryBase="https://github.com/sergi0g/cup/blob/main/docs"
>
<div>{children}</div>
</Layout>
</body>
</html>
);
}

22
docs/src/app/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { useMDXComponents } from "@/mdx-components";
import { Heading, NextraMetadata } from "nextra";
import Home from "./components/pages/home";
/* eslint-disable-next-line */
const Wrapper = useMDXComponents({}).wrapper;
const toc: Heading[] = [];
export const metadata: NextraMetadata = {
title: "Cup - The easiest way to manage your container updates",
description: "Simple, fast, efficient Docker image update checking",
};
export default function Page() {
return (
// @ts-expect-error This component passes all extra props to the underlying component, but that possibility does not exist in the type declarations. A comment there indicates that passing extra props is intended functionality.
<Wrapper toc={toc} metadata={metadata} className={"x:mx-auto x:flex"}>
<Home />
</Wrapper>
);
}

17
docs/src/content/_meta.ts Normal file
View File

@@ -0,0 +1,17 @@
export default {
index: {
theme: {
sidebar: false,
toc: false,
breadcrumb: false,
pagination: false,
timestamp: false,
layout: "full",
},
display: "hidden",
},
docs: {
type: "page",
title: "Documentation",
},
};

View File

@@ -0,0 +1,5 @@
export default {
installation: {},
usage: {},
configuration: {},
};

View File

@@ -0,0 +1,49 @@
import { Callout } from "nextra/components";
# 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 use 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
```
If you don't have a config, you can use this instead:
```yaml
services:
cup:
image: ghcr.io/sergi0g/cup:latest
container_name: cup # Optional
restart: unless-stopped
command: serve
ports:
- 8000:8000
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
Cup can run with a non-root user, but needs to be in a docker group. Assuming user id of 1000 and `docker` group id of 999 you can add this to the `services.cup` key in the docker compose:
```yaml
user: "1000:999"
```
<Callout>
You can use the command `getent group docker | cut -d: -f3` to find the group id for the docker group.
</Callout>
The compose 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,47 @@
import Image from "next/image";
import screenshot from "@/app/assets/ha-cup-component.png";
# Home Assistant integration
Many thanks to [@bastgau](https://github.com/bastgau) for creating this integration.
## About
The **HA Cup Component** integration for Home Assistant allows you to retrieve update statistics for Docker containers directly from your Home Assistant interface.
With this integration, you can easily track the status of your Docker containers and receive notifications when updates are available.
The following sensors are currently implemented:
<Image
src={screenshot}
alt="Screenshot of Home Assistant showing a card with update information provided by Cup"
/>
## Installation
### Via HACS
1. Open Home Assistant and go to HACS
2. Navigate to "Integrations" and click on "Add a custom repository".
3. Use https://github.com/bastgau/ha-cup-component as the URL
4. Search for "HA Cup Component" and install it.
5. Restart Home Assistant.
### One-click install
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=bastgau&repository=ha-cup-component&category=Integration)
### Manual Installation
1. Download the integration files from the GitHub repository.
2. Place the integration folder in the custom_components directory of Home Assistant.
3. Restart Home Assistant.
## Support & Contributions
If you encounter any issues or wish to contribute to improving this integration, feel free to open an issue or a pull request in the [GitHub repository](https://github.com/bastgau/ha-cup-component).
Support the author:
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/bastgau)

View File

@@ -0,0 +1,79 @@
import Image from "next/image";
import widget1 from "@/app/assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png";
import widget2 from "@/app/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/api/v3/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: updates_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/api/v3/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: updates_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,12 @@
# Agent mode
If you'd like to have only the server API exposed without the dashboard, you can run Cup in agent mode.
Modify your config like this:
```jsonc
{
"agent": true
// Other options
}
```

View File

@@ -0,0 +1,26 @@
import { Callout } from "nextra/components";
# Authentication
Some registries (or specific images) may require you to be authenticated. For those, you can modify `cup.json` like this:
```jsonc
{
"registries": {
"<YOUR_REGISTRY_DOMAIN_1>": {
"authentication": "<YOUR_TOKEN_1>"
// Other options
},
"<YOUR_REGISTRY_DOMAIN_2>" {
"authentication": "<YOUR_TOKEN_2>"
// Other options
},
// ...
}
// Other options
}
```
You can use any registry, like `ghcr.io`, `quay.io`, `gcr.io`, etc.
<Callout emoji="⚠️">For Docker Hub, use `registry-1.docker.io`</Callout>

View File

@@ -0,0 +1,18 @@
import { Callout } from "nextra/components";
# Automatic refresh
Cup can automatically refresh the results when running in server mode. Simply add this to your config:
```jsonc
{
"refresh_interval": "0 */30 * * * *", // Check twice an hour
// Other options
}
```
You can use a cron expression to specify the refresh interval. Note that seconds are not optional. The reference is [here](https://github.com/Hexagon/croner-rust#pattern).
<Callout>
If you use a schedule with absolute time (e.g. every day at 6 AM), make sure to set the `TZ` environment variable to your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List).
</Callout>

View File

@@ -0,0 +1,22 @@
# Ignored registries
If you want to skip checking images from some registries, you can modify your config like this:
```jsonc
{
"registries": {
"<SOME_REGISTRY_DOMAIN_1>": {
"ignore": true
// Other options
},
"<SOME_REGISTRY_DOMAIN_2>" {
"ignore": false
// Other options
},
// ...
}
// Other options
}
```
This configuration option is a bit redundant, since you can achieve the same with [this option](/docs/configuration/include-exclude-images). It's recommended to use that.

View File

@@ -0,0 +1,23 @@
import { Callout } from "nextra/components";
# Ignored update types
To ignore certain update types, you can modify your config like this:
```jsonc
{
"ignore_update_type": "minor"
}
```
Available options are:
- `none`: Do not ignore any update types (default).
- `major`: Ignore major updates.
- `minor`: Ignore major and minor updates.
- `patch`: Ignore major, minor and patch updates.
<Callout emoji="⚠️">
Ignoring an update type also implies ignoring all update types less specific than it.
For example, ignoring patch updates also implies ignoring major and minor updates.
</Callout>

View File

@@ -0,0 +1,35 @@
# Include/Exclude images
If you want to exclude some images (e.g. because they have too many tags and take too long to check), you can add the following to your config:
```jsonc
{
"images": {
"exclude": [
"ghcr.io/immich-app/immich-machine-learning",
"postgres:15"
]
// ...
}
// Other options
}
```
For an image to be excluded, it must start with one of the strings you specify above. That means you could use `ghcr.io` to exclude all images from ghcr.io or `ghcr.io/sergi0g` to exclude all my images (why would you do that?).
If you want Cup to always check some extra images that aren't available locally, you can modify your config like this:
```jsonc
{
"images": {
"extra": [
"mysql:8.0",
"nextcloud:30"
]
// ...
}
// Other options
}
```
Note that you must specify images with version tags, otherwise Cup will exit with an error!

View File

@@ -0,0 +1,144 @@
---
asIndexPage: true
---
import { Steps, Callout, Cards } from "nextra/components";
import {
IconPaint,
IconLockOpen,
IconKey,
IconPlug,
IconServer,
} from "@tabler/icons-react";
# Configuration
## Custom docker socket
Sometimes, there may be a need to specify a custom docker socket. Cup provides the `-s` option for this.
For example, if using Podman, you might do
```bash
$ cup -s /run/user/1000/podman/podman.sock check
```
This option is also available in the configuration file and it's best to put it there.
<Cards.Card
icon={<IconPlug />}
title="Custom Docker socket"
href="/docs/configuration/socket"
/>
## Configuration file
Cup has an option to be configured from a configuration file named `cup.json`.
<Steps>
### Create the configuration file
Create a `cup.json` file somewhere on your system. For binary installs, a path like `~/.config/cup.json` is recommended.
If you're running with Docker, you can create a `cup.json` in the directory you're running Cup and mount it into the container. _In the next section you will need to use the path where you **mounted** the file_
### Configure Cup from the configuration file
Follow the guides below to customize your `cup.json`
<Cards>
<Cards.Card
icon={<IconKey />}
title="Authentication"
href="/docs/configuration/authentication"
/>
<Cards.Card
icon={<IconLockOpen />}
title="Insecure registries"
href="/docs/configuration/insecure-registries"
/>
<Cards.Card
icon={<IconPaint />}
title="Theme"
href="/docs/configuration/theme"
/>
<Cards.Card
icon={<IconServer />}
title="Multiple servers"
href="/docs/configuration/servers"
/>
</Cards>
Here's a full example:
```json
{
"$schema": "https://raw.githubusercontent.com/sergi0g/cup/main/cup.schema.json",
"version": 3,
"images": {
"exclude": ["ghcr.io/immich-app/immich-machine-learning"],
"extra": ["ghcr.io/sergi0g/cup:v3.0.0"]
},
"registries": {
"myregistry.com": {
"authentication": "<YOUR_TOKEN_HERE>"
}
},
"servers": {
"Raspberry Pi": "https://server.local:8000"
},
"theme": "blue"
}
```
<Callout>
If you want autocompletions and error checking for your editor, there is a
JSON schema available. Use it by adding a `"$schema":
"https://raw.githubusercontent.com/sergi0g/cup/main/cup.schema.json"` entry in
your `cup.json` file.
</Callout>
### Run Cup with the new configuration file
To let Cup know that you'd like it to use a custom configuration file, you can use the `-c` flag, followed by the _absolute_ path of the file.
```bash
$ cup -c /home/sergio/.config/cup.json check
```
```bash
$ docker run -tv /var/run/docker.sock:/var/run/docker.sock -v /home/sergio/.config/cup.json:/config/cup.json ghcr.io/sergi0g/cup -c /config/cup.json serve
```
</Steps>
## Environment Variables
Want to make a quick change without editing your `config.json`? Cup also supports some configuration options from environment variables.
Here are the ones currently available:
- `CUP_AGENT` - Agent mode
- `CUP_IGNORE_UPDATE_TYPE` - Ignoring specific update types
- `CUP_REFRESH_INTERVAL` - Automatic refresh
- `CUP_SOCKET` - Socket
- `CUP_THEME` - Theme
Refer to the configuration page for more information on each of these.
Here's an example of a Docker Compose file using them:
```yaml
services:
cup:
image: ghcr.io/sergi0g/cup:latest
command: serve
ports:
- 8000:8000
environment:
- CUP_AGENT: true
- CUP_IGNORE_UPDATE_TYPE: major
- CUP_REFRESH_INTERVAL: "0 */30 * * * *"
- CUP_SOCKET: tcp://localhost:2375
- CUP_THEME: blue
```
<Callout>
Heads up!
Any configuration option you set with environment variables **always** overrides anything in your `cup.json`.
</Callout>

View File

@@ -0,0 +1,32 @@
import { Callout } from "nextra/components";
# Insecure registries
For the best security, Cup only connects to registries over SSL (HTTPS) by default. However, for people running a local registry that doesn't support SSL, this may be a problem.
To solve this problem, you can specify exceptions in your `cup.json`.
Here's what it looks like:
```jsonc
{
"registries": {
"<INSECURE_REGISTRY_1>": {
"insecure": true
// Other options
},
"<INSECURE_REGISTRY_2>" {
"insecure": true
// Other options
},
// ...
}
// Other options
}
```
<Callout emoji="⚠️">
When configuring an insecure registry that doesn't run on port 80, don't
forget to specify the port (i.e. use `localhost:5000` instead of `localhost`
if your registry is running on port `5000`)
</Callout>

View File

@@ -0,0 +1,15 @@
# Multiple servers
Besides checking for local image updates, you might want to be able to view update stats for all your servers running Cup in a central place. If you choose to add more servers to your Cup configuration, Cup will retrieve the current list of updates from your other servers and it will be included in the results.
Just add something like this to your config:
```jsonc
{
"servers": {
"Cool server 1": "http://your-other-server-running-cup:8000",
"Other server": "http://and-another-one:9000"
}
// Other options
}
```

View File

@@ -0,0 +1,19 @@
# Custom socket
If you need to specify a custom Docker socket (e.g. because you're using Podman), you can use the `socket` option. Here's an example:
```jsonc
{
"socket": "/run/user/1000/podman/podman.sock"
// Other options
}
```
You can also specify a TCP socket if you're using a remote Docker host or a [proxy](https://github.com/Tecnativa/docker-socket-proxy):
```jsonc
{
"socket": "tcp://localhost:2375"
// Other options
}
```

View File

@@ -0,0 +1,31 @@
import { Callout } from "nextra/components";
import Image from "next/image";
import blue from "@/app/assets/blue_theme.png";
import neutral from "@/app/assets/hero-dark.png";
# Theme
<Callout emoji="⚠️">This configuration option is only for the server</Callout>
Cup initially had a blue theme which looked like this:
<Image alt="Screenshot of blue theme" src={blue} />
This was replaced by a more neutral theme which is now the default:
<Image alt="Screenshot of neutral theme" src={neutral} />
However, you can get the old theme back by adding the `theme` key to your `cup.json`
Available options are `default` and `blue`.
Here's an example:
```jsonc
{
"theme": "blue"
// Other options
}
```
Note that the difference between the 2 themes is almost impossible to perceive when your system is in light mode.

View File

@@ -0,0 +1,71 @@
import { Steps } from "nextra/components";
# 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+
<Steps>
### 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.
### Clone your fork
```bash
git clone https://github.com/<YOUR_USERNAME>/cup
```
If you use SSH:
```bash
git clone git@github.com:<YOUR_USERNAME>/cup`)
```
### Switch to your newly created branch (e.g. if your branch is called `improve-logging`, run `git checkout improve-logging`)
### Set up the frontend
```bash
$ cd web
$ bun install
$ cd ..
$ ./build.sh
```
</Steps>
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

@@ -0,0 +1,34 @@
import Image from "next/image";
import cup from "@/app/assets/cup.gif";
import { Cards } from "nextra/components";
import { IconBrandDocker, IconPackage } from "@tabler/icons-react";
# Introduction
<Image src={cup} alt="Animated GIF of Cup's CLI in action" unoptimized />
Cup is a lightweight alternative to [What's up Docker?](https://github.com/getwud/wud) written in Rust.
# Features ✨
- 🚀 Extremely fast. Cup takes full advantage of your CPU and is hightly optimized, resulting in lightning fast speed. On my Raspberry Pi 5, it took 3.7 seconds for 58 images!
- Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives)
- Doesn't exhaust any rate limits. This is the original reason I created Cup. It was inspired by [What's up docker?](https://github.com/getwud/wud) which would always use it up.
- Beautiful CLI and web interface for checking on your containers any time.
- The binary is tiny! At the time of writing it's just 5.4 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!
# Installation
<Cards>
<Cards.Card
icon={<IconBrandDocker />}
title="With Docker"
href="/docs/installation/docker"
/>
<Cards.Card
icon={<IconPackage />}
title="As a binary"
href="/docs/installation/binary"
/>
</Cards>

View File

@@ -0,0 +1,8 @@
export default {
docker: {
title: "With Docker",
},
binary: {
title: "As a binary",
},
};

View File

@@ -0,0 +1,28 @@
import { Callout, Cards, Steps } from "nextra/components";
import { IconFileDescription } from "@tabler/icons-react";
# As a binary
## Introduction
This guide will help you install Cup from a binary.
## Installation
<Steps>
### Download binary
Go to https://github.com/sergi0g/cup/releases/latest.
Depending on your system's architecture, choose the binary for your system. For example, for an `x86_64` machine, you should download `cup-x86_64-unknown-linux-musl`
<Callout>
You can use the command `uname -i` to find this
</Callout>
### Add binary to path
Move the binary you downloaded to a directory in your path. You can usually get a list those directories by running `echo $PATH`. On most Linux systems, moving it to `~/.local/bin` is usually enough.
</Steps>
That's it! Cup is ready to be used. Head over to the Usage page to get started.
<br />
<Cards.Card icon={<IconFileDescription />} title="Usage" href="/docs/usage" />

View File

@@ -0,0 +1,27 @@
import { Callout, Cards } from "nextra/components";
import { IconFileDescription } from "@tabler/icons-react";
# With Docker
## Introduction
This guide will help you install Cup as a Docker container. It is the easiest installation method and also makes updating Cup very easy.
## Installation
To get started, open up a terminal and run the following command.
```bash
$ docker pull ghcr.io/sergi0g/cup
```
<Callout emoji="⚠️">
If you aren't a member of the `docker` group, please ensure you run all
commands as a user who is. In most cases, you'll just need to prefix the
`docker` commands with `sudo`
</Callout>
That's it! Cup is ready to be used. Head over to the Usage page to get started.
<br />
<Cards.Card icon={<IconFileDescription />} title="Usage" href="/docs/usage" />

View File

@@ -0,0 +1,84 @@
import { Callout, Cards } from "nextra/components";
import { IconServer, IconTerminal } from "@tabler/icons-react";
# Integrations
At the moment, Cup has no built-in integrations, but it provides an API for the server and JSON output for the CLI, which can enable you to connect Cup to your own integrations.
## JSON data
The data returned from the API or from the CLI is in JSON and looks like this:
```jsonc
{
// Statistics useful for displaying on dashboards.
// You could calculate these yourself based on the rest of the data,
// but they're provided for easier integration with other systems.
"metrics": {
"monitored_images": 5,
"up_to_date": 2,
"updates_available": 3,
"major_updates": 1,
"minor_updates": 0,
"patch_updates": 0,
"other_updates": 2,
"unknown": 0,
},
// A list of image objects with all related information.
"images": [
{
"reference": "ghcr.io/sergi0g/cup:latest",
"parts": {
// The information Cup extracted about the image from the reference. Mostly useful for debugging and the way the web interface works.
"registry": "ghcr.io",
"repository": "sergi0g/cup",
"tag": "latest",
},
"url": "https://github.com/sergi0g/cup", // The URL specified in the "org.opencontainers.image.url" label, otherwise null
"result": {
"has_update": true, // `true` when an image has an update of any kind, `false` when up to date and `null` when unknown.
"info": {
// `null` if up to date
"type": "digest", // Can also be `version` when Cup detects the tag contains a version.
// If `type` is "digest":
"local_digests": [
// A list of local digests present for the image
"sha256:b7168e5f6828cbbd3622fa19965007e4611cf42b5f3c603008377ffd45a4fe00",
],
"remote_digest": "sha256:170f1974d8fc8ca245bcfae5590bc326de347b19719972bf122400fb13dfa42c", // Latest digest available in the registry
// If `type` is "version":
"version_update_type": "major", // Loosely corresponds to SemVer versioning. Can also be `minor` or `patch`.
"new_tag": "v3.3.3", // The tag of the latest image.
},
"error": null, // If checking for the image fails, will be a string with an error message.
},
"time": 869, // Time in milliseconds it took to check for the update. Useful for debugging.
"server": "Lithium", // The name of the server which the image was checked for updates on. `null` if from the current machine.
},
],
}
```
<Callout emoji="⚠️">
Please keep in mind that the above may not always be up to date. New fields
may be added, or some types extended. If you notice that, just open an issue
and they'll be updated. Changes to the JSON data schema will _always_ happen
in a backwards-compatible way. In case backwards-incompatible changes are
made, these docs will be updated. For something more up-to-date, you can
take a look at https://github.com/sergi0g/cup/blob/main/web/src/types.ts
</Callout>
For retrieving the above data, refer to the CLI and server pages:
<Cards>
<Cards.Card icon={<IconTerminal />} title="CLI" href="/docs/usage/cli" />
<Cards.Card
icon={<IconServer />}
title="Server"
href="/docs/usage/server"
/>
</Cards>
## Refresh Cup
If you'd like to fetch the latest information, you can manually trigger a refresh by making a `GET` request to the `/api/v3/refresh` endpoint. Once the request completes, you can fetch the data as described above.

View File

@@ -0,0 +1,25 @@
import { Callout } from "nextra/components";
# Using the latest version
The installation instructions you previously followed describe how to install Cup's stable version.
However, it is only updated when a new release is created, so if you want the latest features, you'll need to install Cup's nightly version.
Cup's nightly version always contains the latest changes in the main branch.
<Callout emoji="⚠️">
There is no guarantee that the nightly version will always work. There may be
breaking changes or a bad commit and it may not work properly. Install nightly
only if you know what you are doing. These instructions will assume you have
the technical know-how to follow them. If you do not, please use the stable
release!
</Callout>
## With Docker
Instead of `ghcr.io/sergi0g/cup`, use `ghcr.io/sergi0g/cup:nightly`
## As a binary
Go to a [nightly workflow run](https://github.com/sergi0g/cup/actions/workflows/nightly.yml) and download the artifact for your system.

View File

@@ -0,0 +1,99 @@
import Image from "next/image";
import cup from "@/app/assets/cup.gif";
import { Callout } from "nextra/components";
# CLI
Cup's CLI provides the `cup check` command.
## Basic Usage
### Check for all updates
```ansi
$ cup check
✓ Done!
~ Local images
╭─────────────────────────────────────────┬──────────────────────────────────┬─────────╮
│Reference │Status │Time (ms)│
├─────────────────────────────────────────┼──────────────────────────────────┼─────────┤
│postgres:15-alpine │Major update (15 → 17) │788 │
│ghcr.io/immich-app/immich-server:v1.118.2│Minor update (1.118.2 → 1.127.0) │2294 │
│ollama/ollama:0.4.1 │Minor update (0.4.1 → 0.5.12) │533 │
│adguard/adguardhome:v0.107.52 │Patch update (0.107.52 → 0.107.57)│1738 │
│jc21/nginx-proxy-manager:latest │Up to date │583 │
│louislam/uptime-kuma:1 │Up to date │793 │
│moby/buildkit:buildx-stable-1 │Up to date │600 │
│tecnativa/docker-socket-proxy:latest │Up to date │564 │
│ubuntu:latest │Up to date │585 │
│wagoodman/dive:latest │Up to date │585 │
│rolebot:latest │Unknown │174 │
╰─────────────────────────────────────────┴──────────────────────────────────┴─────────╯
 INFO ✨ Checked 11 images in 8312ms
```
### Check for updates to specific images
```ansi
$ cup check node:latest
✓ Done!
~ Local images
╭───────────┬────────────────┬─────────╮
│Reference │Status │Time (ms)│
├───────────┼────────────────┼─────────┤
│node:latest│Update available│788 │
╰───────────┴────────────────┴─────────╯
 INFO ✨ Checked 1 images in 310ms
```
```ansi
$ cup check nextcloud:30 postgres:14 mysql:8.0
✓ Done!
~ Local images
╭────────────┬────────────────────────┬─────────╮
│Reference │Status │Time (ms)│
├────────────┼────────────────────────┼─────────┤
│postgres:14 │Major update (14 → 17) │195 │
│mysql:8.0 │Major update (8.0 → 9.2)│382 │
│nextcloud:30│Up to date │585 │
╰────────────┴────────────────────────┴─────────╯
 INFO ✨ Checked 3 images in 769ms
```
## Enable icons
You can also enable icons if you have a [Nerd Font](https://nerdfonts.com) installed.
<Image src={cup} alt="GIF of Cup's CLI" unoptimized />
## JSON output
When integrating Cup with other services (e.g. webhooks or a dashboard), you may find Cup's JSON output functionality useful.
It provides some useful metrics (see [server](/docs/usage/server) for more information), along with a list of images and whether they have an update or not. Note that at the moment it does not match the detailed API the server provides.
```
$ cup check -r
{"metrics":{"monitored_images":26,"up_to_date":2,"updates_available":23,"major_updates":8,"minor_updates":6,"patch_updates":2,"other_updates":7,"unknown":1},"images":{"ghcr.io/immich-app/immich-server:v1.106.4":false,"portainer/portainer-ce:2.20.3-alpine":false,"ghcr.io/runtipi/runtipi:v3.4.1":false,...}}
```
<Callout emoji="⚠️">
When parsing Cup's output, capture only `stdout`, otherwise you might not get
valid JSON (if there are warnings)
</Callout>
## Usage with Docker
If you're using the Docker image, just replace all occurences of `cup` in the examples with `docker run -tv /var/run/docker.sock:/var/run/docker.sock ghcr.io/sergi0g/cup`.
For example, this:
```bash
$ cup check node:latest
```
becomes:
```bash
$ docker run -tv /var/run/docker.sock:/var/run/docker.sock ghcr.io/sergi0g/cup check node:latest
```

View File

@@ -0,0 +1,15 @@
---
asIndexPage: true
---
import { IconServer, IconTerminal } from "@tabler/icons-react";
import { Cards } from "nextra/components";
# Usage
You can use Cup in 2 different ways. As a CLI or as a server. You can learn more about each mode on its corresponding page
<Cards>
<Cards.Card icon={<IconTerminal />} title="CLI" href="/docs/usage/cli" />
<Cards.Card icon={<IconServer />} title="Server" href="/docs/usage/server" />
</Cards>

View File

@@ -0,0 +1,55 @@
import { Callout } from "nextra/components";
# Server
The server provides the `cup serve` command.
## Basic usage
```ansi
$ cup serve
 INFO Starting server, please wait...
 INFO ✨ Checked 8 images in 8862ms
 INFO Ready to start!
 HTTP GET / 200 in 0ms
 HTTP GET /assets/index.js 200 in 3ms
 HTTP GET /assets/index.css 200 in 0ms
 HTTP GET /api/v3/json 200 in 0ms
```
This will launch the server on port `8000`. To access it, visit `http://<YOUR_IP>:8000` (replace `<YOUR_IP>` with the IP address of the machine running Cup.)
<Callout>
The URL `http://<YOUR_IP>:8000/api/v3/json` is also available for usage with integrations.
</Callout>
## Use a different port
Pass the `-p` argument with the port you want to use
```ansi
$ cup serve -p 9000
 INFO Starting server, please wait...
 INFO ✨ Checked 8 images in 8862ms
 INFO Ready to start!
 HTTP GET / 200 in 0ms
 HTTP GET /assets/index.js 200 in 3ms
 HTTP GET /assets/index.css 200 in 0ms
 HTTP GET /api/v3/json 200 in 0ms
```
## Usage with Docker
If you're using the Docker image, just replace all occurences of `cup` in the examples with `docker run -tv /var/run/docker.sock:/var/run/docker.sock -p <PORT>:<PORT> ghcr.io/sergi0g/cup`, where `<PORT>` is the port Cup will be using.
For example, this:
```bash
$ cup serve -p 9000
```
becomes:
```bash
$ docker run -tv /var/run/docker.sock:/var/run/docker.sock -p 9000:9000 ghcr.io/sergi0g/cup serve -p 9000
```

View File

@@ -0,0 +1,13 @@
import { useMDXComponents as getThemeComponents } from "nextra-theme-docs";
import { MDXComponents } from "nextra/mdx-components";
// Get the default MDX components
const themeComponents = getThemeComponents();
// Merge components
export function useMDXComponents(components: MDXComponents) {
return {
...themeComponents,
...components,
};
}

27
docs/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 124 KiB

213
src/check.rs Normal file
View File

@@ -0,0 +1,213 @@
use futures::future::join_all;
use itertools::Itertools;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{
docker::{get_images_from_docker_daemon, get_in_use_images},
http::Client,
registry::{check_auth, get_token},
structs::{image::Image, update::Update},
utils::request::{get_response_body, parse_json},
Context,
};
/// Fetches image data from other Cup instances
async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Vec<Update> {
let mut remote_images = Vec::new();
let handles: Vec<_> = ctx.config.servers
.iter()
.map(|(name, url)| async move {
let base_url = if url.starts_with("http://") || url.starts_with("https://") {
format!("{}/api/v3/", url.trim_end_matches('/'))
} else {
format!("https://{}/api/v3/", url.trim_end_matches('/'))
};
let json_url = base_url.clone() + "json";
if refresh {
let refresh_url = base_url + "refresh";
match client.get(&refresh_url, &[], false).await {
Ok(response) => {
if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to refresh server. Server returned invalid response code: {}", refresh_url, response.status()));
return Vec::new();
}
},
Err(e) => {
ctx.logger.warn(format!("GET {}: Failed to refresh server. {}", refresh_url, e));
return Vec::new();
},
}
}
match client.get(&json_url, &[], false).await {
Ok(response) => {
if response.status() != 200 {
ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. Server returned invalid response code: {}", json_url, response.status()));
return Vec::new();
}
let json = parse_json(&get_response_body(response).await);
ctx.logger.debug(format!("JSON response for {}: {}", name, json));
if let Some(updates) = json["images"].as_array() {
let mut server_updates: Vec<Update> = updates
.iter()
.filter_map(|img| serde_json::from_value(img.clone()).ok())
.collect();
// Add server origin to each image
for update in &mut server_updates {
update.server = Some(name.clone());
update.status = update.get_status();
}
ctx.logger.debug(format!("Updates for {}: {:#?}", name, server_updates));
return server_updates;
}
Vec::new()
}
Err(e) => {
ctx.logger.warn(format!("GET {}: Failed to fetch updates from server. {}", json_url, e));
Vec::new()
},
}
})
.collect();
for mut images in join_all(handles).await {
remote_images.append(&mut images);
}
remote_images
}
/// Returns a list of updates for all images passed in.
pub async fn get_updates(
references: &Option<Vec<String>>, // If a user requested _specific_ references to be checked, this will have a value
refresh: bool,
ctx: &Context,
) -> Vec<Update> {
let client = Client::new(ctx);
// Merge references argument with references from config
let all_references = match &references {
Some(refs) => {
if !ctx.config.images.extra.is_empty() {
refs.clone().extend_from_slice(&ctx.config.images.extra);
}
refs
}
None => &ctx.config.images.extra,
};
// Get local images
ctx.logger.debug("Retrieving images to be checked");
let mut images = get_images_from_docker_daemon(ctx, references).await;
let in_use_images = get_in_use_images(ctx).await;
ctx.logger
.debug(format!("Found {} images in use", in_use_images.len()));
// Complete in_use field
images.iter_mut().for_each(|image| {
if in_use_images.contains(&image.reference) {
image.in_use = true
}
});
// Add extra images from references
if !all_references.is_empty() {
let image_refs: FxHashSet<&String> = images.iter().map(|image| &image.reference).collect();
let extra = all_references
.iter()
.filter(|&reference| !image_refs.contains(reference))
.map(|reference| Image::from_reference(reference))
.collect::<Vec<Image>>();
images.extend(extra);
}
// Get remote images from other servers
let remote_updates = if !ctx.config.servers.is_empty() {
ctx.logger.debug("Fetching updates from remote servers");
get_remote_updates(ctx, &client, refresh).await
} else {
Vec::new()
};
ctx.logger.debug(format!(
"Checking {:?}",
images.iter().map(|image| &image.reference).collect_vec()
));
// Get a list of unique registries our images belong to. We are unwrapping the registry because it's guaranteed to be there.
let registries: Vec<&String> = images
.iter()
.map(|image| &image.parts.registry)
.unique()
.filter(|&registry| match ctx.config.registries.get(registry) {
Some(config) => !config.ignore,
None => true,
})
.collect::<Vec<&String>>();
// Create request client. All network requests share the same client for better performance.
// This client is also configured to retry a failed request up to 3 times with exponential backoff in between.
let client = Client::new(ctx);
// Create a map of images indexed by registry. This solution seems quite inefficient, since each iteration causes a key to be looked up. I can't find anything better at the moment.
let mut image_map: FxHashMap<&String, Vec<&Image>> = FxHashMap::default();
for image in &images {
image_map
.entry(&image.parts.registry)
.or_default()
.push(image);
}
// Retrieve an authentication token (if required) for each registry.
let mut tokens: FxHashMap<&str, Option<String>> = FxHashMap::default();
for registry in registries.clone() {
let credentials = if let Some(registry_config) = ctx.config.registries.get(registry) {
&registry_config.authentication
} else {
&None
};
match check_auth(registry, ctx, &client).await {
Some(auth_url) => {
let token = get_token(
image_map.get(registry).unwrap(),
&auth_url,
credentials,
&client,
)
.await;
tokens.insert(registry, Some(token));
}
None => {
tokens.insert(registry, None);
}
}
}
ctx.logger.debug(format!("Tokens: {:?}", tokens));
let mut handles = Vec::with_capacity(images.len());
// Loop through images check for updates
for image in &images {
let is_ignored = !registries.contains(&&image.parts.registry)
|| ctx
.config
.images
.exclude
.iter()
.any(|item| image.reference.starts_with(item));
if !is_ignored {
let token = tokens.get(image.parts.registry.as_str()).unwrap();
let future = image.check(token.as_deref(), ctx, &client);
handles.push(future);
}
}
// Await all the futures
let images = join_all(handles).await;
let mut updates: Vec<Update> = images.iter().map(|image| image.to_update()).collect();
updates.extend_from_slice(&remote_updates);
updates
}

153
src/config.rs Normal file
View File

@@ -0,0 +1,153 @@
use rustc_hash::FxHashMap;
use serde::Deserialize;
use std::env;
use std::mem;
use std::path::PathBuf;
use crate::error;
// We can't assign `a` to `b` in the loop in `Config::load`, so we'll have to use swap. It looks ugly so now we have a macro for it.
macro_rules! swap {
($a:expr, $b:expr) => {
mem::swap(&mut $a, &mut $b)
};
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Theme {
Default,
Blue,
}
impl Default for Theme {
fn default() -> Self {
Self::Default
}
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateType {
None,
Major,
Minor,
Patch,
}
impl Default for UpdateType {
fn default() -> Self {
Self::None
}
}
#[derive(Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
#[serde(default)]
pub struct RegistryConfig {
pub authentication: Option<String>,
pub insecure: bool,
pub ignore: bool,
}
#[derive(Clone, Deserialize, Default)]
#[serde(default)]
pub struct ImageConfig {
pub extra: Vec<String>,
pub exclude: Vec<String>,
}
#[derive(Clone, Deserialize)]
#[serde(default)]
pub struct Config {
version: u8,
pub agent: bool,
pub ignore_update_type: UpdateType,
pub images: ImageConfig,
pub refresh_interval: Option<String>,
pub registries: FxHashMap<String, RegistryConfig>,
pub servers: FxHashMap<String, String>,
pub socket: Option<String>,
pub theme: Theme,
}
impl Config {
pub fn new() -> Self {
Self {
version: 3,
agent: false,
ignore_update_type: UpdateType::default(),
images: ImageConfig::default(),
refresh_interval: None,
registries: FxHashMap::default(),
servers: FxHashMap::default(),
socket: None,
theme: Theme::Default,
}
}
/// Loads file and env config and merges them
pub fn load(&mut self, path: Option<PathBuf>) -> Self {
let mut config = self.load_file(path);
// Get environment variables with CUP_ prefix
let env_vars: FxHashMap<String, String> =
env::vars().filter(|(k, _)| k.starts_with("CUP_")).collect();
if !env_vars.is_empty() {
if let Ok(mut cfg) = envy::prefixed("CUP_").from_env::<Config>() {
// If we have environment variables, override config.json options
for (key, _) in env_vars {
match key.as_str() {
"CUP_AGENT" => config.agent = cfg.agent,
#[rustfmt::skip]
"CUP_IGNORE_UPDATE_TYPE" => swap!(config.ignore_update_type, cfg.ignore_update_type),
#[rustfmt::skip]
"CUP_REFRESH_INTERVAL" => swap!(config.refresh_interval, cfg.refresh_interval),
"CUP_SOCKET" => swap!(config.socket, cfg.socket),
"CUP_THEME" => swap!(config.theme, cfg.theme),
// The syntax for these is slightly more complicated, not sure if they should be enabled or not. Let's stick to simple types for now.
// "CUP_IMAGES" => swap!(config.images, cfg.images),
// "CUP_REGISTRIES" => swap!(config.registries, cfg.registries),
// "CUP_SERVERS" => swap!(config.servers, cfg.servers),
_ => (), // Maybe print a warning if other CUP_ variables are detected
}
}
}
}
config
}
/// Reads the config from the file path provided and returns the parsed result.
fn load_file(&self, path: Option<PathBuf>) -> Self {
let raw_config = match &path {
Some(path) => std::fs::read_to_string(path),
None => return Self::new(), // Empty config
};
if raw_config.is_err() {
error!(
"Failed to read config file from {}. Are you sure the file exists?",
&path.unwrap().to_str().unwrap()
)
};
self.parse(&raw_config.unwrap()) // We can safely unwrap here
}
/// Parses and validates the config.
fn parse(&self, raw_config: &str) -> Self {
let config: Self = match serde_json::from_str(raw_config) {
Ok(config) => config,
Err(e) => error!("Unexpected error occured while parsing config: {}", e),
};
if config.version != 3 {
error!("You are trying to run Cup with an incompatible config file! Please migrate your config file to the version 3, or if you have already done so, add a `version` key with the value `3`.")
}
config
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,84 +1,127 @@
use bollard::{
secret::ImageSummary,
ClientVersion, Docker,
};
use bollard::{container::ListContainersOptions, models::ImageInspect, ClientVersion, Docker};
#[cfg(feature = "cli")]
use bollard::secret::ImageInspect;
use futures::future::join_all;
use crate::{error, image::Image, utils::split_image};
use crate::{error, structs::image::Image, Context};
fn create_docker_client(socket: Option<String>) -> Docker {
fn create_docker_client(socket: Option<&str>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket {
Some(sock) => Docker::connect_with_local(
&sock,
120,
&ClientVersion {
major_version: 1,
minor_version: 44,
},
),
None => Docker::connect_with_local_defaults(),
Some(sock) => {
if sock.starts_with("unix://") {
Docker::connect_with_unix(
sock,
120,
&ClientVersion {
major_version: 1,
minor_version: 44,
},
)
} else {
Docker::connect_with_http(
sock,
120,
&ClientVersion {
major_version: 1,
minor_version: 44,
},
)
}
}
None => Docker::connect_with_unix_defaults(),
};
match client {
Ok(d) => d,
Err(e) => error!("Failed to connect to docker socket!\n{}", e),
Err(e) => error!("Failed to connect to docker daemon!\n{}", e),
}
}
pub async fn get_images_from_docker_daemon(socket: Option<String>) -> Vec<Image> {
let client: Docker = create_docker_client(socket);
let images: Vec<ImageSummary> = match client.list_images::<String>(None).await {
Ok(images) => images,
/// Retrieves images from Docker daemon. If `references` is Some, return only the images whose references match the ones specified.
pub async fn get_images_from_docker_daemon(
ctx: &Context,
references: &Option<Vec<String>>,
) -> Vec<Image> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
let mut swarm_images = match client.list_services::<String>(None).await {
Ok(services) => services
.iter()
.filter_map(|service| match &service.spec {
Some(service_spec) => match &service_spec.task_template {
Some(task_spec) => match &task_spec.container_spec {
Some(container_spec) => match &container_spec.image {
Some(image) => Image::from_inspect_data(ctx, image),
None => None,
},
None => None,
},
None => None,
},
None => None,
})
.collect(),
Err(_) => Vec::new(),
};
let mut local_images = match references {
Some(refs) => {
let mut inspect_handles = Vec::with_capacity(refs.len());
for reference in refs {
inspect_handles.push(client.inspect_image(reference));
}
let inspects: Vec<ImageInspect> = join_all(inspect_handles)
.await
.iter()
.filter(|inspect| inspect.is_ok())
.map(|inspect| inspect.as_ref().unwrap().clone())
.collect();
inspects
.iter()
.filter_map(|inspect| Image::from_inspect_data(ctx, inspect.clone()))
.collect()
}
None => {
let images = match client.list_images::<String>(None).await {
Ok(images) => images,
Err(e) => {
error!("Failed to retrieve list of images available!\n{}", e)
}
};
images
.iter()
.filter_map(|image| Image::from_inspect_data(ctx, image.clone()))
.collect::<Vec<Image>>()
}
};
local_images.append(&mut swarm_images);
local_images
}
pub async fn get_in_use_images(ctx: &Context) -> Vec<String> {
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
let containers = match client
.list_containers::<String>(Some(ListContainersOptions {
all: true,
..Default::default()
}))
.await
{
Ok(containers) => containers,
Err(e) => {
error!("Failed to retrieve list of images available!\n{}", e)
error!("Failed to retrieve list of containers available!\n{}", e)
}
};
let mut result: Vec<Image> = Vec::new();
for image in images {
if !image.repo_tags.is_empty() && image.repo_digests.len() == 1 {
for t in &image.repo_tags {
let (registry, repository, tag) = split_image(t);
result.push(Image {
registry,
repository,
tag,
digest: Some(
image.repo_digests[0]
.clone()
.split('@')
.collect::<Vec<&str>>()[1]
.to_string(),
),
});
}
}
}
result
}
#[cfg(feature = "cli")]
pub async fn get_image_from_docker_daemon(socket: Option<String>, name: &str) -> Image {
let client: Docker = create_docker_client(socket);
let image: ImageInspect = match client.inspect_image(name).await {
Ok(i) => i,
Err(e) => error!("Failed to retrieve image {} from daemon\n{}", name, e),
};
match image.repo_tags {
Some(_) => (),
None => error!("Image has no tags"), // I think this is actually unreachable
}
match image.repo_digests {
Some(d) => {
let (registry, repository, tag) = split_image(&image.repo_tags.unwrap()[0]);
Image {
registry,
repository,
tag,
digest: Some(d[0].clone().split('@').collect::<Vec<&str>>()[1].to_string()),
}
}
None => error!("No digests found for image {}", name),
}
containers
.iter()
.filter_map(|container| match &container.image {
Some(image) => Some({
if image.contains(":") {
image.clone()
} else {
format!("{image}:latest")
}
}),
None => None,
})
.collect()
}

View File

@@ -1,94 +0,0 @@
use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
use json::object;
use crate::utils::sort_update_vec;
pub fn print_updates(updates: &[(String, Option<bool>)], icons: &bool) {
let sorted_updates = sort_update_vec(updates);
let term_width: usize = termsize::get()
.unwrap_or(termsize::Size { rows: 24, cols: 80 })
.cols as usize;
for update in sorted_updates {
let description = match update.1 {
Some(true) => "Update available",
Some(false) => "Up to date",
None => "Unknown",
};
let icon = if *icons {
match update.1 {
Some(true) => "\u{f0aa} ",
Some(false) => "\u{f058} ",
None => "\u{f059} ",
}
} else {
""
};
let color = match update.1 {
Some(true) => "\u{001b}[38;5;12m",
Some(false) => "\u{001b}[38;5;2m",
None => "\u{001b}[38;5;8m",
};
let dynamic_space =
" ".repeat(term_width - description.len() - icon.len() - update.0.len());
println!(
"{}{}{}{}{}",
color, icon, update.0, dynamic_space, description
);
}
}
pub fn print_raw_updates(updates: &[(String, Option<bool>)]) {
let mut result = json::Array::new();
for update in updates {
result.push(object! {image: update.0.clone(), has_update: update.1});
}
println!("{}", json::stringify(result));
}
pub fn print_update(name: &str, has_update: &Option<bool>) {
let color = match has_update {
Some(true) => "\u{001b}[38;5;12m",
Some(false) => "\u{001b}[38;5;2m",
None => "\u{001b}[38;5;8m",
};
let description = match has_update {
Some(true) => "has an update available",
Some(false) => "is up to date",
None => "wasn't found",
};
println!("{}{} {}", color, name, description);
}
pub fn print_raw_update(name: &str, has_update: &Option<bool>) {
let result = object!{image: name, has_update: *has_update};
println!("{}", json::stringify(result));
}
pub struct Spinner {
spinner: ProgressBar,
}
impl Spinner {
pub fn new() -> Spinner {
let spinner = ProgressBar::new_spinner();
let style: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
let progress_style = ProgressStyle::default_spinner();
spinner.set_style(ProgressStyle::tick_strings(progress_style, style));
spinner.set_message("Checking...");
spinner.enable_steady_tick(Duration::from_millis(50));
Spinner { spinner }
}
pub fn succeed(&self) {
const CHECKMARK: &str = "\u{001b}[32;1m\u{2713}\u{001b}[0m";
let success_message = format!("{} Done!", CHECKMARK);
self.spinner
.set_style(ProgressStyle::with_template("{msg}").unwrap());
self.spinner.finish_with_message(success_message);
}
}

168
src/formatting/mod.rs Normal file
View File

@@ -0,0 +1,168 @@
pub mod spinner;
use rustc_hash::FxHashMap;
use crate::{
structs::{
status::Status,
update::{Update, UpdateInfo},
},
utils::{json::to_simple_json, sort_update_vec::sort_update_vec},
};
pub fn print_updates(updates: &[Update], icons: &bool) {
let sorted_updates = sort_update_vec(updates);
let updates_by_server = {
let mut servers: FxHashMap<&str, Vec<&Update>> = FxHashMap::default();
sorted_updates.iter().for_each(|update| {
let key = update.server.as_deref().unwrap_or("");
match servers.get_mut(&key) {
Some(server) => server.push(update),
None => {
let _ = servers.insert(key, vec![update]);
}
}
});
servers
};
for (server, updates) in updates_by_server {
if server.is_empty() {
println!("\x1b[90;1m~ Local images\x1b[0m")
} else {
println!("\x1b[90;1m~ {}\x1b[0m", server)
}
let (reference_width, status_width, time_width) =
updates.iter().fold((9, 6, 9), |acc, update| {
let reference_length = update.reference.len();
let status_length = update.get_status().to_string().len()
+ match &update.result.info {
UpdateInfo::Version(info) => {
info.current_version.len() + info.new_version.len() + 6
}
_ => 0,
};
let time_length = update.time.to_string().len();
(
if reference_length > acc.0 {
reference_length
} else {
acc.0
},
if status_length > acc.1 {
status_length
} else {
acc.1
},
if time_length > acc.2 {
time_length
} else {
acc.2
},
)
});
println!(
" \x1b[90;1m╭{:─<rw$}{:─<sw$}{:─<tw$}\x1b[0m",
"",
"",
"",
rw = reference_width,
sw = status_width + {
if *icons {
2
} else {
0
}
},
tw = time_width
);
println!(
" \x1b[90;1m│\x1b[36;1m{:<rw$}\x1b[90;1m│\x1b[36;1m{:<sw$}\x1b[90;1m│\x1b[36;1m{:<tw$}\x1b[90;1m│\x1b[0m",
"Reference",
"Status",
"Time (ms)",
rw = reference_width,
sw = status_width + {
if *icons {
2
} else {
0
}
},
tw = time_width
);
println!(
" \x1b[90;1m├{:─<rw$}{:─<sw$}{:─<tw$}\x1b[0m",
"",
"",
"",
rw = reference_width,
sw = status_width + {
if *icons {
2
} else {
0
}
},
tw = time_width
);
for update in updates {
let status = update.get_status();
let icon = if *icons {
match status {
Status::UpToDate => "\u{f058} ",
Status::Unknown(_) => "\u{f059} ",
_ => "\u{f0aa} ",
}
} else {
""
};
let color = match status {
Status::UpdateAvailable | Status::UpdatePatch => "\x1b[34m",
Status::UpdateMinor => "\x1b[33m",
Status::UpdateMajor => "\x1b[31m",
Status::UpToDate => "\x1b[32m",
Status::Unknown(_) => "\x1b[90m",
};
let description = format!(
"{}{}",
status,
match &update.result.info {
UpdateInfo::Version(info) => {
format!(" ({}{})", info.current_version, info.new_version)
}
_ => String::new(),
}
);
println!(
" \x1b[90;1m│\x1b[0m{:<rw$}\x1b[90;1m│\x1b[0m{}{}{:<sw$}\x1b[0m\x1b[90;1m│\x1b[0m{:<tw$}\x1b[90;1m│\x1b[0m",
update.reference,
color,
icon,
description,
update.time,
rw = reference_width,
sw = status_width,
tw = time_width
);
}
println!(
" \x1b[90;1m╰{:─<rw$}{:─<sw$}{:─<tw$}\x1b[0m",
"",
"",
"",
rw = reference_width,
sw = status_width + {
if *icons {
2
} else {
0
}
},
tw = time_width
);
}
}
pub fn print_raw_updates(updates: &[Update]) {
println!("{}", to_simple_json(updates));
}

31
src/formatting/spinner.rs Normal file
View File

@@ -0,0 +1,31 @@
use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
pub struct Spinner {
spinner: ProgressBar,
}
impl Spinner {
#[allow(clippy::new_without_default)]
pub fn new() -> Spinner {
let spinner = ProgressBar::new_spinner();
let style: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
let progress_style = ProgressStyle::default_spinner();
spinner.set_style(ProgressStyle::tick_strings(progress_style, style));
spinner.set_message("Checking...");
spinner.enable_steady_tick(Duration::from_millis(50));
Spinner { spinner }
}
pub fn succeed(&self) {
const CHECKMARK: &str = "\u{001b}[32;1m\u{2713}\u{001b}[0m";
let success_message = format!("{} Done!", CHECKMARK);
self.spinner
.set_style(ProgressStyle::with_template("{msg}").unwrap());
self.spinner.finish_with_message(success_message);
}
}

135
src/http.rs Normal file
View File

@@ -0,0 +1,135 @@
use std::fmt::Display;
use reqwest::Response;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use crate::{error, Context};
pub enum RequestMethod {
GET,
HEAD,
}
impl Display for RequestMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
RequestMethod::GET => "GET",
RequestMethod::HEAD => "HEAD",
})
}
}
/// A struct for handling HTTP requests. Takes care of the repetitive work of checking for errors, etc and exposes a simple interface
pub struct Client {
inner: ClientWithMiddleware,
ctx: Context,
}
impl Client {
pub fn new(ctx: &Context) -> Self {
Self {
inner: ClientBuilder::new(reqwest::Client::new())
.with(RetryTransientMiddleware::new_with_policy(
ExponentialBackoff::builder().build_with_max_retries(3),
))
.build(),
ctx: ctx.clone(),
}
}
async fn request(
&self,
url: &str,
method: RequestMethod,
headers: &[(&str, Option<&str>)],
ignore_401: bool,
) -> Result<Response, String> {
let mut request = match method {
RequestMethod::GET => self.inner.get(url),
RequestMethod::HEAD => self.inner.head(url),
};
for (name, value) in headers {
if let Some(v) = value {
request = request.header(*name, *v)
}
}
match request.send().await {
Ok(response) => {
let status = response.status();
if status == 404 {
let message = format!("{} {}: Not found!", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else if status == 401 {
if ignore_401 {
Ok(response)
} else {
let message = format!("{} {}: Unauthorized! Please configure authentication for this registry or if you have already done so, please make sure it is correct.", method, url);
self.ctx.logger.warn(&message);
Err(message)
}
} else if status == 502 {
let message = format!("{} {}: The registry is currently unavailabile (returned status code 502).", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else if status.as_u16() <= 400 {
Ok(response)
} else {
match method {
RequestMethod::GET => error!(
"{} {}: Unexpected error: {}",
method,
url,
response.text().await.unwrap()
),
RequestMethod::HEAD => error!(
"{} {}: Unexpected error: Recieved status code {}",
method, url, status
),
}
}
}
Err(error) => {
if error.is_connect() {
let message = format!("{} {}: Connection failed!", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else if error.is_timeout() {
let message = format!("{} {}: Connection timed out!", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else if error.is_middleware() {
let message = format!("{} {}: Connection failed after 3 retries!", method, url);
self.ctx.logger.warn(&message);
Err(message)
} else {
error!(
"{} {}: Unexpected error: {}",
method,
url,
error.to_string()
)
}
}
}
}
pub async fn get(
&self,
url: &str,
headers: &[(&str, Option<&str>)],
ignore_401: bool,
) -> Result<Response, String> {
self.request(url, RequestMethod::GET, headers, ignore_401)
.await
}
pub async fn head(
&self,
url: &str,
headers: &[(&str, Option<&str>)],
) -> Result<Response, String> {
self.request(url, RequestMethod::HEAD, headers, false).await
}
}

View File

@@ -1,7 +0,0 @@
#[derive(Clone, Debug)]
pub struct Image {
pub registry: String,
pub repository: String,
pub tag: String,
pub digest: Option<String>,
}

42
src/logging.rs Normal file
View File

@@ -0,0 +1,42 @@
#[macro_export]
macro_rules! error {
($($arg:tt)*) => ({
eprintln!("\x1b[31;1mERROR\x1b[0m {}", format!($($arg)*));
std::process::exit(1);
})
}
/// This struct mostly exists so we can print stuff without passing debug or raw every time.
#[derive(Clone)]
pub struct Logger {
debug: bool,
raw: bool,
}
impl Logger {
pub fn new(debug: bool, raw: bool) -> Self {
Self { debug, raw }
}
pub fn warn(&self, msg: impl AsRef<str>) {
if !self.raw {
eprintln!("\x1b[33;1m WARN\x1b[0m {}", msg.as_ref());
}
}
pub fn info(&self, msg: impl AsRef<str>) {
if !self.raw {
println!("\x1b[36;1m INFO\x1b[0m {}", msg.as_ref());
}
}
pub fn debug(&self, msg: impl AsRef<str>) {
if self.debug {
println!("\x1b[35;1mDEBUG\x1b[0m {}", msg.as_ref());
}
}
pub fn set_raw(&mut self, raw: bool) {
self.raw = raw
}
}

View File

@@ -1,29 +1,26 @@
use check::get_updates;
use clap::{Parser, Subcommand};
use config::Config;
use formatting::spinner::Spinner;
#[cfg(feature = "cli")]
use docker::get_image_from_docker_daemon;
use docker::get_images_from_docker_daemon;
#[cfg(feature = "cli")]
use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner};
use image::Image;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
#[cfg(feature = "cli")]
use registry::get_latest_digest;
use registry::{check_auth, get_latest_digests, get_token};
use formatting::{print_raw_updates, print_updates};
use logging::Logger;
#[cfg(feature = "server")]
use server::serve;
use std::{
collections::{HashMap, HashSet},
sync::Mutex,
};
use utils::unsplit_image;
use std::path::PathBuf;
use std::time::SystemTime;
pub mod check;
pub mod config;
pub mod docker;
#[cfg(feature = "cli")]
pub mod formatting;
pub mod image;
pub mod http;
pub mod logging;
pub mod registry;
#[cfg(feature = "server")]
pub mod server;
pub mod structs;
pub mod utils;
#[derive(Parser)]
@@ -31,133 +28,94 @@ pub mod utils;
struct Cli {
#[arg(short, long, default_value = None)]
socket: Option<String>,
#[arg(short, long, default_value_t = String::new(), help = "Config file path")]
config_path: String,
#[command(subcommand)]
command: Option<Commands>,
#[arg(short, long)]
debug: bool,
#[arg(long)]
refresh: bool,
}
#[derive(Subcommand)]
enum Commands {
#[cfg(feature = "cli")]
Check {
#[arg(default_value = None)]
image: Option<String>,
#[arg(name = "images", default_value = None)]
references: Option<Vec<String>>,
#[arg(short, long, default_value_t = false, help = "Enable icons")]
icons: bool,
#[arg(short, long, default_value_t = false, help = "Output JSON instead of formatted text")]
#[arg(
short,
long,
default_value_t = false,
help = "Output JSON instead of formatted text"
)]
raw: bool,
},
#[cfg(feature = "server")]
Serve {
#[arg(short, long, default_value_t = 8000, help = "Use a different port for the server")]
#[arg(
short,
long,
default_value_t = 8000,
help = "Use a different port for the server"
)]
port: u16,
},
}
pub trait Unique<T> {
// So we can filter vecs for duplicates
fn unique(&mut self);
}
impl<T> Unique<T> for Vec<T>
where
T: Clone + Eq + std::hash::Hash,
{
fn unique(self: &mut Vec<T>) {
let mut seen: HashSet<T> = HashSet::new();
self.retain(|item| seen.insert(item.clone()));
}
#[derive(Clone)]
pub struct Context {
pub config: Config,
pub logger: Logger,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let cfg_path = match cli.config_path.as_str() {
"" => None,
path => Some(PathBuf::from(path)),
};
let mut config = Config::new().load(cfg_path);
if let Some(socket) = cli.socket {
config.socket = Some(socket)
}
let mut ctx = Context {
config,
logger: Logger::new(cli.debug, false),
};
match &cli.command {
#[cfg(feature = "cli")]
Some(Commands::Check { image, icons, raw }) => match image {
Some(name) => {
let has_update = get_update(name, cli.socket).await;
match raw {
true => print_raw_update(name, &has_update),
false => print_update(name, &has_update),
};
Some(Commands::Check {
references,
icons,
raw,
}) => {
let start = SystemTime::now();
if *raw {
ctx.logger.set_raw(true);
}
None => {
match raw {
true => print_raw_updates(&get_all_updates(cli.socket).await),
false => {
let spinner = Spinner::new();
let updates = get_all_updates(cli.socket).await;
spinner.succeed();
print_updates(&updates, icons);
}
};
}
},
match *raw || cli.debug {
true => {
let updates = get_updates(references, cli.refresh, &ctx).await;
print_raw_updates(&updates);
}
false => {
let spinner = Spinner::new();
let updates = get_updates(references, cli.refresh, &ctx).await;
spinner.succeed();
print_updates(&updates, icons);
ctx.logger.info(format!("✨ Checked {} images in {}ms", updates.len(), start.elapsed().unwrap().as_millis()));
}
};
}
#[cfg(feature = "server")]
Some(Commands::Serve { port }) => {
let updates = get_all_updates(cli.socket).await;
let _ = serve(port, &updates).await;
let _ = serve(port, &ctx).await;
}
None => (),
None => error!("Whoops! It looks like you haven't specified a command to run! Try `cup help` to see available options."),
}
}
async fn get_all_updates(socket: Option<String>) -> Vec<(String, Option<bool>)> {
let image_map_mutex: Mutex<HashMap<String, &Option<String>>> = Mutex::new(HashMap::new());
let local_images = get_images_from_docker_daemon(socket).await;
local_images.par_iter().for_each(|image| {
let img = unsplit_image(&image.registry, &image.repository, &image.tag);
image_map_mutex.lock().unwrap().insert(img, &image.digest);
});
let image_map = image_map_mutex.lock().unwrap().clone();
let mut registries: Vec<&String> = local_images
.par_iter()
.map(|image| &image.registry)
.collect();
registries.unique();
let mut remote_images: Vec<Image> = Vec::new();
for registry in registries {
let images: Vec<&Image> = local_images
.par_iter()
.filter(|image| &image.registry == registry)
.collect();
let mut latest_images = match check_auth(registry) {
Some(auth_url) => {
let token = get_token(images.clone(), &auth_url);
get_latest_digests(images, Some(&token))
}
None => get_latest_digests(images, None),
};
remote_images.append(&mut latest_images);
}
let result_mutex: Mutex<Vec<(String, Option<bool>)>> = Mutex::new(Vec::new());
remote_images.par_iter().for_each(|image| {
let img = unsplit_image(&image.registry, &image.repository, &image.tag);
match &image.digest {
Some(d) => {
let r = d != image_map.get(&img).unwrap().as_ref().unwrap();
result_mutex.lock().unwrap().push((img, Some(r)))
}
None => result_mutex.lock().unwrap().push((img, None)),
}
});
let result = result_mutex.lock().unwrap().clone();
result
}
#[cfg(feature = "cli")]
async fn get_update(image: &str, socket: Option<String>) -> Option<bool> {
let local_image = get_image_from_docker_daemon(socket, image).await;
let token = match check_auth(&local_image.registry) {
Some(auth_url) => get_token(vec![&local_image], &auth_url),
None => String::new(),
};
let remote_image = match token.as_str() {
"" => get_latest_digest(&local_image, None),
_ => get_latest_digest(&local_image, Some(&token)),
};
match &remote_image.digest {
Some(d) => Some(d != &local_image.digest.unwrap()),
None => None,
}
}

View File

@@ -1,122 +1,261 @@
use std::sync::Mutex;
use std::time::SystemTime;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use ureq::Error;
use itertools::Itertools;
use http_auth::parse_challenges;
use crate::{
config::UpdateType,
error,
http::Client,
structs::{
image::{DigestInfo, Image, VersionInfo},
version::Version,
},
utils::{
link::parse_link,
request::{
get_protocol, get_response_body, parse_json, parse_www_authenticate, to_bearer_string,
},
time::{elapsed, now},
},
Context,
};
use crate::{error, image::Image};
pub fn check_auth(registry: &str) -> Option<String> {
let response = ureq::get(&format!("https://{}/v2/", registry)).call();
pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Option<String> {
let protocol = get_protocol(registry, &ctx.config.registries);
let url = format!("{}://{}/v2/", protocol, registry);
let response = client.get(&url, &[], true).await;
match response {
Ok(_) => None,
Err(Error::Status(401, response)) => match response.header("www-authenticate") {
Some(challenge) => Some(parse_www_authenticate(challenge)),
None => error!("Server returned invalid response!"),
},
Err(e) => error!("{}", e),
}
}
pub fn get_latest_digest(image: &Image, token: Option<&String>) -> Image {
let mut request = ureq::head(&format!(
"https://{}/v2/{}/manifests/{}",
&image.registry, &image.repository, &image.tag
));
if let Some(t) = token {
request = request.set("Authorization", &format!("Bearer {}", t));
}
let raw_response = match request
.set("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json")
.call()
{
Ok(response) => response,
Err(Error::Status(401, response)) => {
if token.is_some() {
error!("Failed to authenticate with given token!\n{}", token.unwrap())
Ok(response) => {
let status = response.status();
if status == 401 {
match response.headers().get("www-authenticate") {
Some(challenge) => Some(parse_www_authenticate(challenge.to_str().unwrap())),
None => error!(
"Unauthorized to access registry {} and no way to authenticate was provided",
registry
),
}
} else {
return get_latest_digest(
image,
Some(&get_token(
vec![image],
&parse_www_authenticate(response.header("www-authenticate").unwrap()),
)),
);
None
}
}
Err(Error::Status(_, _)) => {
return Image {
digest: None,
..image.clone()
}
}
Err(ureq::Error::Transport(e)) => error!("Failed to send request!\n{}", e),
};
match raw_response.header("docker-content-digest") {
Some(digest) => Image {
digest: Some(digest.to_string()),
..image.clone()
},
None => error!("Server returned invalid response! No docker-content-digest!"),
Err(_) => None,
}
}
pub fn get_latest_digests(images: Vec<&Image>, token: Option<&String>) -> Vec<Image> {
let result: Mutex<Vec<Image>> = Mutex::new(Vec::new());
images.par_iter().for_each(|&image| {
let digest = get_latest_digest(image, token).digest;
result.lock().unwrap().push(Image {
digest,
pub async fn get_latest_digest(
image: &Image,
token: Option<&str>,
ctx: &Context,
client: &Client,
) -> Image {
ctx.logger
.debug(format!("Checking for digest update to {}", image.reference));
let start = SystemTime::now();
let protocol = get_protocol(&image.parts.registry, &ctx.config.registries);
let url = format!(
"{}://{}/v2/{}/manifests/{}",
protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag
);
let authorization = to_bearer_string(&token);
let headers = [("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")), ("Authorization", authorization.as_deref())];
let response = client.head(&url, &headers).await;
let time = start.elapsed().unwrap().as_millis() as u32;
ctx.logger.debug(format!(
"Checked for digest update to {} in {}ms",
image.reference, time
));
match response {
Ok(res) => match res.headers().get("docker-content-digest") {
Some(digest) => {
let local_digests = match &image.digest_info {
Some(data) => data.local_digests.clone(),
None => return image.clone(),
};
Image {
digest_info: Some(DigestInfo {
remote_digest: Some(digest.to_str().unwrap().to_string()),
local_digests,
}),
time_ms: image.time_ms + time,
..image.clone()
}
}
None => error!(
"Server returned invalid response! No docker-content-digest!\n{:#?}",
res
),
},
Err(error) => Image {
error: Some(error),
time_ms: image.time_ms + time,
..image.clone()
});
});
let r = result.lock().unwrap().clone();
r
},
}
}
pub fn get_token(images: Vec<&Image>, auth_url: &str) -> String {
let mut final_url = auth_url.to_owned();
pub async fn get_token(
images: &[&Image],
auth_url: &str,
credentials: &Option<String>,
client: &Client,
) -> String {
let mut url = auth_url.to_owned();
for image in images {
final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository);
url = format!("{}&scope=repository:{}:pull", url, image.parts.repository);
}
let raw_response = match ureq::get(&final_url)
.set("Accept", "application/vnd.oci.image.index.v1+json")
.call()
{
Ok(response) => match response.into_string() {
Ok(res) => res,
Err(e) => {
error!("Failed to parse response into string!\n{}", e)
}
},
Err(e) => {
error!("Token request failed!\n{}", e)
}
let authorization = credentials.as_ref().map(|creds| format!("Basic {}", creds));
let headers = [("Authorization", authorization.as_deref())];
let response = client.get(&url, &headers, false).await;
let response_json = match response {
Ok(response) => parse_json(&get_response_body(response).await),
Err(_) => error!("GET {}: Request failed!", url),
};
let parsed_token_response = match json::parse(&raw_response) {
Ok(parsed) => parsed,
Err(e) => {
error!("Failed to parse server response\n{}", e)
}
};
parsed_token_response["token"].to_string()
response_json["token"].as_str().unwrap().to_string()
}
fn parse_www_authenticate(www_auth: &str) -> String {
let challenges = parse_challenges(www_auth).unwrap();
if !challenges.is_empty() {
let challenge = &challenges[0];
if challenge.scheme == "Bearer" {
format!(
"{}?service={}",
challenge.params[0].1.as_escaped(),
challenge.params[1].1.as_escaped()
)
} else {
error!("Unsupported scheme {}", &challenge.scheme)
pub async fn get_latest_tag(
image: &Image,
base: &Version,
token: Option<&str>,
ctx: &Context,
client: &Client,
) -> Image {
ctx.logger
.debug(format!("Checking for tag update to {}", image.reference));
let start = now();
let protocol = get_protocol(&image.parts.registry, &ctx.config.registries);
let url = format!(
"{}://{}/v2/{}/tags/list",
protocol, &image.parts.registry, &image.parts.repository,
);
let authorization = to_bearer_string(&token);
let headers = [
("Accept", Some("application/json")),
("Authorization", authorization.as_deref()),
];
let mut tags: Vec<Version> = Vec::new();
let mut next_url = Some(url);
while next_url.is_some() {
ctx.logger.debug(format!(
"{} has extra tags! Current number of valid tags: {}",
image.reference,
tags.len()
));
let (new_tags, next) = match get_extra_tags(
&next_url.unwrap(),
&headers,
base,
&image.version_info.as_ref().unwrap().format_str,
ctx,
client,
)
.await
{
Ok(t) => t,
Err(message) => {
return Image {
error: Some(message),
time_ms: image.time_ms + elapsed(start),
..image.clone()
}
}
};
tags.extend_from_slice(&new_tags);
next_url = next;
}
let tag = tags.iter().max();
ctx.logger.debug(format!(
"Checked for tag update to {} in {}ms",
image.reference,
elapsed(start)
));
match tag {
Some(t) => {
if t == base && image.digest_info.is_some() {
// Tags are equal so we'll compare digests
ctx.logger.debug(format!(
"Tags for {} are equal, comparing digests.",
image.reference
));
get_latest_digest(
&Image {
version_info: None, // Overwrite previous version info, since it isn't useful anymore (equal tags means up to date and an image is truly up to date when its digests are up to date, and we'll be checking those anyway)
time_ms: image.time_ms + elapsed(start),
..image.clone()
},
token,
ctx,
client,
)
.await
} else {
Image {
version_info: Some(VersionInfo {
latest_remote_tag: Some(t.clone()),
..image.version_info.as_ref().unwrap().clone()
}),
time_ms: image.time_ms + elapsed(start),
..image.clone()
}
}
}
} else {
error!("No challenge provided");
None => error!(
"Image {} has no remote version tags! Local tag: {}",
image.reference, image.parts.tag
),
}
}
pub async fn get_extra_tags(
url: &str,
headers: &[(&str, Option<&str>)],
base: &Version,
format_str: &str,
ctx: &Context,
client: &Client,
) -> Result<(Vec<Version>, Option<String>), String> {
let response = client.get(url, headers, false).await;
match response {
Ok(res) => {
let next_url = res
.headers()
.get("Link")
.map(|link| parse_link(link.to_str().unwrap(), url));
let response_json = parse_json(&get_response_body(res).await);
let result = response_json["tags"]
.as_array()
.unwrap()
.iter()
.filter_map(|tag| Version::from_tag(tag.as_str().unwrap()))
.filter(|(tag, format_string)| match (base.minor, tag.minor) {
(Some(_), Some(_)) | (None, None) => {
matches!((base.patch, tag.patch), (Some(_), Some(_)) | (None, None))
&& format_str == *format_string
}
_ => false,
})
.filter_map(|(tag, _)| match ctx.config.ignore_update_type {
UpdateType::None => Some(tag),
UpdateType::Major => Some(tag).filter(|tag| base.major == tag.major),
UpdateType::Minor => {
Some(tag).filter(|tag| base.major == tag.major && base.minor == tag.minor)
}
UpdateType::Patch => Some(tag).filter(|tag| {
base.major == tag.major
&& base.minor == tag.minor
&& base.patch == tag.patch
}),
})
.dedup()
.collect();
Ok((result, next_url))
}
Err(message) => Err(message),
}
}

View File

@@ -1,104 +1,301 @@
use std::sync::Mutex;
use std::{env, sync::Arc};
use liquid::{object, Object};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use chrono::Local;
use chrono_tz::Tz;
use liquid::{object, Object, ValueView};
use rustc_hash::FxHashMap;
use serde_json::Value;
use tokio::sync::Mutex;
use tokio_cron_scheduler::{Job, JobScheduler};
use xitca_web::{
body::ResponseBody,
handler::{handler_service, state::StateOwn},
http::WebResponse,
bytes::Bytes,
error::Error,
handler::{handler_service, path::PathRef, state::StateRef},
http::{StatusCode, WebResponse},
route::get,
App,
middleware::Logger
service::Service,
App, WebContext,
};
const RAW_TEMPLATE: &str = include_str!("static/template.liquid");
const STYLE: &str = include_str!("static/index.css");
const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico");
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png");
use crate::{
check::get_updates,
config::Theme,
error,
structs::update::Update,
utils::{
json::{to_full_json, to_simple_json},
sort_update_vec::sort_update_vec,
time::{elapsed, now},
},
Context,
};
pub async fn serve(port: &u16, updates: &[(String, Option<bool>)]) -> std::io::Result<()> {
println!("Serving on http://0.0.0.0:{}", port);
App::new()
.with_state(updates.to_owned())
.at("/", get(handler_service(home)))
.at("/json", get(handler_service(json)))
.at("/favicon.ico", handler_service(favicon_ico)) // These aren't pretty but this is xitca-web...
.at("/favicon.svg", handler_service(favicon_svg))
.at("/apple-touch-icon.png", handler_service(apple_touch_icon))
.enclosed(Logger::new())
const HTML: &str = include_str!("static/index.html");
const JS: &str = include_str!("static/assets/index.js");
const CSS: &str = include_str!("static/assets/index.css");
const FAVICON_ICO: Bytes = Bytes::from_static(include_bytes!("static/favicon.ico"));
const FAVICON_SVG: Bytes = Bytes::from_static(include_bytes!("static/favicon.svg"));
const APPLE_TOUCH_ICON: Bytes = Bytes::from_static(include_bytes!("static/apple-touch-icon.png"));
const SORT_ORDER: [&str; 8] = [
"monitored_images",
"updates_available",
"major_updates",
"minor_updates",
"patch_updates",
"other_updates",
"up_to_date",
"unknown",
]; // For Liquid rendering
pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
ctx.logger.info("Starting server, please wait...");
let data = ServerData::new(ctx).await;
let scheduler = JobScheduler::new().await.unwrap();
let data = Arc::new(Mutex::new(data));
let data_copy = data.clone();
let tz = env::var("TZ")
.map(|tz| tz.parse().unwrap_or(Tz::UTC))
.unwrap_or(Tz::UTC);
if let Some(interval) = &ctx.config.refresh_interval {
scheduler
.add(
match Job::new_async_tz(
interval,
tz,
move |_uuid, _lock| {
let data_copy = data_copy.clone();
Box::pin(async move {
data_copy.lock().await.refresh().await;
})
},
) {
Ok(job) => job,
Err(e) => match e {
tokio_cron_scheduler::JobSchedulerError::ParseSchedule => error!(
"Failed to parse cron schedule: {}. Please ensure it is valid!",
interval
),
e => error!(
"An unexpected error occured while scheduling automatic refresh: {}",
e
),
},
},
)
.await
.unwrap();
}
scheduler.start().await.unwrap();
ctx.logger.info("Ready to start!");
let mut app_builder = App::new()
.with_state(data)
.at("/api/v2/json", get(handler_service(api_simple)))
.at("/api/v3/json", get(handler_service(api_full)))
.at("/api/v2/refresh", get(handler_service(refresh)))
.at("/api/v3/refresh", get(handler_service(refresh)));
if !ctx.config.agent {
app_builder = app_builder
.at("/", get(handler_service(_static)))
.at("/*", get(handler_service(_static)));
}
match app_builder
.enclosed_fn(logger)
.serve()
.bind(format!("0.0.0.0:{}", port))?
.run()
.wait()
.bind(format!("0.0.0.0:{}", port))
{
Ok(r) => r,
Err(_) => error!("Failed to bind to port {}. Is it in use?", port),
}
.run()
.wait()
}
async fn home(
updates: StateOwn<Vec<(String, Option<bool>)>>,
) -> WebResponse {
let template = liquid::ParserBuilder::with_stdlib()
.build()
async fn _static(data: StateRef<'_, Arc<Mutex<ServerData>>>, path: PathRef<'_>) -> WebResponse {
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 api_simple(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::builder()
.header("Content-Type", "application/json")
.body(ResponseBody::from(
data.lock().await.simple_json.clone().to_string(),
))
.unwrap()
.parse(RAW_TEMPLATE)
.unwrap();
let images = updates
.0
.par_iter()
.map(|(name, image)| match image {
Some(value) => {
if *value {
object!({"name": name, "status": "update-available"})
} else {
object!({"name": name, "status": "up-to-date"})
}
async fn api_full(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::builder()
.header("Content-Type", "application/json")
.body(ResponseBody::from(
data.lock().await.full_json.clone().to_string(),
))
.unwrap()
}
async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
data.lock().await.refresh().await;
WebResponse::new(ResponseBody::from("OK"))
}
struct ServerData {
template: String,
raw_updates: Vec<Update>,
simple_json: Value,
full_json: Value,
ctx: Context,
theme: &'static str,
}
impl ServerData {
async fn new(ctx: &Context) -> Self {
let mut s = Self {
ctx: ctx.clone(),
template: String::new(),
simple_json: Value::Null,
full_json: Value::Null,
raw_updates: Vec::new(),
theme: "neutral",
};
s.refresh().await;
s
}
async fn refresh(&mut self) {
let start = now();
if !self.raw_updates.is_empty() {
self.ctx.logger.info("Refreshing data");
}
let updates = sort_update_vec(&get_updates(&None, true, &self.ctx).await);
self.ctx.logger.info(format!(
"✨ Checked {} images in {}ms",
updates.len(),
elapsed(start)
));
self.raw_updates = updates;
let template = liquid::ParserBuilder::with_stdlib()
.build()
.unwrap()
.parse(HTML)
.unwrap();
self.simple_json = to_simple_json(&self.raw_updates);
self.full_json = to_full_json(&self.raw_updates);
let last_updated = Local::now();
self.simple_json["last_updated"] = last_updated
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string()
.into();
self.full_json["last_updated"] = self.simple_json["last_updated"].clone();
self.theme = match &self.ctx.config.theme {
Theme::Default => "neutral",
Theme::Blue => "gray",
};
let mut metrics = self.simple_json["metrics"]
.as_object()
.unwrap()
.iter()
.map(|(key, value)| liquid::object!({ "name": key, "value": value }))
.collect::<Vec<_>>();
metrics.sort_unstable_by(|a, b| {
SORT_ORDER
.iter()
.position(|i| i == &a["name"].to_kstr().as_str())
.unwrap()
.cmp(
&SORT_ORDER
.iter()
.position(|i| i == &b["name"].to_kstr().as_str())
.unwrap(),
)
});
let mut servers: FxHashMap<&str, Vec<Object>> = FxHashMap::default();
self.raw_updates.iter().for_each(|update| {
let key = update.server.as_deref().unwrap_or("");
match servers.get_mut(&key) {
Some(server) => server.push(
object!({"name": update.reference, "status": update.get_status().to_string()}),
),
None => {
let _ = servers.insert(key, vec![object!({"name": update.reference, "status": update.get_status().to_string()})]);
}
}
None => object!({"name": name, "status": "unknown"}),
})
.collect::<Vec<Object>>();
let uptodate = images
.par_iter()
.filter(|&o| o["status"] == "up-to-date")
.collect::<Vec<&Object>>()
.len();
let updatable = images
.par_iter()
.filter(|&o| o["status"] == "update-available")
.collect::<Vec<&Object>>()
.len();
let unknown = images
.par_iter()
.filter(|&o| o["status"] == "unknown")
.collect::<Vec<&Object>>()
.len();
let globals = object!({
"metrics": [{"name": "Monitored images", "value": images.len()}, {"name": "Up to date", "value": uptodate}, {"name": "Updates available", "value": updatable}, {"name": "Unknown", "value": unknown}],
"images": images,
"style": STYLE
});
let result = template.render(&globals).unwrap();
WebResponse::new(ResponseBody::from(result))
});
let globals = object!({
"metrics": metrics,
"servers": servers,
"server_ids": servers.into_keys().collect::<Vec<&str>>(),
"last_updated": last_updated.format("%Y-%m-%d %H:%M:%S").to_string(),
"theme": &self.theme
});
self.template = template.render(&globals).unwrap();
}
}
async fn json(
updates: StateOwn<Vec<(String, Option<bool>)>>
) -> WebResponse {
let result_mutex: Mutex<json::object::Object> = Mutex::new(json::object::Object::new());
updates.par_iter().for_each(|image| match image.1 {
Some(b) => result_mutex.lock().unwrap().insert(&image.0, json::from(b)),
None => result_mutex.lock().unwrap().insert(&image.0, json::Null),
});
let result = json::stringify(result_mutex.lock().unwrap().clone());
WebResponse::new(ResponseBody::from(result))
async fn logger<S, C, B>(next: &S, ctx: WebContext<'_, C, B>) -> Result<WebResponse, Error<C>>
where
S: for<'r> Service<WebContext<'r, C, B>, Response = WebResponse, Error = Error<C>>,
{
let start = now();
let request = ctx.req();
let method = request.method().to_string();
let url = request.uri().to_string();
if &method != "GET" {
// We only allow GET requests
log(&method, &url, 405, elapsed(start));
Err(Error::from(StatusCode::METHOD_NOT_ALLOWED))
} else {
let res = next.call(ctx).await?;
let status = res.status().as_u16();
log(&method, &url, status, elapsed(start));
Ok(res)
}
}
async fn favicon_ico() -> WebResponse {
WebResponse::new(ResponseBody::from(FAVICON_ICO))
fn log(method: &str, url: &str, status: u16, time: u32) {
let color = {
if status == 200 {
"\x1b[32m"
} else {
"\x1b[31m"
}
};
println!(
"\x1b[94;1m HTTP \x1b[0m\x1b[32m{}\x1b[0m {} {}{}\x1b[0m in {}ms",
method, url, color, status, time
)
}
async fn favicon_svg() -> WebResponse {
WebResponse::new(ResponseBody::from(FAVICON_SVG))
}
async fn apple_touch_icon() -> WebResponse {
WebResponse::new(ResponseBody::from(APPLE_TOUCH_ICON))
}

File diff suppressed because one or more lines are too long

View File

@@ -1,197 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.svg">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<style>
{{ style }}
</style>
<style>
/* Kinda hacky, but thank you https://geary.co/internal-borders-css-grid/ */
.gi {
position: relative;
height: 100%;
}
.gi::before,
.gi::after {
content: '';
position: absolute;
background-color: #e5e7eb;
z-index: 1;
}
@media (prefers-color-scheme: dark) {
.gi::before,
.gi::after {
background-color: #1f2937;
}
}
.gi::before {
inline-size: 1px;
block-size: 100vh;
inset-inline-start: -0.125rem;
}
.gi::after {
inline-size: 100vw;
block-size: 1px;
inset-inline-start: 0;
inset-block-start: -0.12rem;
}
@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>
</head>
<body>
<div class="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div class="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full">
<div class="max-w-[48rem] mx-auto h-full my-8">
<div class="flex items-center gap-1">
<h1 class="text-6xl font-bold dark:text-white">Cup</h1>
<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-gray-900 rounded-md my-8">
<dl class="lg:grid-cols-4 md:grid-cols-2 gap-1 grid-cols-1 grid overflow-hidden *:relative">
{% for metric in metrics %}
<div class="gi">
<div class="xl:px-8 px-6 py-4 gap-y-2 gap-x-4 justify-between align-baseline flex flex-col h-full">
<dt class="text-gray-500 dark:text-gray-400 leading-6 font-medium">{{ metric.name }}</dt>
<dd class="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl flex-none w-full">
{{ metric.value }}
</dd>
</div>
</div>
{% endfor %}
</dl>
</div>
<div class="shadow-sm bg-white dark:bg-gray-900 rounded-md my-8">
<ul class="*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-gray-800 divide-y dark:text-white">
{% for image in images %}
<li>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M21 16.008v-8.018a1.98 1.98 0 0 0 -1 -1.717l-7 -4.008a2.016 2.016 0 0 0 -2 0l-7 4.008c-.619 .355 -1 1.01 -1 1.718v8.018c0 .709 .381 1.363 1 1.717l7 4.008a2.016 2.016 0 0 0 2 0l7 -4.008c.619 -.355 1 -1.01 1 -1.718z" />
<path d="M12 22v-10" />
<path d="M12 12l8.73 -5.04" />
<path d="M3.27 6.96l8.73 5.04" />
</svg>
{{ image.name }}
{% if image.status == 'up-to-date' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="text-green-500 ml-auto"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z" />
</svg>
{% elsif image.status == 'update-available' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="text-blue-500 ml-auto"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-4.98 3.66l-.163 .01l-.086 .016l-.142 .045l-.113 .054l-.07 .043l-.095 .071l-.058 .054l-4 4l-.083 .094a1 1 0 0 0 1.497 1.32l2.293 -2.293v5.586l.007 .117a1 1 0 0 0 1.993 -.117v-5.585l2.293 2.292l.094 .083a1 1 0 0 0 1.32 -1.497l-4 -4l-.082 -.073l-.089 -.064l-.113 -.062l-.081 -.034l-.113 -.034l-.112 -.02l-.098 -.006z" />
</svg>
{% elsif image.status == 'unknown' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="text-gray-500 ml-auto"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 2c5.523 0 10 4.477 10 10a10 10 0 0 1 -19.995 .324l-.005 -.324l.004 -.28c.148 -5.393 4.566 -9.72 9.996 -9.72zm0 13a1 1 0 0 0 -.993 .883l-.007 .117l.007 .127a1 1 0 0 0 1.986 0l.007 -.117l-.007 -.127a1 1 0 0 0 -.993 -.883zm1.368 -6.673a2.98 2.98 0 0 0 -3.631 .728a1 1 0 0 0 1.44 1.383l.171 -.18a.98 .98 0 0 1 1.11 -.15a1 1 0 0 1 -.34 1.886l-.232 .012a1 1 0 0 0 .111 1.994a3 3 0 0 0 1.371 -5.673z" />
</svg>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</body>
</html>

240
src/structs/image.rs Normal file
View File

@@ -0,0 +1,240 @@
use crate::{
error,
http::Client,
registry::{get_latest_digest, get_latest_tag},
structs::{status::Status, version::Version},
utils::reference::split,
Context,
};
use super::{
inspectdata::InspectData,
parts::Parts,
update::{DigestUpdateInfo, Update, UpdateInfo, UpdateResult, VersionUpdateInfo},
};
#[derive(Clone, PartialEq)]
#[cfg_attr(test, derive(Debug))]
pub struct DigestInfo {
pub local_digests: Vec<String>,
pub remote_digest: Option<String>,
}
#[derive(Clone, PartialEq)]
#[cfg_attr(test, derive(Debug))]
pub struct VersionInfo {
pub current_tag: Version,
pub latest_remote_tag: Option<Version>,
pub format_str: String,
}
/// Image struct that contains all information that may be needed by a function working with an image.
/// It's designed to be passed around between functions
#[derive(Clone, PartialEq, Default)]
#[cfg_attr(test, derive(Debug))]
pub struct Image {
pub reference: String,
pub parts: Parts,
pub url: Option<String>,
pub digest_info: Option<DigestInfo>,
pub version_info: Option<VersionInfo>,
pub in_use: bool,
pub error: Option<String>,
pub time_ms: u32,
}
impl Image {
/// Creates and populates the fields of an Image object based on the ImageSummary from the Docker daemon
pub fn from_inspect_data<T: InspectData>(ctx: &Context, image: T) -> Option<Self> {
let tags = image.tags().unwrap();
let digests = image.digests().unwrap();
if !tags.is_empty() && !digests.is_empty() {
let reference = tags[0].clone();
if reference.contains('@') {
return None; // As far as I know, references that contain @ are either manually pulled by the user or automatically created because of swarm. In the first case AFAICT we can't know what tag was originally pulled, so we'd have to make assumptions and I've decided to remove this. The other case is already handled seperately, so this also ensures images aren't displayed twice, once with and once without a digest.
};
let (registry, repository, tag) = split(&reference);
let version_tag = Version::from_tag(&tag);
let local_digests = digests
.iter()
.filter_map(
|digest| match digest.split('@').collect::<Vec<&str>>().get(1) {
Some(digest) => Some(digest.to_string()),
None => {
ctx.logger.warn(format!(
"Ignoring invalid digest {} for image {}!",
digest, reference
));
None
}
},
)
.collect();
Some(Self {
reference,
parts: Parts {
registry,
repository,
tag,
},
url: image.url(),
digest_info: Some(DigestInfo {
local_digests,
remote_digest: None,
}),
version_info: version_tag.map(|(vtag, format_str)| VersionInfo {
current_tag: vtag,
format_str,
latest_remote_tag: None,
}),
..Default::default()
})
} else {
None
}
}
/// Creates and populates the fields of an Image object based on a reference. If the tag is not recognized as a version string, exits the program with an error.
pub fn from_reference(reference: &str) -> Self {
let (registry, repository, tag) = split(reference);
let version_tag = Version::from_tag(&tag);
match version_tag {
Some((version, format_str)) => Self {
reference: reference.to_string(),
parts: Parts {
registry,
repository,
tag,
},
version_info: Some(VersionInfo {
current_tag: version,
format_str,
latest_remote_tag: None,
}),
..Default::default()
},
None => error!(
"Image {} is not available locally and does not have a recognizable tag format!",
reference
),
}
}
pub fn has_update(&self) -> Status {
if self.error.is_some() {
Status::Unknown(self.error.clone().unwrap())
} else {
match &self.version_info {
Some(data) => data
.latest_remote_tag
.as_ref()
.unwrap()
.to_status(&data.current_tag),
None => match &self.digest_info {
Some(data) => {
if data
.local_digests
.contains(data.remote_digest.as_ref().unwrap())
{
Status::UpToDate
} else {
Status::UpdateAvailable
}
}
None => unreachable!(), // I hope?
},
}
}
}
/// Converts image data into an `Update`
pub fn to_update(&self) -> Update {
let has_update = self.has_update();
let update_type = match has_update {
Status::UpToDate => "none",
Status::UpdateMajor | Status::UpdateMinor | Status::UpdatePatch => "version",
_ => "digest",
};
Update {
reference: self.reference.clone(),
parts: self.parts.clone(),
url: self.url.clone(),
result: UpdateResult {
has_update: has_update.to_option_bool(),
info: match has_update {
Status::Unknown(_) => UpdateInfo::None,
_ => match update_type {
"version" => {
let (new_tag, format_str) = match &self.version_info {
Some(data) => (
data.latest_remote_tag.clone().unwrap(),
data.format_str.clone(),
),
_ => unreachable!(),
};
UpdateInfo::Version(VersionUpdateInfo {
version_update_type: match has_update {
Status::UpdateMajor => "major",
Status::UpdateMinor => "minor",
Status::UpdatePatch => "patch",
_ => unreachable!(),
}
.to_string(),
new_tag: format_str
.replacen("{}", &new_tag.major.to_string(), 1)
.replacen("{}", &new_tag.minor.unwrap_or(0).to_string(), 1)
.replacen("{}", &new_tag.patch.unwrap_or(0).to_string(), 1),
// Throwing these in, because they're useful for the CLI output, however we won't (de)serialize them
current_version: self
.version_info
.as_ref()
.unwrap()
.current_tag
.to_string(),
new_version: self
.version_info
.as_ref()
.unwrap()
.latest_remote_tag
.as_ref()
.unwrap()
.to_string(),
})
}
"digest" => {
let (local_digests, remote_digest) = match &self.digest_info {
Some(data) => {
(data.local_digests.clone(), data.remote_digest.clone())
}
_ => unreachable!(),
};
UpdateInfo::Digest(DigestUpdateInfo {
local_digests,
remote_digest,
})
}
"none" => UpdateInfo::None,
_ => unreachable!(),
},
},
error: self.error.clone(),
},
time: self.time_ms,
server: None,
in_use: self.in_use,
status: has_update,
}
}
/// Checks if the image has an update
pub async fn check(&self, token: Option<&str>, ctx: &Context, client: &Client) -> Self {
match &self.version_info {
Some(data) => get_latest_tag(self, &data.current_tag, token, ctx, client).await,
None => match self.digest_info {
Some(_) => get_latest_digest(self, token, ctx, client).await,
None => unreachable!(),
},
}
}
}

View File

@@ -0,0 +1,62 @@
use bollard::secret::{ImageInspect, ImageSummary};
pub trait InspectData {
fn tags(&self) -> Option<Vec<String>>;
fn digests(&self) -> Option<Vec<String>>;
fn url(&self) -> Option<String>;
}
impl InspectData for ImageInspect {
fn tags(&self) -> Option<Vec<String>> {
self.repo_tags.clone()
}
fn digests(&self) -> Option<Vec<String>> {
self.repo_digests.clone()
}
fn url(&self) -> Option<String> {
match &self.config {
Some(config) => match &config.labels {
Some(labels) => labels.get("org.opencontainers.image.url").cloned(),
None => None,
},
None => None,
}
}
}
impl InspectData for ImageSummary {
fn tags(&self) -> Option<Vec<String>> {
Some(self.repo_tags.clone())
}
fn digests(&self) -> Option<Vec<String>> {
Some(self.repo_digests.clone())
}
fn url(&self) -> Option<String> {
self.labels.get("org.opencontainers.image.url").cloned()
}
}
impl InspectData for &String {
fn tags(&self) -> Option<Vec<String>> {
self.split('@').next().map(|tag| vec![tag.to_string()])
}
fn digests(&self) -> Option<Vec<String>> {
match self.split_once('@') {
Some((reference, digest)) => Some(vec![format!(
"{}@{}",
reference.split(':').next().unwrap(),
digest
)]),
None => Some(vec![]),
}
}
fn url(&self) -> Option<String> {
None
}
}

6
src/structs/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod image;
pub mod inspectdata;
pub mod parts;
pub mod status;
pub mod update;
pub mod version;

Some files were not shown because too many files have changed in this diff Show More