This commit is contained in:
Anders Roos 2023-01-16 23:15:20 +01:00 committed by GitHub
commit beba45c17b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 3951 additions and 33 deletions

View file

@ -14,9 +14,12 @@ import (
"github.com/containrrr/watchtower/internal/flags"
"github.com/containrrr/watchtower/internal/meta"
"github.com/containrrr/watchtower/pkg/api"
apiCheck "github.com/containrrr/watchtower/pkg/api/check"
apiList "github.com/containrrr/watchtower/pkg/api/list"
apiMetrics "github.com/containrrr/watchtower/pkg/api/metrics"
"github.com/containrrr/watchtower/pkg/api/update"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/dashboard"
"github.com/containrrr/watchtower/pkg/filters"
"github.com/containrrr/watchtower/pkg/metrics"
"github.com/containrrr/watchtower/pkg/notifications"
@ -152,11 +155,16 @@ func Run(c *cobra.Command, names []string) {
enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics")
unblockHTTPAPI, _ := c.PersistentFlags().GetBool("http-api-periodic-polls")
apiToken, _ := c.PersistentFlags().GetString("http-api-token")
enableDashboard, _ := c.PersistentFlags().GetBool("http-web-dashboard")
if rollingRestart && monitorOnly {
log.Fatal("Rolling restarts is not compatible with the global monitor only flag")
}
if enableDashboard {
enableUpdateAPI = true
}
awaitDockerClient()
if err := actions.CheckForSanity(client, filter, rollingRestart); err != nil {
@ -182,13 +190,30 @@ func Run(c *cobra.Command, names []string) {
httpAPI := api.New(apiToken)
if enableUpdateAPI {
updateHandler := update.New(func(images []string) { runUpdatesWithNotifications(filters.FilterByImage(images, filter)) }, updateLock)
updateHandler := update.New(
func(images []string) { runUpdatesWithNotifications(filters.FilterByImage(images, filter)) },
func(containers []string) { runUpdatesWithNotifications(filters.FilterByNames(containers, filter)) },
updateLock)
httpAPI.RegisterFunc(updateHandler.Path, updateHandler.Handle)
// If polling isn't enabled the scheduler is never started and
// we need to trigger the startup messages manually.
if !unblockHTTPAPI {
writeStartupMessage(c, time.Time{}, filterDesc)
}
apiListHandler := apiList.New(client)
httpAPI.RegisterFunc(apiListHandler.Path, apiListHandler.HandleGet)
apiCheckHandler := apiCheck.New(client)
httpAPI.RegisterFunc(apiCheckHandler.Path, apiCheckHandler.HandlePost)
}
if enableDashboard {
httpDashboard := dashboard.New()
if err := httpDashboard.Start(); err != nil && err != http.ErrServerClosed {
log.Error("failed to start dashboard server", err)
}
}
if enableMetricsAPI {
@ -260,6 +285,7 @@ func formatDuration(d time.Duration) string {
func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message")
enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
enableDashboardWeb, _ := c.PersistentFlags().GetBool("http-web-dashboard")
var startupLog *log.Entry
if noStartupMessage {
@ -296,6 +322,11 @@ func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
startupLog.Info("The HTTP API is enabled at :8080.")
}
if enableDashboardWeb {
// TODO: make listen port configurable
startupLog.Info("The HTTP Web dashboard is enabled at :8001.")
}
if !noStartupMessage {
// Send the queued up startup messages, not including the trace warning below (to make sure it's noticed)
notifier.SendNotification(nil)

View file

@ -285,6 +285,16 @@ Environment Variable: WATCHTOWER_HTTP_API_PERIODIC_POLLS
Default: false
```
## HTTP Web Dashboard
Enables the web dashboard at http://localhost:8001
```text
Argument: --http-web-dashboard
Environment Variable: WATCHTOWER_HTTP_WEB_DASHBOARD
Type: Boolean
Default: false
```
## Filter by scope
Update containers that have a `com.centurylinklabs.watchtower.scope` label set with the same value as the given argument.
This enables [running multiple instances](https://containrrr.dev/watchtower/running-multiple-instances).

View file

@ -88,12 +88,12 @@ func (client MockClient) ExecuteCommand(_ t.ContainerID, command string, _ int)
}
// IsContainerStale is true if not explicitly stated in TestData for the mock client
func (client MockClient) IsContainerStale(cont container.Container) (bool, t.ImageID, error) {
func (client MockClient) IsContainerStale(cont container.Container) (bool, t.ImageID, string, error) {
stale, found := client.TestData.Staleness[cont.Name()]
if !found {
stale = true
}
return stale, "", nil
return stale, "", "", nil
}
// WarnOnHeadPullFailed is always true for the mock client

View file

@ -34,7 +34,7 @@ func Update(client container.Client, params types.UpdateParams) (types.Report, e
staleCheckFailed := 0
for i, targetContainer := range containers {
stale, newestImage, err := client.IsContainerStale(targetContainer)
stale, newestImage, _, err := client.IsContainerStale(targetContainer)
shouldUpdate := stale && !params.NoRestart && !params.MonitorOnly && !targetContainer.IsMonitorOnly()
if err == nil && shouldUpdate {
// Check to make sure we have all the necessary information for recreating the container

View file

@ -163,6 +163,12 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
viper.GetBool("WATCHTOWER_HTTP_API_PERIODIC_POLLS"),
"Also run periodic updates (specified with --interval and --schedule) if HTTP API is enabled")
flags.BoolP(
"http-web-dashboard",
"",
viper.GetBool("WATCHTOWER_HTTP_WEB_DASHBOARD"),
"Enables the web dashboard at http://localhost:8001")
// https://no-color.org/
flags.BoolP(
"no-color",

View file

@ -23,6 +23,22 @@ func New(token string) *API {
}
}
// EnableCors is a middleware that enables CORS for the API
func (api *API) EnableCors(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Access-Control-Allow-Origin", "*")
w.Header().Add("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
w.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
if r.Method == http.MethodOptions {
http.Error(w, "No Content", http.StatusNoContent)
return
}
fn(w, r)
}
}
// RequireToken is wrapper around http.HandleFunc that checks token validity
func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@ -40,7 +56,7 @@ func (api *API) RequireToken(fn http.HandlerFunc) http.HandlerFunc {
// RegisterFunc is a wrapper around http.HandleFunc that also sets the flag used to determine whether to launch the API
func (api *API) RegisterFunc(path string, fn http.HandlerFunc) {
api.hasHandlers = true
http.HandleFunc(path, api.RequireToken(fn))
http.HandleFunc(path, api.EnableCors(api.RequireToken(fn)))
}
// RegisterHandler is a wrapper around http.Handler that also sets the flag used to determine whether to launch the API

105
pkg/api/check/check.go Normal file
View file

@ -0,0 +1,105 @@
package check
import (
"encoding/json"
"net/http"
log "github.com/sirupsen/logrus"
"github.com/containrrr/watchtower/pkg/container"
"github.com/containrrr/watchtower/pkg/types"
)
// Handler is an HTTP handle for serving check data
type Handler struct {
Path string
Client container.Client
}
type checkRequest struct {
ContainerID string
}
type checkResponse struct {
ContainerID string
HasUpdate bool
NewVersion string
NewVersionCreated string
}
// New is a factory function creating a new List instance
func New(client container.Client) *Handler {
return &Handler{
Path: "/v1/check",
Client: client,
}
}
// HandlePost is the actual http.HandlePost function doing all the heavy lifting
func (handle *Handler) HandlePost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
log.Info("Calling Check API with unsupported method")
w.WriteHeader(http.StatusNotFound)
return
}
log.Info("Check for update triggered by HTTP API request.")
var request checkRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
log.Error(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
client := handle.Client
container, err := client.GetContainer(types.ContainerID(request.ContainerID))
if err != nil {
log.Error(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
hasUpdate := false
newVersion := ""
newVersionCreated := ""
matches, err := client.ContainerDigestMatchesWithRegistry(container)
hasUpdate = !matches
if err != nil {
stale, newestImage, created, err := client.IsContainerStale(container)
hasUpdate = stale
newVersion = newestImage.ShortID()
newVersionCreated = created
if err != nil {
log.Error(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
}
data := checkResponse{
ContainerID: request.ContainerID,
HasUpdate: hasUpdate,
NewVersion: newVersion,
NewVersionCreated: newVersionCreated,
}
jsonData, err := json.Marshal(data)
if err != nil {
log.Fatalf("Error happened in JSON marshal. Err: %s", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonData)
}

81
pkg/api/list/list.go Normal file
View file

@ -0,0 +1,81 @@
package list
import (
"encoding/json"
"net/http"
"strings"
log "github.com/sirupsen/logrus"
"github.com/containrrr/watchtower/pkg/container"
filters "github.com/containrrr/watchtower/pkg/filters"
)
// Handler is an HTTP handle for serving list data
type Handler struct {
Path string
Client container.Client
}
type containerListEntry struct {
ContainerID string
ContainerName string
ImageName string
ImageNameShort string
ImageVersion string
ImageCreatedDate string
}
type listResponse struct {
Containers []containerListEntry
}
// New is a factory function creating a new List instance
func New(client container.Client) *Handler {
return &Handler{
Path: "/v1/list",
Client: client,
}
}
// HandleGet is the actual http.HandleGet function doing all the heavy lifting
func (handle *Handler) HandleGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
log.Info("Calling List API with unsupported method")
w.WriteHeader(http.StatusNotFound)
return
}
log.Info("List containers triggered by HTTP API request.")
client := handle.Client
filter := filters.NoFilter
containers, err := client.ListContainers(filter)
if err != nil {
log.Error(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
data := listResponse{Containers: []containerListEntry{}}
for _, c := range containers {
data.Containers = append(data.Containers, containerListEntry{
ContainerID: c.ID().ShortID(),
ContainerName: c.Name()[1:],
ImageName: c.ImageName(),
ImageNameShort: strings.Split(c.ImageName(), ":")[0],
ImageCreatedDate: c.ImageInfo().Created,
ImageVersion: c.ImageID().ShortID(),
})
}
jsonData, err := json.Marshal(data)
if err != nil {
log.Fatalf("Error happened in JSON marshal. Err: %s", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonData)
}

View file

@ -14,7 +14,7 @@ var (
)
// New is a factory function creating a new Handler instance
func New(updateFn func(images []string), updateLock chan bool) *Handler {
func New(updateImages func(images []string), updateContainers func(containerNames []string), updateLock chan bool) *Handler {
if updateLock != nil {
lock = updateLock
} else {
@ -23,15 +23,17 @@ func New(updateFn func(images []string), updateLock chan bool) *Handler {
}
return &Handler{
fn: updateFn,
Path: "/v1/update",
updateImages: updateImages,
updateContainers: updateContainers,
Path: "/v1/update",
}
}
// Handler is an API handler used for triggering container update scans
type Handler struct {
fn func(images []string)
Path string
updateImages func(images []string)
updateContainers func(containerNames []string)
Path string
}
// Handle is the actual http.Handle function doing all the heavy lifting
@ -55,15 +57,30 @@ func (handle *Handler) Handle(w http.ResponseWriter, r *http.Request) {
images = nil
}
var containers []string
containerQueries, found := r.URL.Query()["container"]
if found {
for _, container := range containerQueries {
containers = append(containers, strings.Split(container, ",")...)
}
} else {
containers = nil
}
if len(images) > 0 {
chanValue := <-lock
defer func() { lock <- chanValue }()
handle.fn(images)
handle.updateImages(images)
} else if len(containers) > 0 {
chanValue := <-lock
defer func() { lock <- chanValue }()
handle.updateContainers(containers)
} else {
select {
case chanValue := <-lock:
defer func() { lock <- chanValue }()
handle.fn(images)
handle.updateImages(images)
default:
log.Debug("Skipped. Another update already running.")
}

View file

@ -30,18 +30,19 @@ type Client interface {
StopContainer(Container, time.Duration) error
StartContainer(Container) (t.ContainerID, error)
RenameContainer(Container, string) error
IsContainerStale(Container) (stale bool, latestImage t.ImageID, err error)
IsContainerStale(Container) (stale bool, latestImage t.ImageID, created string, err error)
ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error)
RemoveImageByID(t.ImageID) error
WarnOnHeadPullFailed(container Container) bool
ContainerDigestMatchesWithRegistry(container Container) (matches bool, err error)
}
// NewClient returns a new Client instance which can be used to interact with
// the Docker API.
// The client reads its configuration from the following environment variables:
// * DOCKER_HOST the docker-engine host to send api requests to
// * DOCKER_TLS_VERIFY whether to verify tls certificates
// * DOCKER_API_VERSION the minimum docker api version to work with
// - DOCKER_HOST the docker-engine host to send api requests to
// - DOCKER_TLS_VERIFY whether to verify tls certificates
// - DOCKER_API_VERSION the minimum docker api version to work with
func NewClient(opts ClientOptions) Client {
cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv)
@ -277,35 +278,35 @@ func (client dockerClient) RenameContainer(c Container, newName string) error {
return client.api.ContainerRename(bg, string(c.ID()), newName)
}
func (client dockerClient) IsContainerStale(container Container) (stale bool, latestImage t.ImageID, err error) {
func (client dockerClient) IsContainerStale(container Container) (stale bool, latestImage t.ImageID, created string, err error) {
ctx := context.Background()
if !client.PullImages {
log.Debugf("Skipping image pull.")
} else if err := client.PullImage(ctx, container); err != nil {
return false, container.SafeImageID(), err
return false, container.SafeImageID(), "", err
}
return client.HasNewImage(ctx, container)
}
func (client dockerClient) HasNewImage(ctx context.Context, container Container) (hasNew bool, latestImage t.ImageID, err error) {
func (client dockerClient) HasNewImage(ctx context.Context, container Container) (hasNew bool, latestImage t.ImageID, created string, err error) {
currentImageID := t.ImageID(container.containerInfo.ContainerJSONBase.Image)
imageName := container.ImageName()
newImageInfo, _, err := client.api.ImageInspectWithRaw(ctx, imageName)
if err != nil {
return false, currentImageID, err
return false, currentImageID, "", err
}
newImageID := t.ImageID(newImageInfo.ID)
if newImageID == currentImageID {
log.Debugf("No new images found for %s", container.Name())
return false, currentImageID, nil
return false, currentImageID, "", nil
}
log.Infof("Found new %s image (%s)", imageName, newImageID.ShortID())
return true, newImageID, nil
return true, newImageID, newImageInfo.Created, nil
}
// PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed
@ -335,21 +336,13 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
log.WithFields(fields).Debugf("Checking if pull is needed")
if match, err := digest.CompareDigest(container, opts.RegistryAuth); err != nil {
headLevel := log.DebugLevel
if client.WarnOnHeadPullFailed(container) {
headLevel = log.WarnLevel
}
log.WithFields(fields).Logf(headLevel, "Could not do a head request for %q, falling back to regular pull.", imageName)
log.WithFields(fields).Log(headLevel, "Reason: ", err)
} else if match {
matches, err := client.digestMatchesWithRegistry(ctx, container, opts.RegistryAuth)
if matches == true {
log.Debug("No pull needed. Skipping image.")
return nil
} else {
log.Debug("Digests did not match, doing a pull.")
}
log.WithFields(fields).Debugf("Pulling image")
log.WithFields(fields).Debugf("Digests did not match, pulling image")
response, err := client.api.ImagePull(ctx, imageName, opts)
if err != nil {
@ -366,6 +359,40 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
return nil
}
func (client dockerClient) ContainerDigestMatchesWithRegistry(container Container) (matches bool, err error) {
ctx := context.Background()
imageName := container.ImageName()
opts, err := registry.GetPullOptions(imageName)
if err != nil {
log.Debugf("Error loading authentication credentials %s", err)
return false, err
}
return client.digestMatchesWithRegistry(ctx, container, opts.RegistryAuth)
}
func (client dockerClient) digestMatchesWithRegistry(ctx context.Context, container Container, registryAuth string) (matches bool, err error) {
containerName := container.Name()
imageName := container.ImageName()
fields := log.Fields{
"image": imageName,
"container": containerName,
}
match, err := digest.CompareDigest(container, registryAuth)
if err != nil {
headLevel := log.DebugLevel
if client.WarnOnHeadPullFailed(container) {
headLevel = log.WarnLevel
}
log.WithFields(fields).Logf(headLevel, "Could not do a head request for %q, falling back to regular pull.", imageName)
log.WithFields(fields).Log(headLevel, "Reason: ", err)
return false, err
}
return match, nil
}
func (client dockerClient) RemoveImageByID(id t.ImageID) error {
log.Infof("Removing image %s", id.ShortID())

View file

@ -0,0 +1,82 @@
package dashboard
import (
"fmt"
"html/template"
"net/http"
"strings"
log "github.com/sirupsen/logrus"
)
// Dashboard is the http server responsible for serving the static Dashboard files
type Dashboard struct {
port string
rootDir string
apiPort string
apiScheme string
apiVersion string
}
// New is a factory function creating a new Dashboard instance
func New() *Dashboard {
const webRootDir = "./web/dist" // Todo: needs to work in containerized environment
const webPort = "8001" // Todo: make configurable?
const apiPort = "8080" // Todo: make configurable?
return &Dashboard{
apiPort: apiPort,
apiScheme: "http",
apiVersion: "v1",
rootDir: webRootDir,
port: webPort,
}
}
// Start the Dashboard and serve over HTTP
func (d *Dashboard) Start() error {
go func() {
d.runHTTPServer()
}()
return nil
}
func (d *Dashboard) templatedHTTPHandler(h http.Handler) http.HandlerFunc {
const apiURLTemplate = "%s://%s:%s/%s/"
indexTemplate, err := template.ParseFiles(d.rootDir + "/index.html")
if err != nil {
log.Error("Error when parsing index template")
log.Error(err)
return nil
}
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
hostName := strings.Split(r.Host, ":")[0]
apiURL := fmt.Sprintf(apiURLTemplate, d.apiScheme, hostName, d.apiPort, d.apiVersion)
err = indexTemplate.Execute(w, struct{ APIURL string }{
APIURL: apiURL,
})
if err != nil {
log.Error("Error when executing index template")
log.Error(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
} else {
h.ServeHTTP(w, r)
}
}
}
func (d *Dashboard) getHandler() http.Handler {
return d.templatedHTTPHandler(http.FileServer(http.Dir(d.rootDir)))
}
func (d *Dashboard) runHTTPServer() {
serveMux := http.NewServeMux()
serveMux.Handle("/", d.getHandler())
log.Debug("Starting http dashboard server")
log.Fatal(http.ListenAndServe(":"+d.port, serveMux))
}

1
web/.env.development Normal file
View file

@ -0,0 +1 @@
VITE_API_URL="http://localhost:8080/v1/"

1
web/.env.production Normal file
View file

@ -0,0 +1 @@
VITE_API_URL={{.APIURL}}

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?

20
web/index.html Normal file
View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<title>Watchtower</title>
</head>
<body data-apipath="%VITE_API_URL%">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2820
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
web/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "watchtower",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"bootstrap": "^5.2.2",
"bootstrap-icons": "^1.10.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^2.2.0",
"typescript": "^4.8.4",
"vite": "^3.2.3"
}
}

BIN
web/public/favicon-16x16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

BIN
web/public/favicon-32x32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
web/public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

47
web/src/App.tsx Normal file
View file

@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import ContainerView from "./components/ContainerView";
import Header from "./components/Header";
import Login from "./components/Login";
import Spinner from "./components/Spinner";
import { checkLogin, logOut } from "./services/Api";
const App = () => {
const [loading, setLoading] = useState(true);
const [loggedIn, setLoggedIn] = useState(false);
useEffect(() => {
setLoading(true);
(async () => {
setLoggedIn(await checkLogin());
setLoading(false);
})();
}, []);
const onLogIn = () => {
setLoading(false);
setLoggedIn(true);
};
const onLogOut = () => {
logOut();
setLoading(false);
setLoggedIn(false);
};
if (loading === true) {
return <div className="mt-5 pt-5"><Spinner /></div>;
}
if (loggedIn !== true) {
return <Login onLogin={onLogIn} />;
}
return (
<div>
<Header onLogOut={onLogOut} />
<ContainerView />
</div>
);
};
export default App;

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,15 @@
import ContainerModel from "../models/ContainerModel";
import ContainerListEntry from "./ContainerListEntry";
interface ContainerListProps {
containers: ContainerModel[];
onContainerClick: (container: ContainerModel) => void;
}
const ContainerList = (props: ContainerListProps) => (
<ul className="list-group">
{props.containers.map((c) => <ContainerListEntry {...c} key={c.ContainerID} onClick={() => props.onContainerClick(c)} />)}
</ul >
);
export default ContainerList;

View file

@ -0,0 +1,31 @@
import ContainerModel from "../models/ContainerModel";
import ImageInfo from "./ImageInfo";
import SpinnerGrow from "./SpinnerGrow";
interface ContainerListEntryProps extends ContainerModel {
onClick: () => void;
}
const ContainerListEntry = (props: ContainerListEntryProps) => (
<li className="list-group-item d-flex justify-content-between align-items-center container-list-entry" onClick={props.onClick} role="button">
<div className="ms-1 me-3 container-list-entry-icon">
{props.Selected
? <i className="bi bi-box-fill text-primary fs-4"></i>
: <i className="bi bi-box text-muted fs-4"></i>
}
</div>
<div className="me-auto">
<div className="fw-bold">{props.ContainerName}</div>
<span className="user-select-all">{props.ImageName}</span> <ImageInfo version={props.ImageVersion} created={props.ImageCreatedDate} />
</div>
<div className="float-end d-flex align-items-center">
{props.HasUpdate === true && <ImageInfo version={props.NewVersion} created={props.NewVersionCreated} />}
{props.IsChecking
? <SpinnerGrow />
: props.HasUpdate === true && <i className="bi bi-arrow-down-circle-fill fs-4 text-primary"></i>
}
</div>
</li>
);
export default ContainerListEntry;

View file

@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import ContainerModel from "../models/ContainerModel";
import { check, list, ListResponse, update } from "../services/Api";
import ContainerList from "./ContainerList";
import Spinner from "./Spinner";
import SpinnerModal from "./SpinnerModal";
import { UpdateSelected, UpdateAll, UpdateCheck } from "./UpdateButtons";
const ContainerView = () => {
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [updating, setUpdating] = useState(false);
const [updatingImage, setUpdatingContainer] = useState<string | null>(null);
const [hasChecked, setHasChecked] = useState(false);
const [containers, setContainers] = useState<ContainerModel[]>([]);
const containersWithUpdates = containers.filter((c) => c.HasUpdate);
const containersWithoutUpdates = containers.filter((c) => !c.HasUpdate);
const selectedContainers = containers.filter((c) => c.Selected);
const hasUpdates = containersWithUpdates.length > 0;
const hasSelectedContainers = selectedContainers.length > 0;
const checkForUpdates = async (containersToUpdate?: ContainerModel[]) => {
if (!containersToUpdate) {
containersToUpdate = containers;
}
setChecking(true);
setContainers((current) =>
current.map((c) => ({
...c,
IsChecking: true
}))
);
await Promise.all(containersToUpdate.map(async (c1) => {
const result = await check(c1.ContainerID);
setContainers((current) =>
current.map((c2) => (c1.ContainerID === c2.ContainerID ? {
...c2,
...result,
IsChecking: false
} : c2
))
);
}));
setChecking(false);
setHasChecked(true);
};
const listContainers = async () => {
setLoading(true);
const data = await list();
const mappedData = data.Containers.map((c) => ({
...c,
Selected: false,
IsChecking: false,
HasUpdate: false,
IsUpdating: false,
NewVersion: "",
NewVersionCreated: ""
}));
setContainers(mappedData);
setLoading(false);
setHasChecked(false);
return mappedData;
};
const updateImages = async (containersToUpdate: ContainerModel[]) => {
setUpdating(true);
const containerNames = containersToUpdate.map((c) => c.ContainerName);
for (const containerName of containerNames) {
setUpdatingContainer(containerName);
await update([containerName]);
}
setUpdatingContainer(null);
const clist = await listContainers();
await checkForUpdates(clist);
setUpdating(false);
};
const updateAll = async () => {
await updateImages(containersWithUpdates);
};
const updateSelected = async () => {
await updateImages(selectedContainers);
};
const onContainerClick = (container: ContainerModel) => {
setContainers((current) =>
current.map((c2) => (container.ContainerID === c2.ContainerID ? {
...c2,
Selected: !c2.Selected
} : c2
))
);
};
useEffect(() => {
listContainers();
}, []);
return (
<main className="mt-5 p-5 d-block">
<SpinnerModal visible={updating} title={`Updating ${updatingImage ?? "containers"}`} message="Please wait..." />
<div className="row mb-2">
<div className="col-12 col-md-4 d-flex align-items-center">
{hasUpdates
? <span>{containersWithUpdates.length} container{containersWithUpdates.length === 1 ? " has" : "s have"} updates.</span>
: checking
? <span>Checking for updates...</span>
: (hasChecked && containers.length > 0)
? <><i className="bi bi-check-circle-fill fs-4 text-primary me-2"></i><span>All containers are up to date.</span></>
: <span>{containers.length} running container{containers.length !== 1 && "s"} found.</span>}
</div>
<div className="col-12 col-md-8 text-end">
{hasUpdates && <UpdateSelected onClick={updateSelected} disabled={checking || !hasSelectedContainers} />}
{hasUpdates && <UpdateAll onClick={updateAll} disabled={checking} />}
<UpdateCheck onClick={() => checkForUpdates()} disabled={checking} />
</div>
</div>
<ContainerList containers={containersWithUpdates} onContainerClick={onContainerClick} />
{hasUpdates && containersWithoutUpdates.length > 0 &&
<div className="row mt-4 mb-2">
<div className="col-4 d-flex align-items-center">
{containersWithoutUpdates.length} container{containersWithoutUpdates.length === 1 ? " is" : "s are"} up to date.
</div>
</div>
}
<ContainerList containers={containersWithoutUpdates} onContainerClick={onContainerClick} />
{loading && <Spinner />}
</main>
);
};
export default ContainerView;

View file

@ -0,0 +1,21 @@
import logo from "../assets/logo.png";
interface HeaderProps {
onLogOut: () => void;
}
const Header = (props: HeaderProps) => (
<nav className="navbar shadow-sm fixed-top navbar-dark bg-secondary px-5">
<img src={logo} className="img-fluid" alt="Watchtower logo" height="30" width="30" />
<a className="navbar-brand mx-0" href="/">
<span>Watchtower</span>
</a>
<div className="d-flex">
<button type="button" className="btn-close btn-close-white" title="Log out" aria-label="Close" onClick={props.onLogOut}></button>
</div>
</nav>
);
export default Header;

View file

@ -0,0 +1,11 @@
interface ImageInfoProps {
version: string;
created: string;
}
const ImageInfo = (props: ImageInfoProps) => (
<small className="text-muted mx-2" title={props.version + " " + props.created}>{props.created.substring(0, 10)}</small>
);
export default ImageInfo;

View file

@ -0,0 +1,69 @@
import { ChangeEvent, FormEvent, useState } from "react";
import { logIn } from "../services/Api";
import logo from "../assets/logo.png";
interface LoginProps {
onLogin: () => void;
}
const Login = (props: LoginProps) => {
const [value, setValue] = useState("");
const [remember, setRemember] = useState(false);
const [error, setError] = useState("");
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.type === "checkbox") {
setRemember(event.target.checked);
} else {
setValue(event.target.value);
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
setError("");
event.preventDefault();
if (value === "") {
return;
}
const loggedIn = await logIn(value, remember);
if (loggedIn) {
props.onLogin();
} else {
setError("Invalid password.");
}
};
return (
<div className="d-flex flex-column min-vh-100 justify-content-center align-items-center">
<form className="form-signin text-center" style={{ width: 350 }} onSubmit={handleSubmit}>
<img className="mb-4" src={logo} alt="Watchtower" width="200" height="200" />
<h1 className="h3 mb-3 fw-normal">Please log in</h1>
<div className="form-floating mb-3">
<input type="password" value={value} onChange={handleChange} className={"form-control" + (error ? " is-invalid" : "")} id="floatingPassword" placeholder="Password" required />
<label htmlFor="floatingPassword" className="user-select-none">Password</label>
{error &&
<div className="invalid-feedback">
{error}
</div>
}
</div>
<button className="w-100 btn btn-lg btn-primary mb-3" type="submit">Log in</button>
<div className="checkbox mb-3">
<label>
<input type="checkbox" value="remember-me" checked={remember} onChange={handleChange} /> Remember me
</label>
</div>
<p className="mt-5 small">
<a href="https://containrrr.dev/watchtower/" className="text-muted small" title="Visit Watchtower" target="_blank">Powered by Watchtower</a>
</p>
</form>
</div>
);
};
export default Login;

View file

@ -0,0 +1,10 @@
const Spinner = () => (
<div className="d-flex justify-content-center">
<div className="spinner-border text-muted" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
export default Spinner;

View file

@ -0,0 +1,8 @@
const SpinnerGrow = () => (
<div className="spinner-grow spinner-grow-sm text-primary" style={{ width: 24, height: 24 }} role="status">
<span className="visually-hidden">Loading...</span>
</div>
);
export default SpinnerGrow;

View file

@ -0,0 +1,45 @@
import { useEffect } from "react";
import logo from "../assets/logo.png";
interface SpinnerModalProps {
visible: boolean;
title: string;
message: string;
}
const SpinnerModal = (props: SpinnerModalProps) => {
useEffect(() => {
document.body.classList.toggle("modal-open", props.visible === true);
}, [props.visible]);
if (props.visible !== true) {
return null;
}
return (
<div>
<div className="modal-backdrop fade show"></div>
<div className="modal fade show d-block" tabIndex={-1}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content text-center">
<div className="modal-body py-5">
<div className="d-flex flex-column align-items-center">
<div className="w-50 text-center mb-4">
<img src={logo} className="img-fluid" alt="Watchtower logo" />
</div>
<h5 className="modal-title">{props.title}</h5>
<p className="mb-4">{props.message}</p>
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default SpinnerModal;

View file

@ -0,0 +1,23 @@
interface UpdateButtonProps {
disabled: boolean;
onClick: () => void;
}
export const UpdateSelected = (props: UpdateButtonProps) => (
<button type="button" className="btn btn-primary me-2" disabled={props.disabled} onClick={props.onClick}>
<i className="bi bi-arrow-down-circle me-2"></i>Update selected
</button>
);
export const UpdateAll = (props: UpdateButtonProps) => (
<button type="button" className="btn btn-primary me-2" disabled={props.disabled} onClick={props.onClick}>
<i className="bi bi-arrow-down-circle me-2"></i>Update all
</button>
);
export const UpdateCheck = (props: UpdateButtonProps) => (
<button type="button" className="btn btn-outline-primary" disabled={props.disabled} onClick={props.onClick}>
<i className="bi bi-arrow-repeat me-2"></i>Check for updates
</button>
);

50
web/src/main.css Normal file
View file

@ -0,0 +1,50 @@
[data-md-color-scheme="containrrr"] {
/* primary and accent */
--md-primary-fg-color: #406170;
--md-primary-fg-color--light: #acbfc7;
--md-primary-fg-color--dark: #003343;
--md-accent-fg-color: #003343;
--md-accent-fg-color--transparent: #00334310;
/* typeset overrides */
--md-typeset-a-color: var(--md-primary-fg-color);
}
:root {
--bs-primary: #406170 !important;
--bs-primary-rgb: 3, 140, 127;
--bs-secondary: #acbfc7 !important;
--bs-secondary-rgb: 64, 97, 112;
--bs-dark: #003343 !important;
}
.btn-primary {
--bs-btn-bg: #038c7f;
--bs-btn-border-color: ##038c7f;
--bs-btn-hover-bg: #02675d;
--bs-btn-hover-border-color: #025a52;
--bs-btn-active-bg: #025a52;
--bs-btn-active-border-color: #025a52;
--bs-btn-disabled-bg: #038c7f;
--bs-btn-disabled-border-color: #038c7f;
}
.btn-outline-primary {
--bs-btn-color: #038c7f;
--bs-btn-disabled-color: #038c7f;
--bs-btn-border-color: #038c7f;
--bs-btn-hover-bg: #02675d;
--bs-btn-hover-border-color: #025a52;
--bs-btn-active-bg: #025a52;
--bs-btn-active-border-color: #025a52;
--bs-btn-disabled-border-color: #038c7f;
}
body {
background-color: rgba(172, 191, 199, 0.5);
}
.container-list-entry:hover .container-list-entry-icon .bi-box::before {
/* .bi-box-fill */
content: "\F7D2";
}

13
web/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import "./main.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -0,0 +1,6 @@
import { CheckResponse, ContainerListEntry } from "../services/Api";
export default interface ContainerModel extends ContainerListEntry, CheckResponse {
Selected: boolean;
IsChecking: boolean;
}

109
web/src/services/Api.ts Normal file
View file

@ -0,0 +1,109 @@
export interface ContainerListEntry {
ContainerID: string;
ContainerName: string;
ImageName: string;
ImageNameShort: string;
ImageVersion: string;
ImageCreatedDate: string;
}
export interface ListResponse {
Containers: ContainerListEntry[];
}
export interface CheckRequest {
ContainerID: string;
}
export interface CheckResponse {
ContainerID: string;
HasUpdate: boolean;
NewVersion: string;
NewVersionCreated: string;
}
const getEmbeddedVariable = (variableName: string) => {
const value = document.body.getAttribute(`data-${variableName}`);
if (value === null) {
throw new Error(`No ${variableName} embedded variable detected`);
}
return value;
};
const apiBasePath = getEmbeddedVariable("apipath");
const tokenStorageKey = "token";
let token = "";
const headers = () => ({
"Authorization": "Bearer " + token
});
export const logIn = async (password: string, remember: boolean): Promise<boolean> => {
token = password;
const response = await fetch(apiBasePath + "list", {
headers: headers()
});
if (response.ok) {
if (remember === true) {
localStorage.setItem(tokenStorageKey, password);
}
return true;
}
token = "";
return false;
};
export const checkLogin = async (): Promise<boolean> => {
const savedToken = localStorage.getItem(tokenStorageKey);
if (savedToken) {
return await logIn(savedToken, false);
}
return false;
};
export const logOut = () => {
token = "";
localStorage.clear();
};
export const list = async (): Promise<ListResponse> => {
const response = await fetch(apiBasePath + "list", {
headers: headers()
});
const data = await response.json();
return data as ListResponse;
};
export const check = async (containerId: string): Promise<CheckResponse> => {
const requestData: CheckRequest = {
ContainerID: containerId
};
const response = await fetch(apiBasePath + "check", {
method: "POST",
headers: {
...headers(),
"Content-Type": "application/json",
},
body: JSON.stringify(requestData)
});
const data = await response.json();
return data as CheckResponse;
};
export const update = async (containers?: string[]): Promise<boolean> => {
let updateUrl = new URL(apiBasePath + "update");
if (containers instanceof Array) {
containers.map((container) => updateUrl.searchParams.append("container", container));
}
const response = await fetch(updateUrl.toString(), {
headers: headers(),
});
return response.ok;
};

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

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

21
web/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

20
web/vite.config.ts Normal file
View file

@ -0,0 +1,20 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
const htmlPlugin = (mode: string) => {
const env = loadEnv(mode, ".");
return {
name: "html-transform",
transformIndexHtml(html: string) {
return html.replace(/%(.*?)%/g, function (match, p1) {
return env[String(p1)];
});
},
};
};
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
plugins: [react(), htmlPlugin(mode)]
}));