+ );
+}
diff --git a/docs/src/app/components/GridPattern.tsx b/docs/src/app/components/GridPattern.tsx
new file mode 100644
index 0000000..d376fab
--- /dev/null
+++ b/docs/src/app/components/GridPattern.tsx
@@ -0,0 +1,31 @@
+import { useId } from "react";
+
+const SIZE = 36;
+
+export function GridPattern() {
+ const id = useId();
+
+ return (
+
+ );
+}
diff --git a/docs/src/app/components/Head.tsx b/docs/src/app/components/Head.tsx
new file mode 100644
index 0000000..b31296e
--- /dev/null
+++ b/docs/src/app/components/Head.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { Head as NextraHead } from "nextra/components";
+
+export function Head() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/src/app/components/Logo.tsx b/docs/src/app/components/Logo.tsx
new file mode 100644
index 0000000..3e733e0
--- /dev/null
+++ b/docs/src/app/components/Logo.tsx
@@ -0,0 +1,57 @@
+export default function Logo() {
+ return (
+
+ );
+}
diff --git a/docs/src/app/components/pages/home.tsx b/docs/src/app/components/pages/home.tsx
new file mode 100644
index 0000000..5e9992a
--- /dev/null
+++ b/docs/src/app/components/pages/home.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+ The easiest way to manage your
+
+
+
+ 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.
+
+
+
+
+ );
+}
diff --git a/docs/src/app/page.tsx b/docs/src/app/page.tsx
new file mode 100644
index 0000000..9a4add8
--- /dev/null
+++ b/docs/src/app/page.tsx
@@ -0,0 +1,23 @@
+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",
+ filePath: "",
+};
+
+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.
+
+
+
+ );
+}
diff --git a/docs/src/content/_meta.ts b/docs/src/content/_meta.ts
new file mode 100644
index 0000000..5a8f226
--- /dev/null
+++ b/docs/src/content/_meta.ts
@@ -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",
+ },
+};
diff --git a/docs/src/content/docs/_meta.ts b/docs/src/content/docs/_meta.ts
new file mode 100644
index 0000000..7bafd3a
--- /dev/null
+++ b/docs/src/content/docs/_meta.ts
@@ -0,0 +1,5 @@
+export default {
+ installation: {},
+ usage: {},
+ configuration: {},
+};
diff --git a/docs/pages/docs/community-resources/docker-compose.mdx b/docs/src/content/docs/community-resources/docker-compose.mdx
similarity index 63%
rename from docs/pages/docs/community-resources/docker-compose.mdx
rename to docs/src/content/docs/community-resources/docker-compose.mdx
index d737b25..191048a 100644
--- a/docs/pages/docs/community-resources/docker-compose.mdx
+++ b/docs/src/content/docs/community-resources/docker-compose.mdx
@@ -20,9 +20,24 @@ services:
- ./cup.json:/config/cup.json
```
-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 docker compose:
+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"
```
-This can be customized further of course, if you choose to use a different port, another config location, or would like to change something else. Have fun!
+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!
\ No newline at end of file
diff --git a/docs/pages/docs/community-resources/homepage-widget.mdx b/docs/src/content/docs/community-resources/homepage-widget.mdx
similarity index 74%
rename from docs/pages/docs/community-resources/homepage-widget.mdx
rename to docs/src/content/docs/community-resources/homepage-widget.mdx
index 46f1798..fcb122e 100644
--- a/docs/pages/docs/community-resources/homepage-widget.mdx
+++ b/docs/src/content/docs/community-resources/homepage-widget.mdx
@@ -1,6 +1,6 @@
-import Image from 'next/image';
-import widget1 from '../../../assets/350767810-42eccc89-bdfd-426a-a113-653abe7483d8.png'
-import widget2 from '../../../assets/358304960-e9f26767-51f7-4b5a-8b74-a5811019497b.jpeg'
+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
@@ -28,7 +28,7 @@ services:
homepage.ping: http://myserver:8000
homepage.description: Checks for container updates
homepage.widget.type: customapi
- homepage.widget.url: http://myserver:8000/json
+ 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
@@ -36,11 +36,13 @@ services:
homepage.widget.mappings[1].field.metrics: up_to_date
homepage.widget.mappings[1].format: number
homepage.widget.mappings[2].label: Updates
- homepage.widget.mappings[2].field.metrics: update_available
+ homepage.widget.mappings[2].field.metrics: updates_available
homepage.widget.mappings[2].format: number
```
+
Preview:
-
+
+
Credit: [@agrmohit](https://github.com/agrmohit)
@@ -49,7 +51,7 @@ Credit: [@agrmohit](https://github.com/agrmohit)
```yaml
widget:
type: customapi
- url: http://:9000/json
+ url: http://:9000/api/v3/json
refreshInterval: 10000
method: GET
mappings:
@@ -57,19 +59,21 @@ widget:
metrics: monitored_images
label: Monitored images
format: number
- - field:
+ - field:
metrics: up_to_date
label: Up to date
format: number
- - field:
- metrics: update_available
+ - field:
+ metrics: updates_available
label: Available updates
format: number
- - field:
+ - field:
metrics: unknown
label: Unknown
format: number
```
+
Preview:
-
-Credit: [@remussamoila](https://github.com/remussamoila)
\ No newline at end of file
+
+
+Credit: [@remussamoila](https://github.com/remussamoila)
diff --git a/docs/src/content/docs/configuration/agent.mdx b/docs/src/content/docs/configuration/agent.mdx
new file mode 100644
index 0000000..c6e87ac
--- /dev/null
+++ b/docs/src/content/docs/configuration/agent.mdx
@@ -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
+}
+```
\ No newline at end of file
diff --git a/docs/src/content/docs/configuration/authentication.mdx b/docs/src/content/docs/configuration/authentication.mdx
new file mode 100644
index 0000000..73048cb
--- /dev/null
+++ b/docs/src/content/docs/configuration/authentication.mdx
@@ -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": {
+ "": {
+ "authentication": ""
+ // Other options
+ },
+ "" {
+ "authentication": ""
+ // Other options
+ },
+ // ...
+ }
+ // Other options
+}
+```
+
+You can use any registry, like `ghcr.io`, `quay.io`, `gcr.io`, etc.
+
+For Docker Hub, use `registry-1.docker.io`
diff --git a/docs/src/content/docs/configuration/automatic-refresh.mdx b/docs/src/content/docs/configuration/automatic-refresh.mdx
new file mode 100644
index 0000000..b6efc35
--- /dev/null
+++ b/docs/src/content/docs/configuration/automatic-refresh.mdx
@@ -0,0 +1,12 @@
+# Automatic refresh
+
+Cup can automatically refresh the results when running in server mode. Simply add this to your config:
+
+```jsonc
+{
+ "refresh_interval": "0 0,30 * 0 0" // Check twice an hour
+ // Other options
+}
+```
+
+You can use a cron expression to specify the refresh interval. The reference is [here](https://github.com/Hexagon/croner-rust#pattern)
\ No newline at end of file
diff --git a/docs/src/content/docs/configuration/ignore-registry.mdx b/docs/src/content/docs/configuration/ignore-registry.mdx
new file mode 100644
index 0000000..3c93b6a
--- /dev/null
+++ b/docs/src/content/docs/configuration/ignore-registry.mdx
@@ -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": {
+ "": {
+ "ignore": true
+ // Other options
+ },
+ "" {
+ "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.
diff --git a/docs/src/content/docs/configuration/include-exclude-images.mdx b/docs/src/content/docs/configuration/include-exclude-images.mdx
new file mode 100644
index 0000000..ba73e1b
--- /dev/null
+++ b/docs/src/content/docs/configuration/include-exclude-images.mdx
@@ -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!
\ No newline at end of file
diff --git a/docs/src/content/docs/configuration/index.mdx b/docs/src/content/docs/configuration/index.mdx
new file mode 100644
index 0000000..a1eab06
--- /dev/null
+++ b/docs/src/content/docs/configuration/index.mdx
@@ -0,0 +1,111 @@
+---
+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.
+
+}
+ title="Custom Docker socket"
+ href="/docs/configuration/socket"
+/>
+
+## Configuration file
+
+Cup has an option to be configured from a configuration file named `cup.json`.
+
+
+### 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`
+
+
+ }
+ title="Authentication"
+ href="/docs/configuration/authentication"
+ />
+ }
+ title="Insecure registries"
+ href="/docs/configuration/insecure-registries"
+ />
+ }
+ title="Theme"
+ href="/docs/configuration/theme"
+ />
+ }
+ title="Multiple servers"
+ href="/docs/configuration/servers"
+ />
+
+
+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": ""
+ }
+ },
+ "servers": {
+ "Raspberry Pi": "https://server.local:8000"
+ },
+ "theme": "blue"
+}
+```
+
+
+ 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.
+
+
+### 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
+```
+
+
diff --git a/docs/src/content/docs/configuration/insecure-registries.mdx b/docs/src/content/docs/configuration/insecure-registries.mdx
new file mode 100644
index 0000000..4c385c0
--- /dev/null
+++ b/docs/src/content/docs/configuration/insecure-registries.mdx
@@ -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": true
+ // Other options
+ },
+ "" {
+ "insecure": true
+ // Other options
+ },
+ // ...
+ }
+ // Other options
+}
+```
+
+
+ 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`)
+
diff --git a/docs/src/content/docs/configuration/servers.mdx b/docs/src/content/docs/configuration/servers.mdx
new file mode 100644
index 0000000..49b2dd1
--- /dev/null
+++ b/docs/src/content/docs/configuration/servers.mdx
@@ -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
+}
+```
\ No newline at end of file
diff --git a/docs/src/content/docs/configuration/socket.mdx b/docs/src/content/docs/configuration/socket.mdx
new file mode 100644
index 0000000..395dda6
--- /dev/null
+++ b/docs/src/content/docs/configuration/socket.mdx
@@ -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
+}
+```
diff --git a/docs/src/content/docs/configuration/theme.mdx b/docs/src/content/docs/configuration/theme.mdx
new file mode 100644
index 0000000..f7cfa7b
--- /dev/null
+++ b/docs/src/content/docs/configuration/theme.mdx
@@ -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
+
+This configuration option is only for the server
+
+Cup initially had a blue theme which looked like this:
+
+
+
+This was replaced by a more neutral theme which is now the default:
+
+
+
+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.
diff --git a/docs/pages/docs/contributing.mdx b/docs/src/content/docs/contributing.mdx
similarity index 77%
rename from docs/pages/docs/contributing.mdx
rename to docs/src/content/docs/contributing.mdx
index 07bffa3..b605ca5 100644
--- a/docs/pages/docs/contributing.mdx
+++ b/docs/src/content/docs/contributing.mdx
@@ -1,3 +1,5 @@
+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.
@@ -5,14 +7,31 @@ First of all, thanks for taking time to contribute to Cup! This guide will help
## Setting up a development environment
Requirements:
+
- A computer running Linux
- Rust (usually installed from https://rustup.rs/)
- Node.js 22+ and Bun 1+
-1. Fork the repository. This is where you'll be pushing your changes before you create a pull request. Make sure to _create a new branch_ for your changes.
-2. Clone your fork with `git clone https://github.com//cup` (if you use SSH, `git clone git@github.com:/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
+
+### 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//cup
+```
+If you use SSH:
+```bash
+git clone git@github.com:/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
+```
+
You're ready to go!
@@ -27,8 +46,9 @@ 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.
+
+ - 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`.
@@ -48,4 +68,4 @@ 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!
\ No newline at end of file
+Happy contributing!
diff --git a/docs/pages/docs/index.mdx b/docs/src/content/docs/index.mdx
similarity index 70%
rename from docs/pages/docs/index.mdx
rename to docs/src/content/docs/index.mdx
index 60c55c5..c4916dd 100644
--- a/docs/pages/docs/index.mdx
+++ b/docs/src/content/docs/index.mdx
@@ -1,11 +1,11 @@
import Image from "next/image";
-import cup from "../../assets/cup.gif";
-import { Cards, Card } from "nextra-theme-docs";
+import cup from "@/app/assets/cup.gif";
+import { Cards } from "nextra/components";
import { IconBrandDocker, IconPackage } from "@tabler/icons-react";
# Introduction
-
+
Cup is a lightweight alternative to [What's up Docker?](https://github.com/getwud/wud) written in Rust.
@@ -15,12 +15,20 @@ Cup is a lightweight alternative to [What's up Docker?](https://github.com/getwu
- 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.2 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
- } title="With Docker" href="/docs/installation/docker" />
- } title="As a binary" href="/docs/installation/binary" />
+ }
+ title="With Docker"
+ href="/docs/installation/docker"
+ />
+ }
+ title="As a binary"
+ href="/docs/installation/binary"
+ />
diff --git a/docs/src/content/docs/installation/_meta.ts b/docs/src/content/docs/installation/_meta.ts
new file mode 100644
index 0000000..31d6423
--- /dev/null
+++ b/docs/src/content/docs/installation/_meta.ts
@@ -0,0 +1,8 @@
+export default {
+ docker: {
+ title: "With Docker",
+ },
+ binary: {
+ title: "As a binary",
+ },
+};
diff --git a/docs/pages/docs/installation/binary.mdx b/docs/src/content/docs/installation/binary.mdx
similarity index 85%
rename from docs/pages/docs/installation/binary.mdx
rename to docs/src/content/docs/installation/binary.mdx
index 27f0028..8d4c741 100644
--- a/docs/pages/docs/installation/binary.mdx
+++ b/docs/src/content/docs/installation/binary.mdx
@@ -1,7 +1,8 @@
-import { Callout, Card, Steps } from "nextra-theme-docs";
+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.
@@ -13,6 +14,7 @@ This guide will help you install Cup from a 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`
+
You can use the command `uname -i` to find this
@@ -21,5 +23,6 @@ Move the binary you downloaded to a directory in your path. You can usually get
That's it! Cup is ready to be used. Head over to the Usage page to get started.
+
-} title="Usage" href="/docs/usage" />
\ No newline at end of file
+} title="Usage" href="/docs/usage" />
diff --git a/docs/pages/docs/installation/docker.mdx b/docs/src/content/docs/installation/docker.mdx
similarity index 60%
rename from docs/pages/docs/installation/docker.mdx
rename to docs/src/content/docs/installation/docker.mdx
index d1fd211..8d7740c 100644
--- a/docs/pages/docs/installation/docker.mdx
+++ b/docs/src/content/docs/installation/docker.mdx
@@ -1,7 +1,8 @@
-import { Callout, Card } from "nextra-theme-docs";
+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.
@@ -9,13 +10,18 @@ This guide will help you install Cup as a Docker container. It is the easiest in
## Installation
To get started, open up a terminal and run the following command.
+
```bash
$ docker pull ghcr.io/sergi0g/cup
```
+
-If you aren't in the `docker` group, please ensure you run all commands as a user who does. In most cases, you'll just need to prefix the `docker` commands with `sudo`
+ 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`
That's it! Cup is ready to be used. Head over to the Usage page to get started.
+
-} title="Usage" href="/docs/usage" />
\ No newline at end of file
+} title="Usage" href="/docs/usage" />
diff --git a/docs/src/content/docs/integrations.mdx b/docs/src/content/docs/integrations.mdx
new file mode 100644
index 0000000..336408e
--- /dev/null
+++ b/docs/src/content/docs/integrations.mdx
@@ -0,0 +1,79 @@
+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",
+ },
+ "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.
+ },
+ ],
+}
+```
+
+
+ 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
+
+
+For retrieving the above data, refer to the CLI and server pages:
+
+
+ } title="CLI" href="/docs/usage/cli" />
+ }
+ title="Server"
+ href="/docs/usage/server"
+ />
+
diff --git a/docs/pages/docs/nightly.mdx b/docs/src/content/docs/nightly.mdx
similarity index 61%
rename from docs/pages/docs/nightly.mdx
rename to docs/src/content/docs/nightly.mdx
index 715099b..cc19ad8 100644
--- a/docs/pages/docs/nightly.mdx
+++ b/docs/src/content/docs/nightly.mdx
@@ -1,4 +1,4 @@
-import { Callout } from "nextra-theme-docs"
+import { Callout } from "nextra/components";
# Using the latest version
@@ -9,7 +9,11 @@ However, it is only updated when a new release is created, so if you want the la
Cup's nightly version always contains the latest changes in the main branch.
-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
+ 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!
## With Docker
@@ -18,4 +22,4 @@ 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.
\ No newline at end of file
+Go to a [nightly workflow run](https://github.com/sergi0g/cup/actions/workflows/nightly.yml) and download the artifact for your system.
diff --git a/docs/src/content/docs/usage/cli.mdx b/docs/src/content/docs/usage/cli.mdx
new file mode 100644
index 0000000..5d6824c
--- /dev/null
+++ b/docs/src/content/docs/usage/cli.mdx
@@ -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
+[32;1m✓[0m Done!
+[90;1m~ Local images[0m
+ [90;1m╭─────────────────────────────────────────┬──────────────────────────────────┬─────────╮[0m
+ [90;1m│[36;1mReference [90;1m│[36;1mStatus [90;1m│[36;1mTime (ms)[90;1m│[0m
+ [90;1m├─────────────────────────────────────────┼──────────────────────────────────┼─────────┤[0m
+ [90;1m│[0mpostgres:15-alpine [90;1m│[0m[31mMajor update (15 → 17) [0m[90;1m│[0m788 [90;1m│[0m
+ [90;1m│[0mghcr.io/immich-app/immich-server:v1.118.2[90;1m│[0m[33mMinor update (1.118.2 → 1.127.0) [0m[90;1m│[0m2294 [90;1m│[0m
+ [90;1m│[0mollama/ollama:0.4.1 [90;1m│[0m[33mMinor update (0.4.1 → 0.5.12) [0m[90;1m│[0m533 [90;1m│[0m
+ [90;1m│[0madguard/adguardhome:v0.107.52 [90;1m│[0m[34mPatch update (0.107.52 → 0.107.57)[0m[90;1m│[0m1738 [90;1m│[0m
+ [90;1m│[0mjc21/nginx-proxy-manager:latest [90;1m│[0m[32mUp to date [0m[90;1m│[0m583 [90;1m│[0m
+ [90;1m│[0mlouislam/uptime-kuma:1 [90;1m│[0m[32mUp to date [0m[90;1m│[0m793 [90;1m│[0m
+ [90;1m│[0mmoby/buildkit:buildx-stable-1 [90;1m│[0m[32mUp to date [0m[90;1m│[0m600 [90;1m│[0m
+ [90;1m│[0mtecnativa/docker-socket-proxy:latest [90;1m│[0m[32mUp to date [0m[90;1m│[0m564 [90;1m│[0m
+ [90;1m│[0mubuntu:latest [90;1m│[0m[32mUp to date [0m[90;1m│[0m585 [90;1m│[0m
+ [90;1m│[0mwagoodman/dive:latest [90;1m│[0m[32mUp to date [0m[90;1m│[0m585 [90;1m│[0m
+ [90;1m│[0mrolebot:latest [90;1m│[0m[90mUnknown [0m[90;1m│[0m174 [90;1m│[0m
+ [90;1m╰─────────────────────────────────────────┴──────────────────────────────────┴─────────╯[0m
+[36;1m INFO[0m ✨ Checked 11 images in 8312ms
+```
+
+### Check for updates to specific images
+
+```ansi
+$ cup check node:latest
+[32;1m✓[0m Done!
+[90;1m~ Local images[0m
+ [90;1m╭───────────┬────────────────┬─────────╮[0m
+ [90;1m│[36;1mReference [90;1m│[36;1mStatus [90;1m│[36;1mTime (ms)[90;1m│[0m
+ [90;1m├───────────┼────────────────┼─────────┤[0m
+ [90;1m│[0mnode:latest[90;1m│[0m[34mUpdate available[0m[90;1m│[0m788 [90;1m│[0m
+ [90;1m╰───────────┴────────────────┴─────────╯[0m
+[36;1m INFO[0m ✨ Checked 1 images in 310ms
+```
+
+```ansi
+$ cup check nextcloud:30 postgres:14 mysql:8.0[38;5;12m
+[32;1m✓[0m Done!
+[90;1m~ Local images[0m
+ [90;1m╭────────────┬────────────────────────┬─────────╮[0m
+ [90;1m│[36;1mReference [90;1m│[36;1mStatus [90;1m│[36;1mTime (ms)[90;1m│[0m
+ [90;1m├────────────┼────────────────────────┼─────────┤[0m
+ [90;1m│[0mpostgres:14 [90;1m│[0m[31mMajor update (14 → 17) [0m[90;1m│[0m195 [90;1m│[0m
+ [90;1m│[0mmysql:8.0 [90;1m│[0m[31mMajor update (8.0 → 9.2)[0m[90;1m│[0m382 [90;1m│[0m
+ [90;1m│[0mnextcloud:30[90;1m│[0m[32mUp to date [0m[90;1m│[0m585 [90;1m│[0m
+ [90;1m╰────────────┴────────────────────────┴─────────╯[0m
+[36;1m INFO[0m ✨ Checked 3 images in 769ms
+```
+
+## Enable icons
+
+You can also enable icons if you have a [Nerd Font](https://nerdfonts.com) installed.
+
+
+
+## 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,...}}
+```
+
+
+ When parsing Cup's output, capture only `stdout`, otherwise you might not get
+ valid JSON (if there are warnings)
+
+
+## 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
+```
diff --git a/docs/src/content/docs/usage/index.mdx b/docs/src/content/docs/usage/index.mdx
new file mode 100644
index 0000000..1ce9b57
--- /dev/null
+++ b/docs/src/content/docs/usage/index.mdx
@@ -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
+
+
+ } title="CLI" href="/docs/usage/cli" />
+ } title="Server" href="/docs/usage/server" />
+
diff --git a/docs/src/content/docs/usage/server.mdx b/docs/src/content/docs/usage/server.mdx
new file mode 100644
index 0000000..14683bf
--- /dev/null
+++ b/docs/src/content/docs/usage/server.mdx
@@ -0,0 +1,55 @@
+import { Callout } from "nextra/components";
+
+# Server
+
+The server provides the `cup serve` command.
+
+## Basic usage
+
+```ansi
+$ cup serve
+[36;1m INFO [0mStarting server, please wait...
+[36;1m INFO [0m✨ Checked 8 images in 8862ms
+[36;1m INFO [0mReady to start!
+[94;1m HTTP [0m[32mGET[0m / [32m200[0m in 0ms
+[94;1m HTTP [0m[32mGET[0m /assets/index.js [32m200[0m in 3ms
+[94;1m HTTP [0m[32mGET[0m /assets/index.css [32m200[0m in 0ms
+[94;1m HTTP [0m[32mGET[0m /api/v3/json [32m200[0m in 0ms
+```
+
+This will launch the server on port `8000`. To access it, visit `http://:8000` (replace `` with the IP address of the machine running Cup.)
+
+
+The URL `http://:8000/api/v3/json` is also available for usage with integrations.
+
+
+## Use a different port
+
+Pass the `-p` argument with the port you want to use
+
+```ansi
+$ cup serve -p 9000
+[36;1m INFO [0mStarting server, please wait...
+[36;1m INFO [0m✨ Checked 8 images in 8862ms
+[36;1m INFO [0mReady to start!
+[94;1m HTTP [0m[32mGET[0m / [32m200[0m in 0ms
+[94;1m HTTP [0m[32mGET[0m /assets/index.js [32m200[0m in 3ms
+[94;1m HTTP [0m[32mGET[0m /assets/index.css [32m200[0m in 0ms
+[94;1m HTTP [0m[32mGET[0m /api/v3/json [32m200[0m 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 : ghcr.io/sergi0g/cup`, where `` 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
+```
diff --git a/docs/src/mdx-components.ts b/docs/src/mdx-components.ts
new file mode 100644
index 0000000..8ebe2b2
--- /dev/null
+++ b/docs/src/mdx-components.ts
@@ -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,
+ };
+}
diff --git a/docs/styles.css b/docs/styles.css
deleted file mode 100644
index 6d80847..0000000
--- a/docs/styles.css
+++ /dev/null
@@ -1,7 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-.tabler-icon {
- color: rgb(250 250 250 / var(--tw-text-opacity)) !important
-}
\ No newline at end of file
diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js
deleted file mode 100644
index 71a7421..0000000
--- a/docs/tailwind.config.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-module.exports = {
- content: [
- "theme.config.jsx"
- ],
- theme: {
- extend: {},
- },
- plugins: [],
-}
-
diff --git a/docs/theme.config.jsx b/docs/theme.config.jsx
deleted file mode 100644
index 1fbe9b6..0000000
--- a/docs/theme.config.jsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { ThemeSwitch } from "nextra-theme-docs";
-import { useRouter } from "next/router";
-import { useConfig } from "nextra-theme-docs";
-
-export default {
- docsRepositoryBase: "https://github.com/sergi0g/cup/tree/main/docs",
- useNextSeoProps() {
- const { asPath } = useRouter()
- if (asPath !== '/') {
- return {
- titleTemplate: '%s – Cup'
- }
- }
- },
- head: () => {
- const { asPath } = useRouter()
- const { frontMatter } = useConfig()
- const url =
- 'https://sergi0g.github.io/cup/docs/' +
- (`/${asPath}`);
-
- return (
- <>
-
-
-
- >
- )
- },
- logo: (
-
-
-
Cup
-
- ),
- logoLink: "https://sergi0g.github.io/cup/docs/",
- project: {
- link: "https://github.com/sergi0g/cup/",
- },
- navbar: {
- extraContent: ,
- },
- toc: {
- backToTop: true,
- },
- footer: {
- text: null,
- },
- navigation: false,
-};
-
-function Logo() {
- return (
-
- );
-}
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
new file mode 100644
index 0000000..c133409
--- /dev/null
+++ b/docs/tsconfig.json
@@ -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"]
+}
diff --git a/screenshots/cup.gif b/screenshots/cup.gif
index 671dc79..89deb5a 100644
Binary files a/screenshots/cup.gif and b/screenshots/cup.gif differ
diff --git a/screenshots/web_dark.png b/screenshots/web_dark.png
index 575b16a..0fce9a5 100644
Binary files a/screenshots/web_dark.png and b/screenshots/web_dark.png differ
diff --git a/screenshots/web_light.png b/screenshots/web_light.png
index 5e7eb2e..f226903 100644
Binary files a/screenshots/web_light.png and b/screenshots/web_light.png differ
diff --git a/src/check.rs b/src/check.rs
index 060d7b8..fbcf321 100644
--- a/src/check.rs
+++ b/src/check.rs
@@ -1,63 +1,155 @@
use futures::future::join_all;
+use itertools::Itertools;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{
- config::Config, image::Image, registry::{check_auth, get_token}, utils::new_reqwest_client
+ docker::get_images_from_docker_daemon,
+ http::Client,
+ registry::{check_auth, get_token},
+ structs::{image::Image, update::Update},
+ utils::request::{get_response_body, parse_json},
+ Context,
};
-use crate::registry::get_latest_digest;
+/// Fetches image data from other Cup instances
+async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Vec {
+ let mut remote_images = Vec::new();
-/// Trait for a type that implements a function `unique` that removes any duplicates.
-/// In this case, it will be used for a Vec.
-pub trait Unique {
- fn unique(&mut self) -> Vec;
-}
+ 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), vec![], 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();
+ },
+ }
-impl Unique for Vec
-where
- T: Clone + Eq + std::hash::Hash,
-{
- /// Remove duplicates from Vec
- fn unique(self: &mut Vec) -> Self {
- let mut seen: FxHashSet = FxHashSet::default();
- self.retain(|item| seen.insert(item.clone()));
- self.to_vec()
+ }
+ match client.get(&json_url, vec![], 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 = 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(images: &[Image], config: &Config) -> Vec<(String, Option)> {
+pub async fn get_updates(
+ references: &Option>,
+ refresh: bool,
+ ctx: &Context,
+) -> Vec {
+ let client = Client::new(ctx);
+
+ // Get local images
+ ctx.logger.debug("Retrieving images to be checked");
+ let mut images = get_images_from_docker_daemon(ctx, references).await;
+
+ // Add extra images from references
+ if let Some(refs) = references {
+ let image_refs: FxHashSet<&String> = images.iter().map(|image| &image.reference).collect();
+ let extra = refs
+ .iter()
+ .filter(|&reference| !image_refs.contains(reference))
+ .map(|reference| Image::from_reference(reference))
+ .collect::>();
+ 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.registry.as_ref().unwrap())
- .collect::>()
- .unique();
+ .map(|image| &image.parts.registry)
+ .unique()
+ .collect::>();
// 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 = new_reqwest_client();
+ 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 {
+ for image in &images {
image_map
- .entry(image.registry.as_ref().unwrap())
+ .entry(&image.parts.registry)
.or_default()
.push(image);
}
// Retrieve an authentication token (if required) for each registry.
- let mut tokens: FxHashMap<&String, Option> = FxHashMap::default();
+ let mut tokens: FxHashMap<&str, Option> = FxHashMap::default();
for registry in registries {
- let credentials = config.authentication.get(registry);
- match check_auth(registry, config, &client).await {
+ let credentials = if let Some(registry_config) = ctx.config.registries.get(registry) {
+ ®istry_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,
+ credentials,
&client,
)
.await;
@@ -69,27 +161,41 @@ pub async fn get_updates(images: &[Image], config: &Config) -> Vec<(String, Opti
}
}
- // Create a Vec to store futures so we can await them all at once.
- let mut handles = Vec::new();
- // Loop through images and get the latest digest for each
- for image in images {
- let token = tokens.get(&image.registry.as_ref().unwrap()).unwrap();
- let future = get_latest_digest(image, token.as_ref(), config, &client);
- handles.push(future);
+ ctx.logger.debug(format!("Tokens: {:?}", tokens));
+
+ let ignored_registries = ctx
+ .config
+ .registries
+ .iter()
+ .filter_map(|(registry, registry_config)| {
+ if registry_config.ignore {
+ Some(registry)
+ } else {
+ None
+ }
+ })
+ .collect::>();
+
+ let mut handles = Vec::with_capacity(images.len());
+
+ // Loop through images check for updates
+ for image in &images {
+ let is_ignored = 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 final_images = join_all(handles).await;
-
- let mut result: Vec<(String, Option)> = Vec::with_capacity(images.len());
- final_images
- .iter()
- .for_each(|image| match &image.remote_digest {
- Some(digest) => {
- let has_update = !image.local_digests.as_ref().unwrap().contains(digest);
- result.push((image.reference.clone(), Some(has_update)))
- }
- None => result.push((image.reference.clone(), None)),
- });
-
- result
+ let images = join_all(handles).await;
+ let mut updates: Vec = images.iter().map(|image| image.to_update()).collect();
+ updates.extend_from_slice(&remote_updates);
+ updates
}
diff --git a/src/config.rs b/src/config.rs
index cc4aaac..2e0c20a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,130 +1,96 @@
use std::path::PathBuf;
use rustc_hash::FxHashMap;
+use serde::Deserialize;
use crate::error;
-const VALID_KEYS: [&str; 4] = ["authentication", "theme", "insecure_registries", "socket"];
-
-#[derive(Clone)]
+#[derive(Clone, Deserialize)]
pub enum Theme {
+ #[serde(rename = "default")]
Default,
+ #[serde(rename = "blue")]
Blue,
}
-#[derive(Clone)]
+impl Default for Theme {
+ fn default() -> Self {
+ Self::Default
+ }
+}
+
+#[derive(Clone, Deserialize, Default)]
+#[serde(deny_unknown_fields)]
+#[serde(default)]
+pub struct RegistryConfig {
+ pub authentication: Option,
+ pub insecure: bool,
+ pub ignore: bool,
+}
+
+#[derive(Clone, Deserialize, Default)]
+#[serde(default)]
+pub struct ImageConfig {
+ pub extra: Vec,
+ pub exclude: Vec,
+}
+
+#[derive(Clone, Deserialize)]
+#[serde(default)]
pub struct Config {
- pub authentication: FxHashMap,
- pub theme: Theme,
- pub insecure_registries: Vec,
+ version: u8,
+ pub agent: bool,
+ pub images: ImageConfig,
+ pub refresh_interval: Option,
+ pub registries: FxHashMap,
+ pub servers: FxHashMap,
pub socket: Option,
+ pub theme: Theme,
}
impl Config {
- /// A stupid new function that exists just so calling `load` doesn't require a self argument
- #[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self {
- authentication: FxHashMap::default(),
+ version: 3,
+ agent: false,
+ images: ImageConfig::default(),
+ refresh_interval: None,
+ registries: FxHashMap::default(),
+ servers: FxHashMap::default(),
+ socket: None,
theme: Theme::Default,
- insecure_registries: Vec::with_capacity(0),
- socket: None
}
}
+
/// Reads the config from the file path provided and returns the parsed result.
pub fn load(&self, path: Option) -> Self {
let raw_config = match &path {
Some(path) => std::fs::read_to_string(path),
- None => Ok(String::from("{}")), // Empty config
+ None => return Self::new(), // Empty config
};
if raw_config.is_err() {
- panic!(
+ 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. The process is quite manual and I would rather use a library, but I don't want to grow the dependency tree, for a config as simple as this one.
- /// Many of these checks are stupid, but we either validate the config properly, or we don't at all, so... this is the result. I _am not_ proud of this code.
+ /// Parses and validates the config.
pub fn parse(&self, raw_config: &str) -> Self {
- let json = match json::parse(raw_config) {
- Ok(v) => v,
- Err(e) => panic!("Failed to parse config!\n{}", e),
+ let config: Self = match serde_json::from_str(raw_config) {
+ Ok(config) => config,
+ Err(e) => error!("Unexpected error occured while parsing config: {}", e),
};
- // In the code, raw_ means the JsonValue from the parsed config, before it's validated.
-
- // Authentication
- let raw_authentication = &json["authentication"];
- if !raw_authentication.is_null() && !raw_authentication.is_object() {
- error!("Config key `authentication` must be an object!");
- }
- let mut authentication: FxHashMap = FxHashMap::default();
- raw_authentication.entries().for_each(|(registry, key)| {
- if !key.is_string() {
- error!("Config key `authentication.{}` must be a string!", registry);
- }
- authentication.insert(registry.to_string(), key.to_string());
- });
-
- // Theme
- let raw_theme = &json["theme"];
- if !raw_theme.is_null() && !raw_theme.is_string() {
- error!("Config key `theme` must be a string!");
- }
- let theme: Theme = {
- if raw_theme.is_null() {
- Theme::Default
- } else {
- match raw_theme.as_str().unwrap() {
- "default" => Theme::Default,
- "blue" => Theme::Blue,
- _ => {
- error!("Config key `theme` must be one of: `default`, `blue`!");
- }
- }
- }
- };
-
- // Insecure registries
- let raw_insecure_registries = &json["insecure_registries"];
- if !raw_insecure_registries.is_null() && !raw_insecure_registries.is_array() {
- error!("Config key `insecure_registries` must be an array!");
- }
- let insecure_registries: Vec = raw_insecure_registries
- .members()
- .map(|registry| {
- if !registry.is_string() {
- error!("Config key `insecure_registries` must only consist of strings!");
- } else {
- registry.as_str().unwrap().to_owned()
- }
- })
- .collect();
-
- // Socket
- let raw_socket = &json["socket"];
- if !raw_socket.is_null() && !raw_socket.is_string() {
- error!("Config key `socket` must be a string!");
- }
- let socket: Option = if raw_socket.is_null() {
- None
- } else {
- Some(raw_socket.to_string())
- };
-
- // Check for extra keys
- json.entries().for_each(|(key, _)| {
- if !VALID_KEYS.contains(&key) {
- error!("Invalid key `{}`", key)
- }
- });
-
- Self {
- authentication,
- theme,
- insecure_registries,
- socket,
+ 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()
}
}
diff --git a/src/docker.rs b/src/docker.rs
index 06c67c6..e7e8db7 100644
--- a/src/docker.rs
+++ b/src/docker.rs
@@ -2,62 +2,46 @@ use bollard::{models::ImageInspect, ClientVersion, Docker};
use futures::future::join_all;
-use crate::{error, image::Image, config::Config};
+use crate::{error, structs::image::Image, Context};
-fn create_docker_client(socket: Option) -> Docker {
+fn create_docker_client(socket: Option<&str>) -> Docker {
let client: Result = 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),
}
}
/// 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(
- config: &Config,
+ ctx: &Context,
references: &Option>,
) -> Vec {
- let client: Docker = create_docker_client(config.socket.clone());
- // If https://github.com/moby/moby/issues/48612 is fixed, this code should be faster. For now a workaround will be used.
- // let mut filters = HashMap::with_capacity(1);
- // match references {
- // Some(refs) => {
- // filters.insert("reference".to_string(), refs.clone());
- // }
- // None => (),
- // }
- // let images: Vec = match client
- // .list_images::(Some(ListImagesOptions {
- // filters,
- // ..Default::default()
- // }))
- // .await
- // {
- // Ok(images) => images,
- // Err(e) => {
- // error!("Failed to retrieve list of images available!\n{}", e)
- // }
- // };
- // let mut handles = Vec::new();
- // for image in images {
- // handles.push(Image::from(image, options))
- // }
- // join_all(handles)
- // .await
- // .iter()
- // .filter_map(|img| img.clone())
- // .collect()
+ let client: Docker = create_docker_client(ctx.config.socket.as_deref());
match references {
Some(refs) => {
let mut inspect_handles = Vec::with_capacity(refs.len());
@@ -70,14 +54,9 @@ pub async fn get_images_from_docker_daemon(
.filter(|inspect| inspect.is_ok())
.map(|inspect| inspect.as_ref().unwrap().clone())
.collect();
- let mut image_handles = Vec::with_capacity(inspects.len());
- for inspect in inspects {
- image_handles.push(Image::from_inspect(inspect.clone()));
- }
- join_all(image_handles)
- .await
+ inspects
.iter()
- .filter_map(|img| img.clone())
+ .filter_map(|inspect| Image::from_inspect_data(inspect.clone()))
.collect()
}
None => {
@@ -87,14 +66,9 @@ pub async fn get_images_from_docker_daemon(
error!("Failed to retrieve list of images available!\n{}", e)
}
};
- let mut handles = Vec::new();
- for image in images {
- handles.push(Image::from_summary(image))
- }
- join_all(handles)
- .await
+ images
.iter()
- .filter_map(|img| img.clone())
+ .filter_map(|image| Image::from_inspect_data(image.clone()))
.collect()
}
}
diff --git a/src/formatting.rs b/src/formatting.rs
deleted file mode 100644
index 4b4cfbd..0000000
--- a/src/formatting.rs
+++ /dev/null
@@ -1,71 +0,0 @@
-use std::time::Duration;
-
-use indicatif::{ProgressBar, ProgressStyle};
-
-use crate::utils::{sort_update_vec, to_json};
-
-pub fn print_updates(updates: &[(String, Option)], 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!(
- "{}{}{}{}{}\u{001b}[0m",
- color, icon, update.0, dynamic_space, description
- );
- }
-}
-
-pub fn print_raw_updates(updates: &[(String, Option)]) {
- println!("{}", json::stringify(to_json(updates)));
-}
-
-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);
- }
-}
diff --git a/src/formatting/mod.rs b/src/formatting/mod.rs
new file mode 100644
index 0000000..1020a29
--- /dev/null
+++ b/src/formatting/mod.rs
@@ -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╭{:─ "\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{: 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);
+ }
+}
diff --git a/src/http.rs b/src/http.rs
new file mode 100644
index 0000000..71a3996
--- /dev/null
+++ b/src/http.rs
@@ -0,0 +1,127 @@
+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: Vec<(&str, Option<&str>)>,
+ ignore_401: bool,
+ ) -> Result {
+ 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.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 {
+ error!(
+ "{} {}: Unexpected error: {}",
+ method,
+ url,
+ error.to_string()
+ )
+ }
+ }
+ }
+ }
+
+ pub async fn get(
+ &self,
+ url: &str,
+ headers: Vec<(&str, Option<&str>)>,
+ ignore_401: bool,
+ ) -> Result {
+ self.request(url, RequestMethod::GET, headers, ignore_401)
+ .await
+ }
+
+ pub async fn head(
+ &self,
+ url: &str,
+ headers: Vec<(&str, Option<&str>)>,
+ ) -> Result {
+ self.request(url, RequestMethod::HEAD, headers, false).await
+ }
+}
diff --git a/src/image.rs b/src/image.rs
deleted file mode 100644
index 6265d63..0000000
--- a/src/image.rs
+++ /dev/null
@@ -1,119 +0,0 @@
-use bollard::models::{ImageInspect, ImageSummary};
-use once_cell::sync::Lazy;
-use regex::Regex;
-
-use crate::error;
-
-/// Image struct that contains all information that may be needed by a function.
-/// It's designed to be passed around between functions
-#[derive(Clone, Debug)]
-pub struct Image {
- pub reference: String,
- pub registry: Option,
- pub repository: Option,
- pub tag: Option,
- pub local_digests: Option>,
- pub remote_digest: Option,
-}
-
-impl Image {
- /// Creates an populates the fields of an Image object based on the ImageSummary from the Docker daemon
- pub async fn from_summary(image: ImageSummary) -> Option {
- if !image.repo_tags.is_empty() && !image.repo_digests.is_empty() {
- let mut image = Image {
- reference: image.repo_tags[0].clone(),
- registry: None,
- repository: None,
- tag: None,
- local_digests: Some(
- image
- .repo_digests
- .clone()
- .iter()
- .map(|digest| digest.split('@').collect::>()[1].to_string())
- .collect(),
- ),
- remote_digest: None,
- };
- let (registry, repository, tag) = image.split();
- image.registry = Some(registry);
- image.repository = Some(repository);
- image.tag = Some(tag);
-
- return Some(image);
- }
- None
- }
-
- pub async fn from_inspect(image: ImageInspect) -> Option {
- if image.repo_tags.is_some()
- && !image.repo_tags.as_ref().unwrap().is_empty()
- && image.repo_digests.is_some()
- && !image.repo_digests.as_ref().unwrap().is_empty()
- {
- let mut image = Image {
- reference: image.repo_tags.as_ref().unwrap()[0].clone(),
- registry: None,
- repository: None,
- tag: None,
- local_digests: Some(
- image
- .repo_digests
- .unwrap()
- .clone()
- .iter()
- .map(|digest| digest.split('@').collect::>()[1].to_string())
- .collect(),
- ),
- remote_digest: None,
- };
- let (registry, repository, tag) = image.split();
- image.registry = Some(registry);
- image.repository = Some(repository);
- image.tag = Some(tag);
-
- return Some(image);
- }
- None
- }
-
- /// Takes an image and splits it into registry, repository and tag, based on the reference.
- /// For example, `ghcr.io/sergi0g/cup:latest` becomes `['ghcr.io', 'sergi0g/cup', 'latest']`.
- pub fn split(&self) -> (String, String, String) {
- match RE.captures(&self.reference) {
- Some(c) => {
- let registry = match c.name("registry") {
- Some(registry) => registry.as_str().to_owned(),
- None => String::from("registry-1.docker.io"),
- };
- return (
- registry.clone(),
- match c.name("repository") {
- Some(repository) => {
- let repo = repository.as_str().to_owned();
- if !repo.contains('/') && registry == "registry-1.docker.io" {
- format!("library/{}", repo)
- } else {
- repo
- }
- }
- None => error!("Failed to parse image {}", &self.reference),
- },
- match c.name("tag") {
- Some(tag) => tag.as_str().to_owned(),
- None => String::from("latest"),
- },
- );
- }
- None => error!("Failed to parse image {}", &self.reference),
- }
- }
-}
-
-/// Regex to match Docker image references against, so registry, repository and tag can be extracted.
-static RE: Lazy = Lazy::new(|| {
- Regex::new(
- r#"^(?P(?:(?P(?:(?:localhost|[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)/)?(?P[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*))(?::(?P[\w][\w.-]{0,127}))?$"#, // From https://regex101.com/r/nmSDPA/1
- )
- .unwrap()
-});
diff --git a/src/logging.rs b/src/logging.rs
new file mode 100644
index 0000000..dd7b169
--- /dev/null
+++ b/src/logging.rs
@@ -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) {
+ if !self.raw {
+ eprintln!("\x1b[33;1m WARN\x1b[0m {}", msg.as_ref());
+ }
+ }
+
+ pub fn info(&self, msg: impl AsRef) {
+ if !self.raw {
+ println!("\x1b[36;1m INFO\x1b[0m {}", msg.as_ref());
+ }
+ }
+
+ pub fn debug(&self, msg: impl AsRef) {
+ if self.debug {
+ println!("\x1b[35;1mDEBUG\x1b[0m {}", msg.as_ref());
+ }
+ }
+
+ pub fn set_raw(&mut self, raw: bool) {
+ self.raw = raw
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 5b62500..5bc4dc6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,23 +1,26 @@
use check::get_updates;
-use chrono::Local;
use clap::{Parser, Subcommand};
use config::Config;
-use docker::get_images_from_docker_daemon;
+use formatting::spinner::Spinner;
#[cfg(feature = "cli")]
-use formatting::{print_raw_updates, print_updates, Spinner};
+use formatting::{print_raw_updates, print_updates};
+use logging::Logger;
#[cfg(feature = "server")]
use server::serve;
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)]
@@ -29,13 +32,17 @@ struct Cli {
config_path: String,
#[command(subcommand)]
command: Option,
+ #[arg(short, long)]
+ debug: bool,
+ #[arg(long)]
+ refresh: bool,
}
#[derive(Subcommand)]
enum Commands {
#[cfg(feature = "cli")]
Check {
- #[arg(name = "Images", default_value = None)]
+ #[arg(name = "images", default_value = None)]
references: Option>,
#[arg(short, long, default_value_t = false, help = "Enable icons")]
icons: bool,
@@ -59,6 +66,12 @@ enum Commands {
},
}
+#[derive(Clone)]
+pub struct Context {
+ pub config: Config,
+ pub logger: Logger,
+}
+
#[tokio::main]
async fn main() {
let cli = Cli::parse();
@@ -67,10 +80,13 @@ async fn main() {
path => Some(PathBuf::from(path)),
};
let mut config = Config::new().load(cfg_path);
- match cli.socket {
- Some(socket) => config.socket = Some(socket),
- None => ()
+ 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 {
@@ -78,27 +94,28 @@ async fn main() {
icons,
raw,
}) => {
- let start = Local::now().timestamp_millis();
- let images = get_images_from_docker_daemon(&config, references).await;
- match raw {
+ let start = SystemTime::now();
+ if *raw {
+ ctx.logger.set_raw(true);
+ }
+ match *raw || cli.debug {
true => {
- let updates = get_updates(&images, &config).await;
+ let updates = get_updates(references, cli.refresh, &ctx).await;
print_raw_updates(&updates);
}
false => {
let spinner = Spinner::new();
- let updates = get_updates(&images, &config).await;
+ let updates = get_updates(references, cli.refresh, &ctx).await;
spinner.succeed();
- let end = Local::now().timestamp_millis();
print_updates(&updates, icons);
- info!("✨ Checked {} images in {}ms", updates.len(), end - start);
+ ctx.logger.info(format!("✨ Checked {} images in {}ms", updates.len(), start.elapsed().unwrap().as_millis()));
}
};
}
#[cfg(feature = "server")]
Some(Commands::Serve { port }) => {
- let _ = serve(port, &config).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."),
}
}
diff --git a/src/registry.rs b/src/registry.rs
index 11d34d6..a5bcb5c 100644
--- a/src/registry.rs
+++ b/src/registry.rs
@@ -1,179 +1,247 @@
-use json::JsonValue;
+use std::time::SystemTime;
-use http_auth::parse_challenges;
-use reqwest_middleware::ClientWithMiddleware;
+use itertools::Itertools;
-use crate::{config::Config, error, image::Image, warn};
+use crate::{
+ 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,
+};
-pub async fn check_auth(
- registry: &str,
- config: &Config,
- client: &ClientWithMiddleware,
-) -> Option {
- let protocol = if config.insecure_registries.contains(®istry.to_string()) {
- "http"
- } else {
- "https"
- };
- let response = client
- .get(format!("{}://{}/v2/", protocol, registry))
- .send()
- .await;
+pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Option {
+ let protocol = get_protocol(registry, &ctx.config.registries);
+ let url = format!("{}://{}/v2/", protocol, registry);
+ let response = client.get(&url, Vec::new(), true).await;
match response {
- Ok(r) => {
- let status = r.status().as_u16();
+ Ok(response) => {
+ let status = response.status();
if status == 401 {
- match r.headers().get("www-authenticate") {
- Some(challenge) => Some(parse_www_authenticate(challenge.to_str().unwrap())),
- None => error!(
- "Unauthorized to access registry {} and no way to authenticate was provided",
- registry
- ),
- }
- } else if status == 200 {
- None
+ 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 {
- warn!(
- "Received unexpected status code {}\nResponse: {}",
- status,
- r.text().await.unwrap()
- );
None
}
}
- Err(e) => {
- if e.is_connect() {
- warn!("Connection to registry {} failed.", ®istry);
- None
- } else {
- error!("Unexpected error: {}", e.to_string())
- }
- }
+ Err(_) => None,
}
}
pub async fn get_latest_digest(
image: &Image,
- token: Option<&String>,
- config: &Config,
- client: &ClientWithMiddleware,
+ token: Option<&str>,
+ ctx: &Context,
+ client: &Client,
) -> Image {
- let protocol = if config.insecure_registries.contains(&image.registry.clone().unwrap())
- {
- "http"
- } else {
- "https"
- };
- let mut request = client.head(format!(
+ 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.registry.as_ref().unwrap(),
- &image.repository.as_ref().unwrap(),
- &image.tag.as_ref().unwrap()
+ protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag
+ );
+ let authorization = to_bearer_string(&token);
+ let headers = vec![("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
));
- if let Some(t) = token {
- request = request.header("Authorization", &format!("Bearer {}", t));
- }
- let raw_response = match request
- .header("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")
- .send().await
- {
- Ok(response) => {
- let status = response.status();
- if status == 401 {
- if token.is_some() {
- warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry.as_ref().unwrap(), token.unwrap());
- } else {
- warn!("Registry requires authentication");
+ 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()
}
- return Image { remote_digest: None, ..image.clone() }
- } else if status == 404 {
- warn!("Image {:?} not found", &image);
- return Image { remote_digest: None, ..image.clone() }
- } else {
- response
}
+ None => error!(
+ "Server returned invalid response! No docker-content-digest!\n{:#?}",
+ res
+ ),
},
- Err(e) => {
- if e.is_connect() {
- warn!("Connection to registry failed.");
- return Image { remote_digest: None, ..image.clone() }
- } else {
- error!("Unexpected error: {}", e.to_string())
- }
- },
- };
- match raw_response.headers().get("docker-content-digest") {
- Some(digest) => Image {
- remote_digest: Some(digest.to_str().unwrap().to_string()),
+ Err(error) => Image {
+ error: Some(error),
+ time_ms: image.time_ms + time,
..image.clone()
},
- None => error!(
- "Server returned invalid response! No docker-content-digest!\n{:#?}",
- raw_response
- ),
}
}
pub async fn get_token(
images: &Vec<&Image>,
auth_url: &str,
- credentials: &Option<&String>,
- client: &ClientWithMiddleware,
+ credentials: &Option,
+ client: &Client,
) -> String {
- let mut final_url = auth_url.to_owned();
+ let mut url = auth_url.to_owned();
for image in images {
- final_url = format!(
- "{}&scope=repository:{}:pull",
- final_url,
- image.repository.as_ref().unwrap()
- );
+ url = format!("{}&scope=repository:{}:pull", url, image.parts.repository);
}
- let mut base_request = client
- .get(&final_url)
- .header("Accept", "application/vnd.oci.image.index.v1+json"); // Seems to be unnecessary. Will probably remove in the future
- base_request = match credentials {
- Some(creds) => base_request.header("Authorization", &format!("Basic {}", creds)),
- None => base_request,
+ let authorization = credentials.as_ref().map(|creds| format!("Basic {}", creds));
+ let headers = vec![("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 raw_response = match base_request.send().await {
- Ok(response) => match response.text().await {
- Ok(res) => res,
- Err(e) => {
- error!("Failed to parse response into string!\n{}", e)
- }
- },
- Err(e) => {
- if e.is_connect() {
- error!("Connection to registry failed.");
- } else {
- error!("Token request failed!\n{}", e.to_string())
- }
- }
- };
- let parsed_token_response: JsonValue = match json::parse(&raw_response) {
- 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 = vec![
+ ("Accept", Some("application/json")),
+ ("Authorization", authorization.as_deref()),
+ ];
+
+ let mut tags: Vec = 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.clone(),
+ base,
+ &image.version_info.as_ref().unwrap().format_str,
+ 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: Some(VersionInfo {
+ latest_remote_tag: Some(t.clone()),
+ ..image.version_info.as_ref().unwrap().clone()
+ }),
+ 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 by the server");
+ None => unreachable!("{:?}", tags),
+ }
+}
+
+pub async fn get_extra_tags(
+ url: &str,
+ headers: Vec<(&str, Option<&str>)>,
+ base: &Version,
+ format_str: &str,
+ client: &Client,
+) -> Result<(Vec, Option), 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,
+ })
+ .map(|(tag, _)| tag)
+ .dedup()
+ .collect();
+ Ok((result, next_url))
+ }
+ Err(message) => Err(message),
}
}
diff --git a/src/server.rs b/src/server.rs
index 0ee6f77..8df3694 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -1,20 +1,31 @@
use std::sync::Arc;
use chrono::Local;
-use json::JsonValue;
-use liquid::{object, Object};
+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,
+ error::Error,
handler::{handler_service, path::PathRef, state::StateRef},
- http::WebResponse,
- middleware::Logger,
+ http::{StatusCode, WebResponse},
route::get,
- App,
+ service::Service,
+ App, WebContext,
};
use crate::{
- check::get_updates, config::{Config, Theme}, docker::get_images_from_docker_daemon, info, utils::{sort_update_vec, to_json}
+ check::get_updates,
+ config::Theme,
+ structs::update::Update,
+ utils::{
+ json::{to_full_json, to_simple_json},
+ sort_update_vec::sort_update_vec,
+ time::{elapsed, now},
+ },
+ Context,
};
const HTML: &str = include_str!("static/index.html");
@@ -24,17 +35,52 @@ const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico");
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png");
-pub async fn serve(port: &u16, config: &Config) -> std::io::Result<()> {
- info!("Starting server, please wait...");
- let data = ServerData::new(config).await;
- info!("Ready to start!");
- App::new()
- .with_state(Arc::new(Mutex::new(data)))
- .at("/", get(handler_service(_static)))
- .at("/json", get(handler_service(json)))
- .at("/refresh", get(handler_service(refresh)))
- .at("/*", get(handler_service(_static)))
- .enclosed(Logger::new())
+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();
+ if let Some(interval) = &ctx.config.refresh_interval {
+ scheduler
+ .add(
+ Job::new_async(interval, move |_uuid, _lock| {
+ let data_copy = data_copy.clone();
+ Box::pin(async move {
+ data_copy.lock().await.refresh().await;
+ })
+ })
+ .unwrap(),
+ )
+ .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)));
+ }
+ app_builder
+ .enclosed_fn(logger)
.serve()
.bind(format!("0.0.0.0:{}", port))?
.run()
@@ -77,10 +123,22 @@ async fn _static(data: StateRef<'_, Arc>>, path: PathRef<'_>)
}
}
-async fn json(data: StateRef<'_, Arc>>) -> WebResponse {
- WebResponse::new(ResponseBody::from(json::stringify(
- data.lock().await.json.clone(),
- )))
+async fn api_simple(data: StateRef<'_, Arc>>) -> WebResponse {
+ WebResponse::builder()
+ .header("Content-Type", "application/json")
+ .body(ResponseBody::from(
+ data.lock().await.simple_json.clone().to_string(),
+ ))
+ .unwrap()
+}
+
+async fn api_full(data: StateRef<'_, Arc>>) -> 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>>) -> WebResponse {
@@ -90,21 +148,20 @@ async fn refresh(data: StateRef<'_, Arc>>) -> WebResponse {
struct ServerData {
template: String,
- raw_updates: Vec<(String, Option)>,
- json: JsonValue,
- config: Config,
+ raw_updates: Vec,
+ simple_json: Value,
+ full_json: Value,
+ ctx: Context,
theme: &'static str,
}
impl ServerData {
- async fn new(config: &Config) -> Self {
+ async fn new(ctx: &Context) -> Self {
let mut s = Self {
- config: config.clone(),
+ ctx: ctx.clone(),
template: String::new(),
- json: json::object! {
- metrics: json::object! {},
- images: json::object! {},
- },
+ simple_json: Value::Null,
+ full_json: Value::Null,
raw_updates: Vec::new(),
theme: "neutral",
};
@@ -112,44 +169,108 @@ impl ServerData {
s
}
async fn refresh(&mut self) {
- let start = Local::now().timestamp_millis();
+ let start = now();
if !self.raw_updates.is_empty() {
- info!("Refreshing data");
+ self.ctx.logger.info("Refreshing data");
}
- let images = get_images_from_docker_daemon(&self.config, &None).await;
- let updates = sort_update_vec(&get_updates(&images, &self.config).await);
- let end = Local::now().timestamp_millis();
- info!("✨ Checked {} images in {}ms", updates.len(), end - start);
+ 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();
- let images = self
- .raw_updates
- .iter()
- .map(|(name, has_update)| match has_update {
- Some(v) => object!({"name": name, "has_update": v.to_string()}), // Liquid kinda thinks false == nil, so we'll be comparing strings from now on
- None => object!({"name": name, "has_update": "null"}),
- })
- .collect::>();
- self.json = to_json(&self.raw_updates);
+ self.simple_json = to_simple_json(&self.raw_updates);
+ self.full_json = to_full_json(&self.raw_updates);
let last_updated = Local::now();
- self.json["last_updated"] = last_updated
+ self.simple_json["last_updated"] = last_updated
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string()
.into();
- self.theme = match &self.config.theme {
+ self.full_json["last_updated"] = self.simple_json["last_updated"].clone();
+ self.theme = match &self.ctx.config.theme {
Theme::Default => "neutral",
- Theme::Blue => "gray"
+ Theme::Blue => "gray",
};
+ let mut metrics = self.simple_json["metrics"]
+ .as_object()
+ .unwrap()
+ .iter()
+ .map(|(key, value)| liquid::object!({ "name": key, "value": value }))
+ .collect::>();
+ 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