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

20 Commits

Author SHA1 Message Date
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
50 changed files with 1323 additions and 546 deletions

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

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

View File

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

View File

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

1
.gitignore vendored
View File

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

42
Cargo.lock generated
View File

@@ -350,7 +350,7 @@ dependencies = [
[[package]] [[package]]
name = "cup" name = "cup"
version = "2.0.1" version = "2.2.0"
dependencies = [ dependencies = [
"bollard", "bollard",
"chrono", "chrono",
@@ -770,9 +770,9 @@ checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [ dependencies = [
"either", "either",
] ]
@@ -800,9 +800,9 @@ checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd"
[[package]] [[package]]
name = "kstring" name = "kstring"
version = "2.0.0" version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3066350882a1cd6d950d055997f379ac37fd39f81cd4d8ed186032eb3c5747" checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1"
dependencies = [ dependencies = [
"serde", "serde",
"static_assertions", "static_assertions",
@@ -822,9 +822,9 @@ checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]] [[package]]
name = "liquid" name = "liquid"
version = "0.26.6" version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10929f201279ba14da3297b957dcda1e0bf7a6f3bb5115688be684aa8864e9cc" checksum = "7cdcc72b82748f47c2933c172313f5a9aea5b2c4eb3fa4c66b4ea55bb60bb4b1"
dependencies = [ dependencies = [
"doc-comment", "doc-comment",
"liquid-core", "liquid-core",
@@ -835,9 +835,9 @@ dependencies = [
[[package]] [[package]]
name = "liquid-core" name = "liquid-core"
version = "0.26.6" version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aef4b2160791f456eb880c990a97746f693746f92302ef5f1d06111cf14b768" checksum = "2752e978ffc53670f3f2e8b3ef09f348d6f7b5474a3be3f8a5befe5382e4effb"
dependencies = [ dependencies = [
"anymap2", "anymap2",
"itertools", "itertools",
@@ -853,9 +853,9 @@ dependencies = [
[[package]] [[package]]
name = "liquid-derive" name = "liquid-derive"
version = "0.26.5" version = "0.26.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915f6d0a2963a27cd5205c1902f32ddfe3bc035816afd268cf88c0fc0f8d287e" checksum = "3b51f1d220e3fa869e24cfd75915efe3164bd09bb11b3165db3f37f57bf673e3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -864,9 +864,9 @@ dependencies = [
[[package]] [[package]]
name = "liquid-lib" name = "liquid-lib"
version = "0.26.6" version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f48fc446873f74d869582f5c4b8cbf3248c93395e410a67af5809b3731e44a" checksum = "59b1a298d3d2287ee5b1e43840d885b8fdfc37d3f4e90d82aacfd04d021618da"
dependencies = [ dependencies = [
"itertools", "itertools",
"liquid-core", "liquid-core",
@@ -989,9 +989,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" checksum = "9c73c26c01b8c87956cea613c907c9d6ecffd8d18a2a5908e5de0adfaa185cea"
dependencies = [ dependencies = [
"memchr", "memchr",
"thiserror", "thiserror",
@@ -1000,9 +1000,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" checksum = "664d22978e2815783adbdd2c588b455b1bd625299ce36b2a99881ac9627e6d8d"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@@ -1010,9 +1010,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" checksum = "a2d5487022d5d33f4c30d91c22afa240ce2a644e87fe08caad974d4eab6badbe"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
@@ -1023,9 +1023,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.7.11" version = "2.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" checksum = "0091754bbd0ea592c4deb3a122ce8ecbb0753b738aa82bc055fcc2eccc8d8174"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"pest", "pest",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cup" name = "cup"
version = "2.0.1" version = "2.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

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

View File

@@ -27,7 +27,7 @@ Take a look at https://sergi0g.github.io/cup/docs!
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 What's up Docker. 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 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 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 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)
## Roadmap ## Roadmap
@@ -46,8 +46,6 @@ Here are some ideas to get you started:
To contribute, fork the repository, make your changes and the submit a pull request. 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/static/index.css`. You need to have the Tailwind CSS CLI installed ([instructions here](https://tailwindcss.com/docs/installation))
## Support ## 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)!

16
build.sh Executable file
View File

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

1
docs/.tool-versions Normal file
View File

@@ -0,0 +1 @@
nodejs 21.6.2

View File

@@ -1,7 +1,5 @@
import Image from "next/image"; import { Steps, Callout, Card, Cards } from "nextra-theme-docs";
import { Steps, Callout } from "nextra-theme-docs"; import { IconPaint, IconLockOpen, IconKey } from '@tabler/icons-react';
import blue from "../../assets/blue_theme.png"
import gray from "../../assets/gray_theme.png"
# Configuration # Configuration
@@ -15,6 +13,8 @@ For example, if using Podman, you might do
$ cup -s /run/user/1000/podman/podman.sock check $ cup -s /run/user/1000/podman/podman.sock check
``` ```
This option will hopefully be moved to the configuration file soon.
## Configuration file ## Configuration file
Cup has an option to be configured from a configuration file named `cup.json`. Cup has an option to be configured from a configuration file named `cup.json`.
@@ -25,16 +25,23 @@ Create a `cup.json` file somewhere on your system. For binary installs, a path l
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_ 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 ### Configure Cup from the configuration file
Follow the guides below (Theme and Authentication) to make your `cup.json` Follow the guides below to customize your `cup.json`
<Cards>
<Card icon={<IconKey />} title="Authentication" href="/docs/configuration/authentication" />
<Card icon={<IconLockOpen />} title="Insecure registries" href="/docs/configuration/insecure-registries" />
<Card icon={<IconPaint />} title="Theme" href="/docs/configuration/theme" />
</Cards>
Here's a full example: Here's a full example:
```json ```json
{ {
authentication: { "authentication": {
"ghcr.io": "<YOUR_TOKEN_HERE>", "ghcr.io": "<YOUR_TOKEN_HERE>",
"registry-1.docker.io": "<YOUR_TOKEN_HERE>" "registry-1.docker.io": "<YOUR_TOKEN_HERE>"
}, },
theme: "blue" "theme": "blue",
"insecure_registries": ["localhost:5000", "my-insecure-registry.example.com"]
} }
``` ```
@@ -48,51 +55,4 @@ $ cup -c /home/sergio/.config/cup.json check
```bash ```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 $ 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> </Steps>
## Theme (server only)
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={gray} />
However, you can get the old theme back by adding the `theme` key to your `cup.json`
Available values are `default` and `blue`.
Here's an example:
```json
{
"theme": "blue",
// Other options
}
```
## Authentication
<Callout emoji="⛔">
The features described in this section have not been implemented yet.
</Callout>
Some registries (or specific images) may require you to be authenticated. For those, you can modify `cup.json` like this:
```json
{
"authentication": {
"<YOUR_REGISTRY_DOMAIN_1>": "<YOUR_TOKEN_1>",
"<YOUR_REGISTRY_DOMAIN_2>": "<YOUR_TOKEN_2>"
// ...
},
// 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,22 @@
import { Callout } from 'nextra-theme-docs'
# Authentication
Some registries (or specific images) may require you to be authenticated. For those, you can modify `cup.json` like this:
```json
{
"authentication": {
"<YOUR_REGISTRY_DOMAIN_1>": "<YOUR_TOKEN_1>",
"<YOUR_REGISTRY_DOMAIN_2>": "<YOUR_TOKEN_2>"
// ...
},
// 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,20 @@
import { Callout } from 'nextra-theme-docs'
# Insecure registries
For the best security, Cup only connects to registries over SSL (HTTPS) by default. However, for people running a local registry that haven't configured SSL, this may be a problem.
To solve this problem, `cup.json` has an `"insecure_registries"` option which allows you to specify exceptions
Here's what it looks like:
```json
{
"insecure_registries": ["<INSECURE_REGISTRY_1>", "<INSECURE_REGISTRY_2>"],
// Other options
}
```
<Callout emoji="⚠️">
When configuring an insecure registry that doesn't run on port 80, don't forget to specify it (i.e. use `localhost:5000` instead of `localhost` if your registry is running on port `5000`)
</Callout>

View File

@@ -0,0 +1,31 @@
import { Callout } from "nextra-theme-docs";
import Image from "next/image";
import blue from "../../../assets/blue_theme.png";
import gray from "../../../assets/gray_theme.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={gray} />
However, you can get the old theme back by adding the `theme` key to your `cup.json`
Available values are `default` and `blue`.
Here's an example:
```json
{
"theme": "blue",
// Other options
}
```

View File

@@ -1,8 +1,18 @@
use std::{collections::{HashMap, HashSet}, sync::Mutex}; use std::{
collections::{HashMap, HashSet},
sync::Mutex,
};
use json::JsonValue;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use crate::{docker::get_images_from_docker_daemon, image::Image, registry::{check_auth, get_token, get_latest_digests}, utils::unsplit_image}; use crate::{
docker::get_images_from_docker_daemon,
image::Image,
registry::{check_auth, get_latest_digests, get_token},
utils::unsplit_image,
};
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
use crate::docker::get_image_from_docker_daemon; use crate::docker::get_image_from_docker_daemon;
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
@@ -23,7 +33,10 @@ where
} }
} }
pub async fn get_all_updates(socket: Option<String>) -> Vec<(String, Option<bool>)> { pub async fn get_all_updates(
socket: Option<String>,
config: &JsonValue,
) -> Vec<(String, Option<bool>)> {
let image_map_mutex: Mutex<HashMap<String, &Option<String>>> = Mutex::new(HashMap::new()); let image_map_mutex: Mutex<HashMap<String, &Option<String>>> = Mutex::new(HashMap::new());
let local_images = get_images_from_docker_daemon(socket).await; let local_images = get_images_from_docker_daemon(socket).await;
local_images.par_iter().for_each(|image| { local_images.par_iter().for_each(|image| {
@@ -42,12 +55,16 @@ pub async fn get_all_updates(socket: Option<String>) -> Vec<(String, Option<bool
.par_iter() .par_iter()
.filter(|image| &image.registry == registry) .filter(|image| &image.registry == registry)
.collect(); .collect();
let mut latest_images = match check_auth(registry) { let credentials = config["authentication"][registry]
.clone()
.take_string()
.or(None);
let mut latest_images = match check_auth(registry, config) {
Some(auth_url) => { Some(auth_url) => {
let token = get_token(images.clone(), &auth_url); let token = get_token(images.clone(), &auth_url, &credentials);
get_latest_digests(images, Some(&token)) get_latest_digests(images, Some(&token), config)
} }
None => get_latest_digests(images, None), None => get_latest_digests(images, None, config),
}; };
remote_images.append(&mut latest_images); remote_images.append(&mut latest_images);
} }
@@ -67,18 +84,22 @@ pub async fn get_all_updates(socket: Option<String>) -> Vec<(String, Option<bool
} }
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
pub async fn get_update(image: &str, socket: Option<String>) -> Option<bool> { pub async fn get_update(image: &str, socket: Option<String>, config: &JsonValue) -> Option<bool> {
let local_image = get_image_from_docker_daemon(socket, image).await; let local_image = get_image_from_docker_daemon(socket, image).await;
let token = match check_auth(&local_image.registry) { let credentials = config["authentication"][&local_image.registry]
Some(auth_url) => get_token(vec![&local_image], &auth_url), .clone()
.take_string()
.or(None);
let token = match check_auth(&local_image.registry, config) {
Some(auth_url) => get_token(vec![&local_image], &auth_url, &credentials),
None => String::new(), None => String::new(),
}; };
let remote_image = match token.as_str() { let remote_image = match token.as_str() {
"" => get_latest_digest(&local_image, None), "" => get_latest_digest(&local_image, None, config),
_ => get_latest_digest(&local_image, Some(&token)), _ => get_latest_digest(&local_image, Some(&token), config),
}; };
match &remote_image.digest { match &remote_image.digest {
Some(d) => Some(d != &local_image.digest.unwrap()), Some(d) => Some(d != &local_image.digest.unwrap()),
None => None, None => None,
} }
} }

View File

@@ -1,7 +1,4 @@
use bollard::{ use bollard::{secret::ImageSummary, ClientVersion, Docker};
secret::ImageSummary,
ClientVersion, Docker,
};
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
use bollard::secret::ImageInspect; use bollard::secret::ImageInspect;
@@ -37,7 +34,7 @@ pub async fn get_images_from_docker_daemon(socket: Option<String>) -> Vec<Image>
}; };
let mut result: Vec<Image> = Vec::new(); let mut result: Vec<Image> = Vec::new();
for image in images { for image in images {
if !image.repo_tags.is_empty() && image.repo_digests.len() == 1 { if !image.repo_tags.is_empty() && !image.repo_digests.is_empty() {
for t in &image.repo_tags { for t in &image.repo_tags {
let (registry, repository, tag) = split_image(t); let (registry, repository, tag) = split_image(t);
result.push(Image { result.push(Image {

View File

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

View File

@@ -1,8 +1,8 @@
#[cfg(feature = "cli")]
use check::{get_all_updates, get_update};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner}; use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner};
#[cfg(feature = "cli")]
use check::{get_all_updates, get_update};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use server::serve; use server::serve;
use std::path::PathBuf; use std::path::PathBuf;
@@ -10,13 +10,13 @@ use utils::load_config;
pub mod check; pub mod check;
pub mod docker; pub mod docker;
pub mod image;
pub mod registry;
pub mod utils;
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
pub mod formatting; pub mod formatting;
pub mod image;
pub mod registry;
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod server; pub mod server;
pub mod utils;
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
@@ -69,7 +69,7 @@ async fn main() {
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
Some(Commands::Check { image, icons, raw }) => match image { Some(Commands::Check { image, icons, raw }) => match image {
Some(name) => { Some(name) => {
let has_update = get_update(name, cli.socket).await; let has_update = get_update(name, cli.socket, &config).await;
match raw { match raw {
true => print_raw_update(name, &has_update), true => print_raw_update(name, &has_update),
false => print_update(name, &has_update), false => print_update(name, &has_update),
@@ -77,10 +77,10 @@ async fn main() {
} }
None => { None => {
match raw { match raw {
true => print_raw_updates(&get_all_updates(cli.socket).await), true => print_raw_updates(&get_all_updates(cli.socket, &config).await),
false => { false => {
let spinner = Spinner::new(); let spinner = Spinner::new();
let updates = get_all_updates(cli.socket).await; let updates = get_all_updates(cli.socket, &config).await;
spinner.succeed(); spinner.succeed();
print_updates(&updates, icons); print_updates(&updates, icons);
} }

View File

@@ -2,28 +2,55 @@ use std::sync::Mutex;
use json::JsonValue; use json::JsonValue;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use ureq::Error; use ureq::{Error, ErrorKind};
use http_auth::parse_challenges; use http_auth::parse_challenges;
use crate::{error, image::Image}; use crate::{error, image::Image, warn};
pub fn check_auth(registry: &str) -> Option<String> { pub fn check_auth(registry: &str, config: &JsonValue) -> Option<String> {
let response = ureq::get(&format!("https://{}/v2/", registry)).call(); let protocol = if config["insecure_registries"].contains(registry) {
"http"
} else {
"https"
};
let response = ureq::get(&format!("{}://{}/v2/", protocol, registry)).call();
match response { match response {
Ok(_) => None, Ok(_) => None,
Err(Error::Status(401, response)) => match response.header("www-authenticate") { Err(Error::Status(401, response)) => match response.header("www-authenticate") {
Some(challenge) => Some(parse_www_authenticate(challenge)), Some(challenge) => Some(parse_www_authenticate(challenge)),
None => error!("Server returned invalid response!"), None => error!(
"Unauthorized to access registry {} and no way to authenticate was provided",
registry
),
}, },
Err(Error::Transport(error)) => {
match error.kind() {
ErrorKind::Dns => {
warn!("Failed to lookup the IP of the registry, retrying.");
return check_auth(registry, config);
} // If something goes really wrong, this can get stuck in a loop
ErrorKind::ConnectionFailed => {
warn!("Connection probably timed out, retrying.");
return check_auth(registry, config);
} // Same here
_ => error!("{}", error),
}
}
Err(e) => error!("{}", e), Err(e) => error!("{}", e),
} }
} }
pub fn get_latest_digest(image: &Image, token: Option<&String>) -> Image { pub fn get_latest_digest(image: &Image, token: Option<&String>, config: &JsonValue) -> Image {
let protocol =
if config["insecure_registries"].contains(json::JsonValue::from(image.registry.clone())) {
"http"
} else {
"https"
};
let mut request = ureq::head(&format!( let mut request = ureq::head(&format!(
"https://{}/v2/{}/manifests/{}", "{}://{}/v2/{}/manifests/{}",
&image.registry, &image.repository, &image.tag protocol, &image.registry, &image.repository, &image.tag
)); ));
if let Some(t) = token { if let Some(t) = token {
request = request.set("Authorization", &format!("Bearer {}", t)); request = request.set("Authorization", &format!("Bearer {}", t));
@@ -35,14 +62,17 @@ pub fn get_latest_digest(image: &Image, token: Option<&String>) -> Image {
Ok(response) => response, Ok(response) => response,
Err(Error::Status(401, response)) => { Err(Error::Status(401, response)) => {
if token.is_some() { if token.is_some() {
error!("Failed to authenticate to registry {} with given token!\n{}", &image.registry, token.unwrap()) warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry, token.unwrap());
return Image { digest: None, ..image.clone() }
} else { } else {
return get_latest_digest( return get_latest_digest(
image, image,
Some(&get_token( Some(&get_token(
vec![image], vec![image],
&parse_www_authenticate(response.header("www-authenticate").unwrap()), &parse_www_authenticate(response.header("www-authenticate").unwrap()),
&None // I think?
)), )),
config
); );
} }
} }
@@ -51,8 +81,20 @@ pub fn get_latest_digest(image: &Image, token: Option<&String>) -> Image {
digest: None, digest: None,
..image.clone() ..image.clone()
} }
} },
Err(ureq::Error::Transport(e)) => error!("Failed to send request!\n{}", e), Err(Error::Transport(error)) => {
match error.kind() {
ErrorKind::Dns => {
warn!("Failed to lookup the IP of the registry, retrying.");
return get_latest_digest(image, token, config)
}, // If something goes really wrong, this can get stuck in a loop
ErrorKind::ConnectionFailed => {
warn!("Connection probably timed out, retrying.");
return get_latest_digest(image, token, config)
}, // Same here
_ => error!("Failed to retrieve image digest\n{}!", error)
}
},
}; };
match raw_response.header("docker-content-digest") { match raw_response.header("docker-content-digest") {
Some(digest) => Image { Some(digest) => Image {
@@ -63,10 +105,14 @@ pub fn get_latest_digest(image: &Image, token: Option<&String>) -> Image {
} }
} }
pub fn get_latest_digests(images: Vec<&Image>, token: Option<&String>) -> Vec<Image> { pub fn get_latest_digests(
images: Vec<&Image>,
token: Option<&String>,
config: &JsonValue,
) -> Vec<Image> {
let result: Mutex<Vec<Image>> = Mutex::new(Vec::new()); let result: Mutex<Vec<Image>> = Mutex::new(Vec::new());
images.par_iter().for_each(|&image| { images.par_iter().for_each(|&image| {
let digest = get_latest_digest(image, token).digest; let digest = get_latest_digest(image, token, config).digest;
result.lock().unwrap().push(Image { result.lock().unwrap().push(Image {
digest, digest,
..image.clone() ..image.clone()
@@ -76,21 +122,37 @@ pub fn get_latest_digests(images: Vec<&Image>, token: Option<&String>) -> Vec<Im
r r
} }
pub fn get_token(images: Vec<&Image>, auth_url: &str) -> String { pub fn get_token(images: Vec<&Image>, auth_url: &str, credentials: &Option<String>) -> String {
let mut final_url = auth_url.to_owned(); let mut final_url = auth_url.to_owned();
for image in images { for image in &images {
final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository); final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository);
} }
let raw_response = match ureq::get(&final_url) let mut base_request =
.set("Accept", "application/vnd.oci.image.index.v1+json") // Seems to be unnecesarry. Will probably remove in the future ureq::get(&final_url).set("Accept", "application/vnd.oci.image.index.v1+json"); // Seems to be unnecesarry. Will probably remove in the future
.call() base_request = match credentials {
{ Some(creds) => base_request.set("Authorization", &format!("Basic {}", creds)),
None => base_request,
};
let raw_response = match base_request.call() {
Ok(response) => match response.into_string() { Ok(response) => match response.into_string() {
Ok(res) => res, Ok(res) => res,
Err(e) => { Err(e) => {
error!("Failed to parse response into string!\n{}", e) error!("Failed to parse response into string!\n{}", e)
} }
}, },
Err(Error::Transport(error)) => {
match error.kind() {
ErrorKind::Dns => {
warn!("Failed to lookup the IP of the registry, retrying.");
return get_token(images, auth_url, credentials);
} // If something goes really wrong, this can get stuck in a loop
ErrorKind::ConnectionFailed => {
warn!("Connection probably timed out, retrying.");
return get_token(images, auth_url, credentials);
} // Same here
_ => error!("Token request failed\n{}!", error),
}
}
Err(e) => { Err(e) => {
error!("Token request failed!\n{}", e) error!("Token request failed!\n{}", e)
} }
@@ -118,6 +180,6 @@ fn parse_www_authenticate(www_auth: &str) -> String {
error!("Unsupported scheme {}", &challenge.scheme) error!("Unsupported scheme {}", &challenge.scheme)
} }
} else { } else {
error!("No challenge provided"); error!("No challenge provided by the server");
} }
} }

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -13,6 +13,14 @@ macro_rules! error {
}) })
} }
// A small macro to print in yellow as a warning
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => ({
eprintln!("\x1b[93m{}\x1b[0m", format!($($arg)*));
})
}
/// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest']. /// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest'].
pub fn split_image(image: &str) -> (String, String, String) { pub fn split_image(image: &str) -> (String, String, String) {
static RE: Lazy<Regex> = Lazy::new(|| { static RE: Lazy<Regex> = Lazy::new(|| {
@@ -23,15 +31,16 @@ pub fn split_image(image: &str) -> (String, String, String) {
}); });
match RE.captures(image) { match RE.captures(image) {
Some(c) => { Some(c) => {
let registry = match c.name("registry") {
Some(registry) => registry.as_str().to_owned(),
None => String::from("registry-1.docker.io"),
};
return ( return (
match c.name("registry") { registry.clone(),
Some(registry) => registry.as_str().to_owned(),
None => String::from("registry-1.docker.io"),
},
match c.name("repository") { match c.name("repository") {
Some(repository) => { Some(repository) => {
let repo = repository.as_str().to_owned(); let repo = repository.as_str().to_owned();
if !repo.contains('/') { if !repo.contains('/') && registry == "registry-1.docker.io" {
format!("library/{}", repo) format!("library/{}", repo)
} else { } else {
repo repo
@@ -43,7 +52,7 @@ pub fn split_image(image: &str) -> (String, String, String) {
Some(tag) => tag.as_str().to_owned(), Some(tag) => tag.as_str().to_owned(),
None => String::from("latest"), None => String::from("latest"),
}, },
) );
} }
None => error!("Failed to parse image {}", image), None => error!("Failed to parse image {}", image),
} }

View File

@@ -1,17 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"src/static/template.liquid"
],
theme: {
extend: {},
},
plugins: [],
safelist: [
{
pattern: /(bg|text|divide)-(gray|neutral)-.+/,
variants: ["dark"]
}
]
}

24
web/.gitignore vendored Normal file
View File

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

1
web/.tool-versions Normal file
View File

@@ -0,0 +1 @@
nodejs 21.6.2

9
web/README.md Normal file
View File

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

BIN
web/bun.lockb Executable file

Binary file not shown.

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

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

273
web/index.html Normal file
View File

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

37
web/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"fmt": "prettier --write ."
},
"dependencies": {
"@tabler/icons-react": "^3.14.0",
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/node": "^22.5.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.42",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}

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

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

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

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

@@ -0,0 +1,92 @@
import { MouseEvent, useState } from "react";
import Logo from "./components/Logo";
import Statistic from "./components/Statistic";
import Image from "./components/Image";
import { LastChecked } from "./components/LastChecked";
import Loading from "./components/Loading";
import { Data } from "./types";
function App() {
const [data, setData] = useState<Data | null>(null);
const theme = "neutral"; // Stupid, I know but I want both the dev and prod to work easily.
if (!data) return <Loading onLoad={setData} />;
const refresh = (event: MouseEvent) => {
const btn = event.currentTarget as HTMLButtonElement;
btn.disabled = true;
let request = new XMLHttpRequest();
request.onload = () => {
if (request.status === 200) {
window.location.reload();
}
};
request.open(
"GET",
process.env.NODE_ENV === "production"
? "/refresh"
: `http://${window.location.hostname}:8000/refresh`
);
request.send();
};
return (
<div
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
>
<div className="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full">
<div className="flex flex-col max-w-[48rem] mx-auto h-full my-8">
<div className="flex items-center gap-1">
<h1 className="text-5xl lg:text-6xl font-bold dark:text-white">
Cup
</h1>
<Logo />
</div>
<div
className={`shadow-sm bg-white dark:bg-${theme}-900 rounded-md my-8`}
>
<dl className="lg:grid-cols-4 md:grid-cols-2 gap-1 grid-cols-1 grid overflow-hidden *:relative">
{Object.entries(data.metrics).map(([name, value]) => (
<Statistic name={name} value={value} key={name} />
))}
</dl>
</div>
<div
className={`shadow-sm bg-white dark:bg-${theme}-900 rounded-md my-8`}
>
<div
className={`flex justify-between items-center px-6 py-4 text-${theme}-500`}
>
<LastChecked datetime={data.last_updated} />
<button className="group" onClick={refresh}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="group-disabled:animate-spin"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4,11A8.1,8.1 0 0 1 19.5,9M20,5v4h-4" />
<path d="M20,13A8.1,8.1 0 0 1 4.5,15M4,19v-4h4" />
</svg>
</button>
</div>
<ul
className={`*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-${theme}-800 divide-y dark:text-white`}
>
{Object.entries(data.images).map(([name, status]) => (
<Image name={name} status={status} key={name} />
))}
</ul>
</div>
</div>
</div>
</div>
);
}
export default App;

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { IconLoader2 } from "@tabler/icons-react";
import { Data } from "../types";
import Logo from "./Logo";
export default function Loading({ onLoad }: { onLoad: (data: Data) => void }) {
const theme = "neutral";
fetch(
process.env.NODE_ENV === "production"
? "/json"
: `http://${window.location.hostname}:8000/json`,
).then((response) => response.json().then((data) => onLoad(data)));
return (
<div
className={`flex justify-center min-h-screen bg-${theme}-50 dark:bg-${theme}-950`}
>
<div className="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full absolute overflow-hidden">
<div className="flex flex-col max-w-[48rem] mx-auto h-full my-8">
<div className="flex items-center gap-1">
<h1 className="text-5xl lg:text-6xl font-bold dark:text-white">
Cup
</h1>
<Logo />
</div>
<div
className={`h-full flex justify-center
items-center gap-1 text-${theme}-500 dark:text-${theme}-400`}
>
Loading <IconLoader2 className="animate-spin" />
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

7
web/tsconfig.json Normal file
View File

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

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

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

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

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