diff --git a/pkg/api/api.go b/pkg/api/api.go index 88ec288..ac36e3b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -23,6 +23,7 @@ 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", "*") diff --git a/pkg/api/check/check.go b/pkg/api/check/check.go index b0e83fb..d5e7c9f 100644 --- a/pkg/api/check/check.go +++ b/pkg/api/check/check.go @@ -10,20 +10,18 @@ import ( "github.com/containrrr/watchtower/pkg/types" ) -// Handler is an HTTP handle for serving list data +// Handler is an HTTP handle for serving check data type Handler struct { Path string Client container.Client } -// CheckRequest defines the type for the request data of the Check endpoint -type CheckRequest struct { - ContainerId string +type checkRequest struct { + ContainerID string } -// CheckResponse defines the type for the response data of the Check endpoint -type CheckResponse struct { - ContainerId string +type checkResponse struct { + ContainerID string HasUpdate bool NewVersion string NewVersionCreated string @@ -47,7 +45,7 @@ func (handle *Handler) HandlePost(w http.ResponseWriter, r *http.Request) { log.Info("Check for update triggered by HTTP API request.") - var request CheckRequest + var request checkRequest err := json.NewDecoder(r.Body).Decode(&request) if err != nil { log.Error(err) @@ -57,7 +55,7 @@ func (handle *Handler) HandlePost(w http.ResponseWriter, r *http.Request) { } client := handle.Client - container, err := client.GetContainer(types.ContainerID(request.ContainerId)) + container, err := client.GetContainer(types.ContainerID(request.ContainerID)) if err != nil { log.Error(err) w.WriteHeader(http.StatusInternalServerError) @@ -74,8 +72,8 @@ func (handle *Handler) HandlePost(w http.ResponseWriter, r *http.Request) { return } - data := CheckResponse{ - ContainerId: request.ContainerId, + data := checkResponse{ + ContainerID: request.ContainerID, HasUpdate: stale, NewVersion: newestImage.ShortID(), NewVersionCreated: created, diff --git a/pkg/api/list/list.go b/pkg/api/list/list.go index 06d79e1..53288dd 100644 --- a/pkg/api/list/list.go +++ b/pkg/api/list/list.go @@ -17,9 +17,8 @@ type Handler struct { Client container.Client } -// ContainerListEntry defines the type of each container in the response -type ContainerListEntry struct { - ContainerId string +type containerListEntry struct { + ContainerID string ContainerName string ImageName string ImageNameShort string @@ -27,9 +26,8 @@ type ContainerListEntry struct { ImageCreatedDate string } -// ListResponse defines the return type of the List endpoint -type ListResponse struct { - Containers []ContainerListEntry +type listResponse struct { + Containers []containerListEntry } // New is a factory function creating a new List instance @@ -59,11 +57,11 @@ func (handle *Handler) HandleGet(w http.ResponseWriter, r *http.Request) { w.Write([]byte(err.Error())) } - data := ListResponse{Containers: []ContainerListEntry{}} + data := listResponse{Containers: []containerListEntry{}} for _, c := range containers { - data.Containers = append(data.Containers, ContainerListEntry{ - ContainerId: c.ID().ShortID(), + data.Containers = append(data.Containers, containerListEntry{ + ContainerID: c.ID().ShortID(), ContainerName: c.Name()[1:], ImageName: c.ImageName(), ImageNameShort: strings.Split(c.ImageName(), ":")[0], diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index 9f65296..72e8d82 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -41,8 +41,8 @@ func (d *Dashboard) Start() error { return nil } -func (d *Dashboard) templatedHttpHandler(h http.Handler) http.HandlerFunc { - const apiUrlTemplate = "%s://%s:%s/%s/" +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") @@ -53,9 +53,9 @@ func (d *Dashboard) templatedHttpHandler(h http.Handler) http.HandlerFunc { 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, + 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") @@ -70,7 +70,7 @@ func (d *Dashboard) templatedHttpHandler(h http.Handler) http.HandlerFunc { } func (d *Dashboard) getHandler() http.Handler { - return d.templatedHttpHandler(http.FileServer(http.Dir(d.rootDir))) + return d.templatedHTTPHandler(http.FileServer(http.Dir(d.rootDir))) } func (d *Dashboard) runHTTPServer() { diff --git a/web/.env.production b/web/.env.production index f23b4a2..506f0c9 100644 --- a/web/.env.production +++ b/web/.env.production @@ -1 +1 @@ -VITE_API_URL={{.ApiUrl}} \ No newline at end of file +VITE_API_URL={{.APIURL}} \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index 90360a3..237323a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useEffect, useState } from "react"; import ContainerView from "./components/ContainerView"; import Header from "./components/Header"; import Login from "./components/Login"; @@ -20,7 +20,7 @@ const App = () => { const onLogIn = () => { setLoading(false); setLoggedIn(true); - } + }; const onLogOut = () => { logOut(); @@ -44,4 +44,4 @@ const App = () => { ); }; -export default App +export default App; \ No newline at end of file diff --git a/web/src/components/ContainerList.tsx b/web/src/components/ContainerList.tsx index ff0b73e..7428750 100644 --- a/web/src/components/ContainerList.tsx +++ b/web/src/components/ContainerList.tsx @@ -1,14 +1,14 @@ -import ContainerEntry from "../models/ContainerEntry"; +import ContainerModel from "../models/ContainerModel"; import ContainerListEntry from "./ContainerListEntry"; interface ContainerListProps { - containers: ContainerEntry[]; - onContainerClick: (container: ContainerEntry) => void; + containers: ContainerModel[]; + onContainerClick: (container: ContainerModel) => void; } const ContainerList = (props: ContainerListProps) => ( ); diff --git a/web/src/components/ContainerListEntry.tsx b/web/src/components/ContainerListEntry.tsx index 4725833..631fcf1 100644 --- a/web/src/components/ContainerListEntry.tsx +++ b/web/src/components/ContainerListEntry.tsx @@ -1,8 +1,12 @@ -import ContainerEntry from "../models/ContainerEntry"; +import ContainerModel from "../models/ContainerModel"; import ImageInfo from "./ImageInfo"; import SpinnerGrow from "./SpinnerGrow"; -const ContainerListEntry = (props: (ContainerEntry & { onClick: () => void })) => ( +interface ContainerListEntryProps extends ContainerModel { + onClick: () => void; +} + +const ContainerListEntry = (props: ContainerListEntryProps) => (
  • {props.Selected diff --git a/web/src/components/ContainerView.tsx b/web/src/components/ContainerView.tsx index 2e8a304..7723a46 100644 --- a/web/src/components/ContainerView.tsx +++ b/web/src/components/ContainerView.tsx @@ -1,12 +1,13 @@ import { useEffect, useState } from "react"; -import ContainerEntry from "../models/ContainerEntry" +import ContainerModel from "../models/ContainerModel"; import { check, list, update } from "../services/Api"; import ContainerList from "./ContainerList"; import Spinner from "./Spinner"; import SpinnerModal from "./SpinnerModal"; +import { UpdateSelected, UpdateAll, UpdateCheck } from "./UpdateButtons"; interface ViewModel { - Containers: ContainerEntry[]; + Containers: ContainerModel[]; } const ContainerView = () => { @@ -16,32 +17,28 @@ const ContainerView = () => { const [hasChecked, setHasChecked] = useState(false); const [viewModel, setViewModel] = useState({ Containers: [] }); - useEffect(() => { - listContainers(); - }, []); - const containers = viewModel.Containers; - const containersWithUpdates = containers.filter(c => c.HasUpdate); - const containersWithoutUpdates = containers.filter(c => !c.HasUpdate); - const hasSelectedContainers = containers.some(c => c.Selected); + const containersWithUpdates = containers.filter((c) => c.HasUpdate); + const containersWithoutUpdates = containers.filter((c) => !c.HasUpdate); + const hasSelectedContainers = containers.some((c) => c.Selected); const hasUpdates = containersWithUpdates.length > 0; const checkForUpdates = async () => { setChecking(true); - setViewModel(m => ({ + setViewModel((m) => ({ ...m, - Containers: m.Containers.map(c => ({ + Containers: m.Containers.map((c) => ({ ...c, IsChecking: true })) })); await Promise.all(containers.map(async (c1) => { - const result = await check(c1.ContainerId); - setViewModel(m => ({ + const result = await check(c1.ContainerID); + setViewModel((m) => ({ ...m, - Containers: m.Containers.map((c2, i) => (c1.ContainerId === c2.ContainerId ? { + Containers: m.Containers.map((c2) => (c1.ContainerID === c2.ContainerID ? { ...c2, ...result, IsChecking: false @@ -57,7 +54,17 @@ const ContainerView = () => { const listContainers = async () => { setLoading(true); const data = await list(); - setViewModel(data); + setViewModel({ + Containers: data.Containers.map((c) => ({ + ...c, + Selected: false, + IsChecking: false, + HasUpdate: false, + IsUpdating: false, + NewVersion: "", + NewVersionCreated: "" + })) + }); setLoading(false); setHasChecked(false); }; @@ -75,14 +82,14 @@ const ContainerView = () => { }; const updateSelected = async () => { - const selectedImages = containers.filter(c => c.Selected === true).map(c => c.ImageNameShort); + const selectedImages = containers.filter((c) => c.Selected === true).map((c) => c.ImageNameShort); await updateImages(selectedImages); }; - const onContainerClick = (container: ContainerEntry) => { - setViewModel(m => ({ + const onContainerClick = (container: ContainerModel) => { + setViewModel((m) => ({ ...m, - Containers: m.Containers.map(c2 => (container.ContainerId === c2.ContainerId ? { + Containers: m.Containers.map((c2) => (container.ContainerID === c2.ContainerID ? { ...c2, Selected: !c2.Selected, } : c2 @@ -90,6 +97,10 @@ const ContainerView = () => { })); }; + useEffect(() => { + listContainers(); + }, []); + return (
    @@ -127,27 +138,4 @@ const ContainerView = () => { ); }; -interface UpdateButtonProps { - disabled: boolean; - onClick: () => void; -}; - -const UpdateSelected = (props: UpdateButtonProps) => ( - -); - -const UpdateAll = (props: UpdateButtonProps) => ( - -); - -const UpdateCheck = (props: UpdateButtonProps) => ( - -); - export default ContainerView; \ No newline at end of file diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 1c3baa1..0aa0d8b 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,4 +1,4 @@ -import logo from "../assets/logo.png" +import logo from "../assets/logo.png"; interface HeaderProps { onLogOut: () => void; diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx index ef6ceba..72e2823 100644 --- a/web/src/components/Login.tsx +++ b/web/src/components/Login.tsx @@ -1,15 +1,15 @@ import { ChangeEvent, FormEvent, useState } from "react"; import { logIn } from "../services/Api"; -import logo from "../assets/logo.png" +import logo from "../assets/logo.png"; interface LoginProps { onLogin: () => void; } const Login = (props: LoginProps) => { - const [password, setPassword] = useState(undefined); + const [password, setPassword] = useState(""); const [remember, setRemember] = useState(false); - const [error, setError] = useState(undefined); + const [error, setError] = useState(""); const handleChange = (event: ChangeEvent) => { if (event.target.type === "checkbox") { @@ -20,10 +20,10 @@ const Login = (props: LoginProps) => { }; const handleSubmit = async (event: FormEvent) => { - setError(undefined); + setError(""); event.preventDefault(); - if (password === undefined) { + if (password === "") { return; } diff --git a/web/src/components/SpinnerModal.tsx b/web/src/components/SpinnerModal.tsx index 6719d78..d89dd9e 100644 --- a/web/src/components/SpinnerModal.tsx +++ b/web/src/components/SpinnerModal.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import logo from "../assets/logo.png" +import logo from "../assets/logo.png"; interface SpinnerModalProps { visible: boolean; @@ -10,9 +10,11 @@ interface SpinnerModalProps { const SpinnerModal = (props: SpinnerModalProps) => { useEffect(() => { document.body.classList.toggle("modal-open", props.visible === true); - }, [props.visible]) + }, [props.visible]); - if (props.visible !== true) return null; + if (props.visible !== true) { + return null; + } return (
    @@ -37,7 +39,7 @@ const SpinnerModal = (props: SpinnerModalProps) => {
    - ) + ); }; export default SpinnerModal; \ No newline at end of file diff --git a/web/src/components/UpdateButtons.tsx b/web/src/components/UpdateButtons.tsx new file mode 100644 index 0000000..47ca6f0 --- /dev/null +++ b/web/src/components/UpdateButtons.tsx @@ -0,0 +1,23 @@ + +interface UpdateButtonProps { + disabled: boolean; + onClick: () => void; +} + +export const UpdateSelected = (props: UpdateButtonProps) => ( + +); + +export const UpdateAll = (props: UpdateButtonProps) => ( + +); + +export const UpdateCheck = (props: UpdateButtonProps) => ( + +); \ No newline at end of file diff --git a/web/src/main.css b/web/src/main.css index a7c2bc7..94066ae 100644 --- a/web/src/main.css +++ b/web/src/main.css @@ -1,45 +1,43 @@ [data-md-color-scheme="containrrr"] { - /* Primary and accent */ + /* 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 */ + /* 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-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; + --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-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; + --bs-btn-disabled-border-color: #038c7f; } body { @@ -48,5 +46,5 @@ body { .container-list-entry:hover .container-list-entry-icon .bi-box::before { /* .bi-box-fill */ - content: "\F7D2" -} \ No newline at end of file + content: "\F7D2"; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index a9fef4d..0cc528d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,13 +1,13 @@ -import React from "react" -import ReactDOM from "react-dom/client" -import App from "./App" +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( - + - -) \ No newline at end of file + +); \ No newline at end of file diff --git a/web/src/models/ContainerEntry.ts b/web/src/models/ContainerEntry.ts deleted file mode 100644 index 3d94a8b..0000000 --- a/web/src/models/ContainerEntry.ts +++ /dev/null @@ -1,14 +0,0 @@ - -export default interface ContainerEntry { - ContainerId: string; - ContainerName: string; - ImageName: string; - ImageNameShort: string; - ImageVersion: string; - ImageCreatedDate: string; - NewVersion: string; - NewVersionCreated: string; - HasUpdate: boolean; - Selected: boolean; - IsChecking: boolean; -} \ No newline at end of file diff --git a/web/src/models/ContainerModel.ts b/web/src/models/ContainerModel.ts new file mode 100644 index 0000000..9bc36f1 --- /dev/null +++ b/web/src/models/ContainerModel.ts @@ -0,0 +1,6 @@ +import { CheckResponse, ContainerListEntry } from "../services/Api"; + +export default interface ContainerModel extends ContainerListEntry, CheckResponse { + Selected: boolean; + IsChecking: boolean; +} \ No newline at end of file diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 008fc6a..fd601d9 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -16,7 +16,7 @@ const headers = () => ({ "Authorization": "Bearer " + token }); -export const logIn = async (password: string, remember: boolean) => { +export const logIn = async (password: string, remember: boolean): Promise => { token = password; const response = await fetch(apiBasePath + "list", { headers: headers() @@ -33,7 +33,7 @@ export const logIn = async (password: string, remember: boolean) => { return false; }; -export const checkLogin = async () => { +export const checkLogin = async (): Promise => { const savedToken = localStorage.getItem(tokenStorageKey); if (savedToken) { return await logIn(savedToken, false); @@ -47,17 +47,17 @@ export const logOut = () => { localStorage.clear(); }; -export const list = async () => { +export const list = async (): Promise => { const response = await fetch(apiBasePath + "list", { headers: headers() }); const data = await response.json(); - return data; + return data as ListResponse; }; -export const check = async (containerId: string) => { - const requestData = { - ContainerId: containerId +export const check = async (containerId: string): Promise => { + const requestData: CheckRequest = { + ContainerID: containerId }; const response = await fetch(apiBasePath + "check", { method: "POST", @@ -68,14 +68,14 @@ export const check = async (containerId: string) => { body: JSON.stringify(requestData) }); const data = await response.json(); - return data; + return data as CheckResponse; }; -export const update = async (images?: string[]) => { +export const update = async (images?: string[]): Promise => { let updateUrl = new URL(apiBasePath + "/update"); if (images instanceof Array) { - images.map(image => updateUrl.searchParams.append("image", image)); + images.map((image) => updateUrl.searchParams.append("image", image)); } const response = await fetch(updateUrl.toString(), { @@ -83,4 +83,28 @@ export const update = async (images?: string[]) => { }); return response.ok; -}; \ No newline at end of file +}; + +export interface ListResponse { + Containers: ContainerListEntry[]; +} + +export interface ContainerListEntry { + ContainerID: string; + ContainerName: string; + ImageName: string; + ImageNameShort: string; + ImageVersion: string; + ImageCreatedDate: string; +} + +export interface CheckRequest { + ContainerID: string; +} + +export interface CheckResponse { + ContainerID: string; + HasUpdate: boolean; + NewVersion: string; + NewVersionCreated: string; +} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index bd44117..3fca177 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,5 +1,5 @@ -import { defineConfig, loadEnv } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from "vite" +import react from "@vitejs/plugin-react" const htmlPlugin = (mode: string) => { const env = loadEnv(mode, ".");