mirror of
https://github.com/containrrr/watchtower.git
synced 2026-02-26 00:44:08 +01:00
Merge 4b494ded86 into c16ac967c5
This commit is contained in:
commit
beba45c17b
40 changed files with 3951 additions and 33 deletions
33
cmd/root.go
33
cmd/root.go
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
105
pkg/api/check/check.go
Normal 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
81
pkg/api/list/list.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
82
pkg/dashboard/dashboard.go
Normal file
82
pkg/dashboard/dashboard.go
Normal 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
1
web/.env.development
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL="http://localhost:8080/v1/"
|
||||
1
web/.env.production
Normal file
1
web/.env.production
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL={{.APIURL}}
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal 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
20
web/index.html
Normal 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
2820
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
web/package.json
Normal file
24
web/package.json
Normal 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
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
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
BIN
web/public/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
47
web/src/App.tsx
Normal file
47
web/src/App.tsx
Normal 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
BIN
web/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
15
web/src/components/ContainerList.tsx
Normal file
15
web/src/components/ContainerList.tsx
Normal 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;
|
||||
31
web/src/components/ContainerListEntry.tsx
Normal file
31
web/src/components/ContainerListEntry.tsx
Normal 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;
|
||||
144
web/src/components/ContainerView.tsx
Normal file
144
web/src/components/ContainerView.tsx
Normal 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;
|
||||
21
web/src/components/Header.tsx
Normal file
21
web/src/components/Header.tsx
Normal 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;
|
||||
11
web/src/components/ImageInfo.tsx
Normal file
11
web/src/components/ImageInfo.tsx
Normal 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;
|
||||
69
web/src/components/Login.tsx
Normal file
69
web/src/components/Login.tsx
Normal 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;
|
||||
10
web/src/components/Spinner.tsx
Normal file
10
web/src/components/Spinner.tsx
Normal 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;
|
||||
8
web/src/components/SpinnerGrow.tsx
Normal file
8
web/src/components/SpinnerGrow.tsx
Normal 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;
|
||||
45
web/src/components/SpinnerModal.tsx
Normal file
45
web/src/components/SpinnerModal.tsx
Normal 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;
|
||||
23
web/src/components/UpdateButtons.tsx
Normal file
23
web/src/components/UpdateButtons.tsx
Normal 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
50
web/src/main.css
Normal 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
13
web/src/main.tsx
Normal 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>
|
||||
);
|
||||
6
web/src/models/ContainerModel.ts
Normal file
6
web/src/models/ContainerModel.ts
Normal 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
109
web/src/services/Api.ts
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
21
web/tsconfig.json
Normal file
21
web/tsconfig.json
Normal 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
9
web/tsconfig.node.json
Normal 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
20
web/vite.config.ts
Normal 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)]
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue