feat: Implement Edit Container component
The new component displays container information in a textarea. The information is presented in YAML-formatted syntax for easier editing and understanding.
- commit
- 90623aced9bbc97aa0c12712854d8d81a53a9080
- author
- bnema · · 2023-09-27 11:31 UTC
- parent
- 0fbbe4d
- signature
- unsigned
8 files · +272 -15
M internal/gotemplate/models/html/fragments/containerlist.gohtml +17 -0
| @@ -51,6 +51,18 @@ | |||
| 51 | 51 | > | |
| 52 | 52 | <span class="iconoir-system-shut text-xl font-bold"></span> | |
| 53 | 53 | </button> | |
| 54 | + | <!-- EDIT --> | |
| 55 | + | <button | |
| 56 | + | id="edit-button-{{.ID}}" | |
| 57 | + | hx-get="/htmx/container-manager/edit/{{.ID}}" | |
| 58 | + | hx-target="#edit-container-{{.ID}}" | |
| 59 | + | hx-target-*="#error-message-{{.ID}}" | |
| 60 | + | hx-swap="innerHTML" | |
| 61 | + | class="py-2 px-4" | |
| 62 | + | data-active="false" | |
| 63 | + | > | |
| 64 | + | <span class="iconoir-edit text-xl text-blue-600 font-bold"></span> | |
| 65 | + | </button> | |
| 54 | 66 | <!-- REMOVE --> | |
| 55 | 67 | <button | |
| 56 | 68 | id="remove-button-img-{{.ID}}" | |
| @@ -66,6 +78,11 @@ | |||
| 66 | 78 | </button> | |
| 67 | 79 | </td> | |
| 68 | 80 | </tr> | |
| 81 | + | <tr> | |
| 82 | + | <td colspan="7"> | |
| 83 | + | <div id="edit-container-{{.ID}}"></div> | |
| 84 | + | </td> | |
| 85 | + | </tr> | |
| 69 | 86 | <tr> | |
| 70 | 87 | <td colspan="7"> | |
| 71 | 88 | <div id="error-message-{{.ID}}" class="text-red-500"></div> | |
| @@ -51,6 +51,18 @@ | |||||
| 51 | > | 51 | > | ||
| 52 | <span class="iconoir-system-shut text-xl font-bold"></span> | 52 | <span class="iconoir-system-shut text-xl font-bold"></span> | ||
| 53 | </button> | 53 | </button> | ||
| 54 | + | <!-- EDIT --> | |||
| 55 | + | <button | |||
| 56 | + | id="edit-button-{{.ID}}" | |||
| 57 | + | hx-get="/htmx/container-manager/edit/{{.ID}}" | |||
| 58 | + | hx-target="#edit-container-{{.ID}}" | |||
| 59 | + | hx-target-*="#error-message-{{.ID}}" | |||
| 60 | + | hx-swap="innerHTML" | |||
| 61 | + | class="py-2 px-4" | |||
| 62 | + | data-active="false" | |||
| 63 | + | > | |||
| 64 | + | <span class="iconoir-edit text-xl text-blue-600 font-bold"></span> | |||
| 65 | + | </button> | |||
| 54 | <!-- REMOVE --> | 66 | <!-- REMOVE --> | ||
| 55 | <button | 67 | <button | ||
| 56 | id="remove-button-img-{{.ID}}" | 68 | id="remove-button-img-{{.ID}}" | ||
| @@ -66,6 +78,11 @@ | |||||
| 66 | </button> | 78 | </button> | ||
| 67 | </td> | 79 | </td> | ||
| 68 | </tr> | 80 | </tr> | ||
| 81 | + | <tr> | |||
| 82 | + | <td colspan="7"> | |||
| 83 | + | <div id="edit-container-{{.ID}}"></div> | |||
| 84 | + | </td> | |||
| 85 | + | </tr> | |||
| 69 | <tr> | 86 | <tr> | ||
| 70 | <td colspan="7"> | 87 | <td colspan="7"> | ||
| 71 | <div id="error-message-{{.ID}}" class="text-red-500"></div> | 88 | <div id="error-message-{{.ID}}" class="text-red-500"></div> | ||
A internal/gotemplate/models/html/fragments/editcontainer.gohtml +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | <div id="editor"> | |
| 2 | + | <form method="post" action="/htmx/container-manager/edit/{{.ID}}"> | |
| 3 | + | <textarea | |
| 4 | + | name="container_config" | |
| 5 | + | id="container_config" | |
| 6 | + | class="w-full p-4 rounded-md bg-slate-100 border-gray-300 border-2" | |
| 7 | + | oninput="this.style.height = '';this.style.height = this.scrollHeight + 'px'" | |
| 8 | + | >{{.ContainerInfo}}</textarea | |
| 9 | + | > | |
| 10 | + | ||
| 11 | + | <input | |
| 12 | + | type="submit" | |
| 13 | + | value="Save" | |
| 14 | + | class="bg-gordonmascot-mblue text-white px-4 py-2 rounded-md md:text-xl hover:bg-gordonmascot-hblue" | |
| 15 | + | /> | |
| 16 | + | </form> | |
| 17 | + | </div> | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | <div id="editor"> | |||
| 2 | + | <form method="post" action="/htmx/container-manager/edit/{{.ID}}"> | |||
| 3 | + | <textarea | |||
| 4 | + | name="container_config" | |||
| 5 | + | id="container_config" | |||
| 6 | + | class="w-full p-4 rounded-md bg-slate-100 border-gray-300 border-2" | |||
| 7 | + | oninput="this.style.height = '';this.style.height = this.scrollHeight + 'px'" | |||
| 8 | + | >{{.ContainerInfo}}</textarea | |||
| 9 | + | > | |||
| 10 | + | ||||
| 11 | + | <input | |||
| 12 | + | type="submit" | |||
| 13 | + | value="Save" | |||
| 14 | + | class="bg-gordonmascot-mblue text-white px-4 py-2 rounded-md md:text-xl hover:bg-gordonmascot-hblue" | |||
| 15 | + | /> | |||
| 16 | + | </form> | |||
| 17 | + | </div> | |||
M internal/httpserve/handler/createcontainer.go +1 -15
| @@ -10,18 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/labstack/echo/v4" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | type CreateContainer struct { | |
| 14 | - | ContainerName string | |
| 15 | - | ContainerHost string | |
| 16 | - | Environment string | |
| 17 | - | ImageName string | |
| 18 | - | ImageID string | |
| 19 | - | Ports string | |
| 20 | - | Volumes string | |
| 21 | - | TraefikLabels []string | |
| 22 | - | Network string | |
| 23 | - | } | |
| 24 | - | ||
| 25 | 13 | // FromShortIDToImageID converts a short image ID to a full image ID | |
| 26 | 14 | func FromShortIDToImageID(ShortID string) (string, error) { | |
| 27 | 15 | idMapMutex.Lock() | |
| @@ -118,9 +106,7 @@ func CreateContainerPOST(c echo.Context, a *app.App) error { | |||
| 118 | 106 | } | |
| 119 | 107 | } | |
| 120 | 108 | ||
| 121 | - | // Result : Form values: map[container_domain:gogs.localhost container_name:gogs image_id:sha256:5d92d8ae733d93dc4af5b32bb353539eeff660fc2136c6902abcdf3aafe4cef1 image_name:gogs/gogs:latest ports:8888:80 restart:unless-stopped volumes:/home/data/test:/data] | |
| 122 | - | // With those values we can use gotemplate/txt to create a custom docker run command | |
| 123 | - | cmdParams, err := render.FromInputsToCmdParams(sanitizedInputs) // Make sure cmdParams is of type render.ContainerCommandParams | |
| 109 | + | cmdParams, err := render.FromInputsToCmdParams(sanitizedInputs) | |
| 124 | 110 | if err != nil { | |
| 125 | 111 | return sendError(c, err) | |
| 126 | 112 | } | |
| @@ -10,18 +10,6 @@ import ( | |||||
| 10 | "github.com/labstack/echo/v4" | 10 | "github.com/labstack/echo/v4" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | type CreateContainer struct { | |||
| 14 | - | ContainerName string | |||
| 15 | - | ContainerHost string | |||
| 16 | - | Environment string | |||
| 17 | - | ImageName string | |||
| 18 | - | ImageID string | |||
| 19 | - | Ports string | |||
| 20 | - | Volumes string | |||
| 21 | - | TraefikLabels []string | |||
| 22 | - | Network string | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | // FromShortIDToImageID converts a short image ID to a full image ID | 13 | // FromShortIDToImageID converts a short image ID to a full image ID | ||
| 26 | func FromShortIDToImageID(ShortID string) (string, error) { | 14 | func FromShortIDToImageID(ShortID string) (string, error) { | ||
| 27 | idMapMutex.Lock() | 15 | idMapMutex.Lock() | ||
| @@ -118,9 +106,7 @@ func CreateContainerPOST(c echo.Context, a *app.App) error { | |||||
| 118 | } | 106 | } | ||
| 119 | } | 107 | } | ||
| 120 | 108 | ||||
| 121 | - | // Result : Form values: map[container_domain:gogs.localhost container_name:gogs image_id:sha256:5d92d8ae733d93dc4af5b32bb353539eeff660fc2136c6902abcdf3aafe4cef1 image_name:gogs/gogs:latest ports:8888:80 restart:unless-stopped volumes:/home/data/test:/data] | 109 | + | cmdParams, err := render.FromInputsToCmdParams(sanitizedInputs) |
| 122 | - | // With those values we can use gotemplate/txt to create a custom docker run command | |||
| 123 | - | cmdParams, err := render.FromInputsToCmdParams(sanitizedInputs) // Make sure cmdParams is of type render.ContainerCommandParams | |||
| 124 | if err != nil { | 110 | if err != nil { | ||
| 125 | return sendError(c, err) | 111 | return sendError(c, err) | ||
| 126 | } | 112 | } | ||
A internal/httpserve/handler/editcontainer.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | package handler | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/app" | |
| 8 | + | "github.com/bnema/gordon/internal/gotemplate/render" | |
| 9 | + | "github.com/bnema/gordon/pkg/utils/docker" | |
| 10 | + | "github.com/labstack/echo/v4" | |
| 11 | + | "gopkg.in/yaml.v3" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func generateOrderedYAML(infoMap map[string]interface{}) (string, error) { | |
| 15 | + | orderedKeys := []string{"Name", "Image", "Hostname", "Ports", "Environment", "Labels", "Network", "Volumes", "Restart"} | |
| 16 | + | ||
| 17 | + | var yamlString string | |
| 18 | + | for _, key := range orderedKeys { | |
| 19 | + | value, exists := infoMap[key] | |
| 20 | + | if !exists { | |
| 21 | + | continue | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | yamlBytes, err := yaml.Marshal(map[string]interface{}{key: value}) | |
| 25 | + | if err != nil { | |
| 26 | + | return "", err | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | yamlString += string(yamlBytes) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | return yamlString, nil | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ContainerManagerEditGET displays the edit container view | |
| 36 | + | func ContainerManagerEditGET(c echo.Context, a *app.App) error { | |
| 37 | + | containerID := c.Param("ID") | |
| 38 | + | ||
| 39 | + | fmt.Println(containerID) | |
| 40 | + | ||
| 41 | + | // Get the container info | |
| 42 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 43 | + | if err != nil { | |
| 44 | + | return sendError(c, err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Trim the slash from the container name | |
| 48 | + | containerInfo.Name = strings.TrimPrefix(containerInfo.Name, "/") | |
| 49 | + | // Get just the network names | |
| 50 | + | networkNames := make([]string, 0, len(containerInfo.NetworkSettings.Networks)) | |
| 51 | + | for networkName := range containerInfo.NetworkSettings.Networks { | |
| 52 | + | networkNames = append(networkNames, networkName) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Prepare Ports | |
| 56 | + | portMappings := make([]string, 0) | |
| 57 | + | for port, binding := range containerInfo.NetworkSettings.Ports { | |
| 58 | + | for _, b := range binding { | |
| 59 | + | portMappings = append(portMappings, fmt.Sprintf("%s:%s->%s", b.HostIP, b.HostPort, port)) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Prepare Volumes (Mounts) | |
| 64 | + | volumeMappings := make([]string, 0) | |
| 65 | + | for _, mount := range containerInfo.Mounts { | |
| 66 | + | volumeMappings = append(volumeMappings, fmt.Sprintf("%s:%s", mount.Source, mount.Destination)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Initialize a map to store the information you want to display | |
| 70 | + | infoMap := make(map[string]interface{}) | |
| 71 | + | ||
| 72 | + | // Populate the map with container details | |
| 73 | + | infoMap["Name"] = containerInfo.Name | |
| 74 | + | infoMap["Image"] = containerInfo.Config.Image | |
| 75 | + | infoMap["Hostname"] = containerInfo.Config.Hostname | |
| 76 | + | infoMap["Ports"] = portMappings | |
| 77 | + | infoMap["Volumes"] = volumeMappings | |
| 78 | + | infoMap["Environment"] = containerInfo.Config.Env | |
| 79 | + | infoMap["Labels"] = containerInfo.Config.Labels | |
| 80 | + | infoMap["Network"] = networkNames | |
| 81 | + | infoMap["Restart"] = containerInfo.HostConfig.RestartPolicy.Name | |
| 82 | + | ||
| 83 | + | // Convert the infoMap to YAML format | |
| 84 | + | yamlString, err := generateOrderedYAML(infoMap) | |
| 85 | + | if err != nil { | |
| 86 | + | return sendError(c, err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Pass the containerInfo to the data map | |
| 90 | + | data := map[string]interface{}{ | |
| 91 | + | "Title": "Edit container", | |
| 92 | + | "ContainerInfo": yamlString, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | rendererData, err := render.GetHTMLRenderer("html/fragments", "editcontainer.gohtml", a.TemplateFS, a) | |
| 96 | + | ||
| 97 | + | if err != nil { | |
| 98 | + | return sendError(c, err) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | renderedHTML, err := rendererData.Render(data, a) | |
| 102 | + | ||
| 103 | + | if err != nil { | |
| 104 | + | return sendError(c, err) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | return c.HTML(200, renderedHTML) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // ContainerManagerEditPOST is the route action for editing a container | |
| 111 | + | func ContainerManagerEditPOST(c echo.Context, a *app.App) error { | |
| 112 | + | containerID := c.Param("ID") | |
| 113 | + | fmt.Println(containerID) | |
| 114 | + | ||
| 115 | + | // Get the new config | |
| 116 | + | newConfig := c.FormValue("container_config") | |
| 117 | + | ||
| 118 | + | fmt.Println(newConfig) | |
| 119 | + | ||
| 120 | + | return nil | |
| 121 | + | ||
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package handler | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/app" | |||
| 8 | + | "github.com/bnema/gordon/internal/gotemplate/render" | |||
| 9 | + | "github.com/bnema/gordon/pkg/utils/docker" | |||
| 10 | + | "github.com/labstack/echo/v4" | |||
| 11 | + | "gopkg.in/yaml.v3" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func generateOrderedYAML(infoMap map[string]interface{}) (string, error) { | |||
| 15 | + | orderedKeys := []string{"Name", "Image", "Hostname", "Ports", "Environment", "Labels", "Network", "Volumes", "Restart"} | |||
| 16 | + | ||||
| 17 | + | var yamlString string | |||
| 18 | + | for _, key := range orderedKeys { | |||
| 19 | + | value, exists := infoMap[key] | |||
| 20 | + | if !exists { | |||
| 21 | + | continue | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | yamlBytes, err := yaml.Marshal(map[string]interface{}{key: value}) | |||
| 25 | + | if err != nil { | |||
| 26 | + | return "", err | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | yamlString += string(yamlBytes) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | return yamlString, nil | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ContainerManagerEditGET displays the edit container view | |||
| 36 | + | func ContainerManagerEditGET(c echo.Context, a *app.App) error { | |||
| 37 | + | containerID := c.Param("ID") | |||
| 38 | + | ||||
| 39 | + | fmt.Println(containerID) | |||
| 40 | + | ||||
| 41 | + | // Get the container info | |||
| 42 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return sendError(c, err) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Trim the slash from the container name | |||
| 48 | + | containerInfo.Name = strings.TrimPrefix(containerInfo.Name, "/") | |||
| 49 | + | // Get just the network names | |||
| 50 | + | networkNames := make([]string, 0, len(containerInfo.NetworkSettings.Networks)) | |||
| 51 | + | for networkName := range containerInfo.NetworkSettings.Networks { | |||
| 52 | + | networkNames = append(networkNames, networkName) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Prepare Ports | |||
| 56 | + | portMappings := make([]string, 0) | |||
| 57 | + | for port, binding := range containerInfo.NetworkSettings.Ports { | |||
| 58 | + | for _, b := range binding { | |||
| 59 | + | portMappings = append(portMappings, fmt.Sprintf("%s:%s->%s", b.HostIP, b.HostPort, port)) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Prepare Volumes (Mounts) | |||
| 64 | + | volumeMappings := make([]string, 0) | |||
| 65 | + | for _, mount := range containerInfo.Mounts { | |||
| 66 | + | volumeMappings = append(volumeMappings, fmt.Sprintf("%s:%s", mount.Source, mount.Destination)) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Initialize a map to store the information you want to display | |||
| 70 | + | infoMap := make(map[string]interface{}) | |||
| 71 | + | ||||
| 72 | + | // Populate the map with container details | |||
| 73 | + | infoMap["Name"] = containerInfo.Name | |||
| 74 | + | infoMap["Image"] = containerInfo.Config.Image | |||
| 75 | + | infoMap["Hostname"] = containerInfo.Config.Hostname | |||
| 76 | + | infoMap["Ports"] = portMappings | |||
| 77 | + | infoMap["Volumes"] = volumeMappings | |||
| 78 | + | infoMap["Environment"] = containerInfo.Config.Env | |||
| 79 | + | infoMap["Labels"] = containerInfo.Config.Labels | |||
| 80 | + | infoMap["Network"] = networkNames | |||
| 81 | + | infoMap["Restart"] = containerInfo.HostConfig.RestartPolicy.Name | |||
| 82 | + | ||||
| 83 | + | // Convert the infoMap to YAML format | |||
| 84 | + | yamlString, err := generateOrderedYAML(infoMap) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return sendError(c, err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Pass the containerInfo to the data map | |||
| 90 | + | data := map[string]interface{}{ | |||
| 91 | + | "Title": "Edit container", | |||
| 92 | + | "ContainerInfo": yamlString, | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | rendererData, err := render.GetHTMLRenderer("html/fragments", "editcontainer.gohtml", a.TemplateFS, a) | |||
| 96 | + | ||||
| 97 | + | if err != nil { | |||
| 98 | + | return sendError(c, err) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | renderedHTML, err := rendererData.Render(data, a) | |||
| 102 | + | ||||
| 103 | + | if err != nil { | |||
| 104 | + | return sendError(c, err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | return c.HTML(200, renderedHTML) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // ContainerManagerEditPOST is the route action for editing a container | |||
| 111 | + | func ContainerManagerEditPOST(c echo.Context, a *app.App) error { | |||
| 112 | + | containerID := c.Param("ID") | |||
| 113 | + | fmt.Println(containerID) | |||
| 114 | + | ||||
| 115 | + | // Get the new config | |||
| 116 | + | newConfig := c.FormValue("container_config") | |||
| 117 | + | ||||
| 118 | + | fmt.Println(newConfig) | |||
| 119 | + | ||||
| 120 | + | return nil | |||
| 121 | + | ||||
| 122 | + | } | |||
M internal/httpserve/router.go +8 -0
| @@ -92,6 +92,14 @@ func bindHTMXEndpoints(e *echo.Echo, a *app.App) { | |||
| 92 | 92 | e.POST("/htmx/container-manager/start/:ID", func(c echo.Context) error { | |
| 93 | 93 | return handler.ContainerManagerStart(c, a) | |
| 94 | 94 | }) | |
| 95 | + | // Edit a container view | |
| 96 | + | e.GET("/htmx/container-manager/edit/:ID", func(c echo.Context) error { | |
| 97 | + | return handler.ContainerManagerEditGET(c, a) | |
| 98 | + | }) | |
| 99 | + | // Edit a container action | |
| 100 | + | e.POST("/htmx/container-manager/edit/:ID", func(c echo.Context) error { | |
| 101 | + | return handler.ContainerManagerEditPOST(c, a) | |
| 102 | + | }) | |
| 95 | 103 | ||
| 96 | 104 | // Display upload-image component | |
| 97 | 105 | e.GET("/htmx/upload-image", func(c echo.Context) error { | |
| @@ -92,6 +92,14 @@ func bindHTMXEndpoints(e *echo.Echo, a *app.App) { | |||||
| 92 | e.POST("/htmx/container-manager/start/:ID", func(c echo.Context) error { | 92 | e.POST("/htmx/container-manager/start/:ID", func(c echo.Context) error { | ||
| 93 | return handler.ContainerManagerStart(c, a) | 93 | return handler.ContainerManagerStart(c, a) | ||
| 94 | }) | 94 | }) | ||
| 95 | + | // Edit a container view | |||
| 96 | + | e.GET("/htmx/container-manager/edit/:ID", func(c echo.Context) error { | |||
| 97 | + | return handler.ContainerManagerEditGET(c, a) | |||
| 98 | + | }) | |||
| 99 | + | // Edit a container action | |||
| 100 | + | e.POST("/htmx/container-manager/edit/:ID", func(c echo.Context) error { | |||
| 101 | + | return handler.ContainerManagerEditPOST(c, a) | |||
| 102 | + | }) | |||
| 95 | 103 | ||||
| 96 | // Display upload-image component | 104 | // Display upload-image component | ||
| 97 | e.GET("/htmx/upload-image", func(c echo.Context) error { | 105 | e.GET("/htmx/upload-image", func(c echo.Context) error { | ||
M internal/webui/public/assets/css/tailwind.css +25 -0
| @@ -822,6 +822,11 @@ video { | |||
| 822 | 822 | background-color: rgb(228 228 231 / var(--tw-bg-opacity)); | |
| 823 | 823 | } | |
| 824 | 824 | ||
| 825 | + | .bg-gordonmascot-mblue { | |
| 826 | + | --tw-bg-opacity: 1; | |
| 827 | + | background-color: rgb(0 82 147 / var(--tw-bg-opacity)); | |
| 828 | + | } | |
| 829 | + | ||
| 825 | 830 | .p-0 { | |
| 826 | 831 | padding: 0px; | |
| 827 | 832 | } | |
| @@ -854,6 +859,11 @@ video { | |||
| 854 | 859 | padding-bottom: 0.5rem; | |
| 855 | 860 | } | |
| 856 | 861 | ||
| 862 | + | .py-4 { | |
| 863 | + | padding-top: 1rem; | |
| 864 | + | padding-bottom: 1rem; | |
| 865 | + | } | |
| 866 | + | ||
| 857 | 867 | .pb-4 { | |
| 858 | 868 | padding-bottom: 1rem; | |
| 859 | 869 | } | |
| @@ -959,6 +969,16 @@ video { | |||
| 959 | 969 | color: rgb(255 255 255 / var(--tw-text-opacity)); | |
| 960 | 970 | } | |
| 961 | 971 | ||
| 972 | + | .text-blue-600 { | |
| 973 | + | --tw-text-opacity: 1; | |
| 974 | + | color: rgb(37 99 235 / var(--tw-text-opacity)); | |
| 975 | + | } | |
| 976 | + | ||
| 977 | + | .text-black { | |
| 978 | + | --tw-text-opacity: 1; | |
| 979 | + | color: rgb(0 0 0 / var(--tw-text-opacity)); | |
| 980 | + | } | |
| 981 | + | ||
| 962 | 982 | .underline { | |
| 963 | 983 | text-decoration-line: underline; | |
| 964 | 984 | } | |
| @@ -1099,6 +1119,11 @@ video { | |||
| 1099 | 1119 | background-color: rgb(29 78 216 / var(--tw-bg-opacity)); | |
| 1100 | 1120 | } | |
| 1101 | 1121 | ||
| 1122 | + | .hover\:bg-gordonmascot-hblue:hover { | |
| 1123 | + | --tw-bg-opacity: 1; | |
| 1124 | + | background-color: rgb(1 30 92 / var(--tw-bg-opacity)); | |
| 1125 | + | } | |
| 1126 | + | ||
| 1102 | 1127 | @media (min-width: 640px) { | |
| 1103 | 1128 | .sm\:h-24 { | |
| 1104 | 1129 | height: 6rem; | |
| @@ -822,6 +822,11 @@ video { | |||||
| 822 | background-color: rgb(228 228 231 / var(--tw-bg-opacity)); | 822 | background-color: rgb(228 228 231 / var(--tw-bg-opacity)); | ||
| 823 | } | 823 | } | ||
| 824 | 824 | ||||
| 825 | + | .bg-gordonmascot-mblue { | |||
| 826 | + | --tw-bg-opacity: 1; | |||
| 827 | + | background-color: rgb(0 82 147 / var(--tw-bg-opacity)); | |||
| 828 | + | } | |||
| 829 | + | ||||
| 825 | .p-0 { | 830 | .p-0 { | ||
| 826 | padding: 0px; | 831 | padding: 0px; | ||
| 827 | } | 832 | } | ||
| @@ -854,6 +859,11 @@ video { | |||||
| 854 | padding-bottom: 0.5rem; | 859 | padding-bottom: 0.5rem; | ||
| 855 | } | 860 | } | ||
| 856 | 861 | ||||
| 862 | + | .py-4 { | |||
| 863 | + | padding-top: 1rem; | |||
| 864 | + | padding-bottom: 1rem; | |||
| 865 | + | } | |||
| 866 | + | ||||
| 857 | .pb-4 { | 867 | .pb-4 { | ||
| 858 | padding-bottom: 1rem; | 868 | padding-bottom: 1rem; | ||
| 859 | } | 869 | } | ||
| @@ -959,6 +969,16 @@ video { | |||||
| 959 | color: rgb(255 255 255 / var(--tw-text-opacity)); | 969 | color: rgb(255 255 255 / var(--tw-text-opacity)); | ||
| 960 | } | 970 | } | ||
| 961 | 971 | ||||
| 972 | + | .text-blue-600 { | |||
| 973 | + | --tw-text-opacity: 1; | |||
| 974 | + | color: rgb(37 99 235 / var(--tw-text-opacity)); | |||
| 975 | + | } | |||
| 976 | + | ||||
| 977 | + | .text-black { | |||
| 978 | + | --tw-text-opacity: 1; | |||
| 979 | + | color: rgb(0 0 0 / var(--tw-text-opacity)); | |||
| 980 | + | } | |||
| 981 | + | ||||
| 962 | .underline { | 982 | .underline { | ||
| 963 | text-decoration-line: underline; | 983 | text-decoration-line: underline; | ||
| 964 | } | 984 | } | ||
| @@ -1099,6 +1119,11 @@ video { | |||||
| 1099 | background-color: rgb(29 78 216 / var(--tw-bg-opacity)); | 1119 | background-color: rgb(29 78 216 / var(--tw-bg-opacity)); | ||
| 1100 | } | 1120 | } | ||
| 1101 | 1121 | ||||
| 1122 | + | .hover\:bg-gordonmascot-hblue:hover { | |||
| 1123 | + | --tw-bg-opacity: 1; | |||
| 1124 | + | background-color: rgb(1 30 92 / var(--tw-bg-opacity)); | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1102 | @media (min-width: 640px) { | 1127 | @media (min-width: 640px) { | ||
| 1103 | .sm\:h-24 { | 1128 | .sm\:h-24 { | ||
| 1104 | height: 6rem; | 1129 | height: 6rem; | ||
M internal/webui/public/assets/js/custom.js +47 -0
| @@ -9,3 +9,50 @@ document.body.addEventListener("htmx:afterOnLoad", function(event) { | |||
| 9 | 9 | } | |
| 10 | 10 | }); | |
| 11 | 11 | ||
| 12 | + | function toggleButtonState(event) { | |
| 13 | + | const button = event.currentTarget; | |
| 14 | + | const isActive = button.getAttribute('data-active') === 'true'; | |
| 15 | + | ||
| 16 | + | // Toggle the state | |
| 17 | + | button.setAttribute('data-active', !isActive); | |
| 18 | + | ||
| 19 | + | // Get the related component based on hx-target attribute | |
| 20 | + | const hxTarget = button.getAttribute('hx-target'); | |
| 21 | + | const relatedComponent = document.querySelector(hxTarget); | |
| 22 | + | ||
| 23 | + | if (relatedComponent) { | |
| 24 | + | if (isActive) { | |
| 25 | + | relatedComponent.style.display = 'none'; // Hide | |
| 26 | + | } else { | |
| 27 | + | relatedComponent.style.display = 'block'; // Show | |
| 28 | + | ||
| 29 | + | // Adjust textarea height if it exists within the relatedComponent | |
| 30 | + | const textarea = relatedComponent.querySelector("#container_config"); | |
| 31 | + | if (textarea) { | |
| 32 | + | textarea.style.height = ""; // Reset the height | |
| 33 | + | textarea.style.height = textarea.scrollHeight + "px"; | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | ||
| 40 | + | // Attach event listeners to buttons | |
| 41 | + | document.body.addEventListener("htmx:afterSwap", function (event) { | |
| 42 | + | const buttons = document.querySelectorAll('[id^="start-button-"], [id^="stop-button-"], [id^="edit-button-"], [id^="remove-button-img-"]'); | |
| 43 | + | buttons.forEach(button => { | |
| 44 | + | console.log("button: ", button); | |
| 45 | + | button.addEventListener('click', toggleButtonState); | |
| 46 | + | }); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | document.body.addEventListener("htmx:afterSwap", function (event) { | |
| 50 | + | const textarea = document.getElementById("container_config"); | |
| 51 | + | if (textarea) { | |
| 52 | + | console.log("Textarea found, adjusting height."); | |
| 53 | + | textarea.style.height = ""; // Reset the height | |
| 54 | + | textarea.style.height = textarea.scrollHeight + "px"; | |
| 55 | + | } else { | |
| 56 | + | console.log("Textarea not found."); | |
| 57 | + | } | |
| 58 | + | });⏎̸ | |
| @@ -9,3 +9,50 @@ document.body.addEventListener("htmx:afterOnLoad", function(event) { | |||||
| 9 | } | 9 | } | ||
| 10 | }); | 10 | }); | ||
| 11 | 11 | ||||
| 12 | + | function toggleButtonState(event) { | |||
| 13 | + | const button = event.currentTarget; | |||
| 14 | + | const isActive = button.getAttribute('data-active') === 'true'; | |||
| 15 | + | ||||
| 16 | + | // Toggle the state | |||
| 17 | + | button.setAttribute('data-active', !isActive); | |||
| 18 | + | ||||
| 19 | + | // Get the related component based on hx-target attribute | |||
| 20 | + | const hxTarget = button.getAttribute('hx-target'); | |||
| 21 | + | const relatedComponent = document.querySelector(hxTarget); | |||
| 22 | + | ||||
| 23 | + | if (relatedComponent) { | |||
| 24 | + | if (isActive) { | |||
| 25 | + | relatedComponent.style.display = 'none'; // Hide | |||
| 26 | + | } else { | |||
| 27 | + | relatedComponent.style.display = 'block'; // Show | |||
| 28 | + | ||||
| 29 | + | // Adjust textarea height if it exists within the relatedComponent | |||
| 30 | + | const textarea = relatedComponent.querySelector("#container_config"); | |||
| 31 | + | if (textarea) { | |||
| 32 | + | textarea.style.height = ""; // Reset the height | |||
| 33 | + | textarea.style.height = textarea.scrollHeight + "px"; | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | ||||
| 40 | + | // Attach event listeners to buttons | |||
| 41 | + | document.body.addEventListener("htmx:afterSwap", function (event) { | |||
| 42 | + | const buttons = document.querySelectorAll('[id^="start-button-"], [id^="stop-button-"], [id^="edit-button-"], [id^="remove-button-img-"]'); | |||
| 43 | + | buttons.forEach(button => { | |||
| 44 | + | console.log("button: ", button); | |||
| 45 | + | button.addEventListener('click', toggleButtonState); | |||
| 46 | + | }); | |||
| 47 | + | }); | |||
| 48 | + | ||||
| 49 | + | document.body.addEventListener("htmx:afterSwap", function (event) { | |||
| 50 | + | const textarea = document.getElementById("container_config"); | |||
| 51 | + | if (textarea) { | |||
| 52 | + | console.log("Textarea found, adjusting height."); | |||
| 53 | + | textarea.style.height = ""; // Reset the height | |||
| 54 | + | textarea.style.height = textarea.scrollHeight + "px"; | |||
| 55 | + | } else { | |||
| 56 | + | console.log("Textarea not found."); | |||
| 57 | + | } | |||
| 58 | + | });⏎̸ | |||
M pkg/utils/docker/containers.go +35 -0
| @@ -182,6 +182,41 @@ func GetContainerInfo(containerID string) (types.ContainerJSON, error) { | |||
| 182 | 182 | return containerInfo, nil | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | + | // UpdateContainerConfig updates the configuration of an existing container. | |
| 186 | + | func UpdateContainerConfig(containerID string, newConfig *container.Config) error { | |
| 187 | + | ctx := context.Background() | |
| 188 | + | // 1. Gracefully stop the existing container | |
| 189 | + | _, err := StopContainerGracefully(containerID, 3*time.Second) | |
| 190 | + | if err != nil { | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // 2. Remove the old container | |
| 195 | + | if err := dockerCli.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{}); err != nil { | |
| 196 | + | return err | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // 3. Create a new container with the new configuration | |
| 200 | + | resp, err := dockerCli.ContainerCreate( | |
| 201 | + | ctx, | |
| 202 | + | newConfig, | |
| 203 | + | &container.HostConfig{}, | |
| 204 | + | &network.NetworkingConfig{}, | |
| 205 | + | nil, | |
| 206 | + | "", | |
| 207 | + | ) | |
| 208 | + | if err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // 4. Start the new container | |
| 213 | + | if err := dockerCli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { | |
| 214 | + | return err | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | ||
| 185 | 220 | // GetContainerLogs returns the logs of a container | |
| 186 | 221 | func GetContainerLogs(containerID string) (string, error) { | |
| 187 | 222 | ||
| @@ -182,6 +182,41 @@ func GetContainerInfo(containerID string) (types.ContainerJSON, error) { | |||||
| 182 | return containerInfo, nil | 182 | return containerInfo, nil | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | + | // UpdateContainerConfig updates the configuration of an existing container. | |||
| 186 | + | func UpdateContainerConfig(containerID string, newConfig *container.Config) error { | |||
| 187 | + | ctx := context.Background() | |||
| 188 | + | // 1. Gracefully stop the existing container | |||
| 189 | + | _, err := StopContainerGracefully(containerID, 3*time.Second) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return err | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // 2. Remove the old container | |||
| 195 | + | if err := dockerCli.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{}); err != nil { | |||
| 196 | + | return err | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // 3. Create a new container with the new configuration | |||
| 200 | + | resp, err := dockerCli.ContainerCreate( | |||
| 201 | + | ctx, | |||
| 202 | + | newConfig, | |||
| 203 | + | &container.HostConfig{}, | |||
| 204 | + | &network.NetworkingConfig{}, | |||
| 205 | + | nil, | |||
| 206 | + | "", | |||
| 207 | + | ) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // 4. Start the new container | |||
| 213 | + | if err := dockerCli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { | |||
| 214 | + | return err | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | ||||
| 185 | // GetContainerLogs returns the logs of a container | 220 | // GetContainerLogs returns the logs of a container | ||
| 186 | func GetContainerLogs(containerID string) (string, error) { | 221 | func GetContainerLogs(containerID string) (string, error) { | ||
| 187 | 222 | ||||