appmanager: replace svelte implementation with go based one
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
new file mode 100644
index 0000000..df7501b
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -0,0 +1,214 @@
+{{ define "schema-form" }}
+ {{ $readonly := .ReadOnly }}
+ {{ $networks := .AvailableNetworks }}
+ {{ $data := .Data }}
+ {{ range $name, $schema := .Schema.properties }}
+ {{ if eq $schema.type "string" }}
+ <label for="{{ $name }}">
+ <span>{{ $name }}</span>
+ </label>
+ {{ if eq (index $schema "role") "network" }}
+ <select oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} >
+ {{ if not $readonly }}<option disabled selected value> -- select an option -- </option>{{ end }}
+ {{ range $networks }}
+ <option {{if eq .Name (index $data $name) }}selected{{ end }}>{{ .Name }}</option>
+ {{ end }}
+ </select>
+ {{ else }}
+ <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}"/>
+ {{ end }}
+ {{ end }}
+ {{ end }}
+{{ end }}
+
+{{ define "main" }}
+{{ $instance := .Instance }}
+<h1>{{ .App.Icon }}{{ .App.Name }}</h1>
+<pre id="readme"></pre>
+
+{{ $schema := .App.ConfigSchema }}
+{{ $networks := .AvailableNetworks }}
+
+<form id="config-form">
+ {{ if $instance }}
+ {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" $instance.Config) }}
+ {{ else }}
+ {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" (dict)) }}
+ {{ end }}
+ {{ if $instance }}
+ <div class="grid">
+ <button type="submit" id="submit" name="update">Update</button>
+ <button type="submit" id="uninstall" name="remove">Uninstall</button>
+ </div>
+ {{ else }}
+ <button type="submit" id="submit">{{ if $instance }}Update{{ else }}Install{{ end }}</button>
+ {{ end }}
+</form>
+
+{{ range .Instances }}
+ {{ if or (not $instance) (ne $instance.Id .Id)}}
+ <details>
+ <summary>{{ .Id }}</summary>
+ {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" true "Data" .Config ) }}
+ <a href="/instance/{{ .Id }}" role="button" class="secondary">View</a>
+ </details>
+ {{ end }}
+{{ end }}
+
+
+<div id="toast-success" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> {{ if $instance }}Update succeeded{{ else }}Install succeeded{{ end}}
+</div>
+
+<div id="toast-failure" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> {{ if $instance }}Update failed{{ else}}Install failed{{ end }}
+</div>
+
+<div id="toast-uninstall-success" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> Uninstalled application
+</div>
+
+<div id="toast-uninstall-failure" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> Failed to uninstall application
+</div>
+
+<style>
+ pre {
+ white-space: pre-wrap; /* Since CSS 2.1 */
+ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
+ white-space: -pre-wrap; /* Opera 4-6 */
+ white-space: -o-pre-wrap; /* Opera 7 */
+ word-wrap: break-word; /* Internet Explorer 5.5+ */
+ background-color: transparent;
+ }
+
+ .hidden {
+ visibility: hidden;
+ }
+
+ .toast {
+ position: fixed;
+ z-index: 999;
+ bottom: 10px;
+ }
+</style>
+
+<script>
+ let readme = "";
+ let config = {{ if $instance }}JSON.parse({{ toJson $instance.Config }}){{ else }}{}{{ end }};
+
+ function valueChanged(name, value) {
+ config[name] = value;
+ renderReadme();
+ }
+
+ async function renderReadme() {
+ const resp = await fetch("/api/app/{{ .App.Name }}/render", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+ body: JSON.stringify(config),
+ });
+ const app = await resp.json();
+ document.getElementById("readme").innerHTML = app.readme;
+ }
+
+ {{ if $instance }}renderReadme();{{ end }}
+
+ function disableForm() {
+ document.querySelectorAll("#config-form input").forEach((i) => i.setAttribute("disabled", ""));
+ document.querySelectorAll("#config-form select").forEach((i) => i.setAttribute("disabled", ""));
+ document.querySelectorAll("#config-form button").forEach((i) => i.setAttribute("disabled", ""));
+ }
+
+ function enableForm() {
+ document.querySelectorAll("[aria-busy]").forEach((i) => i.removeAttribute("aria-busy"));
+ document.querySelectorAll("#config-form input").forEach((i) => i.removeAttribute("disabled"));
+ document.querySelectorAll("#config-form select").forEach((i) => i.removeAttribute("disabled"));
+ document.querySelectorAll("#config-form button").forEach((i) => i.removeAttribute("disabled"));
+ }
+
+ function installStarted() {
+ const submit = document.getElementById("submit");
+ submit.setAttribute("aria-busy", true);
+ submit.innerHTML = {{ if $instance }}"Updating ..."{{ else }}"Installing ..."{{ end }};
+ disableForm();
+ }
+
+ function uninstallStarted() {
+ const submit = document.getElementById("uninstall");
+ submit.setAttribute("aria-busy", true);
+ submit.innerHTML = "Uninstalling ...";
+ disableForm();
+ }
+
+ function actionFinished(toast) {
+ enableForm();
+ toast.classList.remove("hidden");
+ setTimeout(
+ () => toast.classList.add("hidden"),
+ 2000,
+ );
+ }
+
+ function installSucceeded() {
+ actionFinished(document.getElementById("toast-success"));
+ }
+
+ function installFailed() {
+ actionFinished(document.getElementById("toast-failure"));
+ }
+
+ function uninstallSucceeded() {
+ actionFinished(document.getElementById("toast-uninstall-success"));
+ }
+
+ function uninstallFailed() {
+ actionFinished(document.getElementById("toast-uninstall-failure"));
+ }
+
+ const submitAddr = {{ if $instance }}"/api/instance/{{ $instance.Id }}/update"{{ else }}"/api/app/{{ .App.Name }}/install"{{ end }};
+
+ async function install() {
+ installStarted();
+ const resp = await fetch(submitAddr, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+ body: JSON.stringify(config),
+ });
+ if (resp.status === 200) {
+ installSucceeded();
+ } else {
+ installFailed();
+ }
+ }
+
+ async function uninstall() {
+ {{ if $instance }}
+ uninstallStarted();
+ const resp = await fetch("/api/instance/{{ $instance.Id }}/remove", {
+ method: "POST",
+ });
+ if (resp.status === 200) {
+ uninstallSucceeded();
+ } else {
+ uninstallFailed();
+ }
+ {{ end }}
+ }
+
+ document.getElementById("config-form").addEventListener("submit", (event) => {
+ event.preventDefault();
+ if (event.submitter.id === "submit") {
+ install();
+ } if (event.submitter.id === "uninstall") {
+ uninstall();
+ }
+ });
+</script>
+{{ end }}
diff --git a/core/installer/welcome/appmanager-tmpl/base.html b/core/installer/welcome/appmanager-tmpl/base.html
new file mode 100644
index 0000000..9e4bb49
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/base.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="light">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" href="/static/pico.min.css">
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <style>
+ #main {
+ margin-top: 10px;
+ }
+
+ @media screen and (min-width:801px) {
+ #main {
+ width: 600px;
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <div id="main" class="container">{{ block "main" . }}CHILD MUST OVERRIDE THIS{{ end }}</div>
+ </body>
+</html>
diff --git a/core/installer/welcome/appmanager-tmpl/index.html b/core/installer/welcome/appmanager-tmpl/index.html
new file mode 100644
index 0000000..02150ff
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/index.html
@@ -0,0 +1,50 @@
+{{ define "main" }}
+<form>
+ <input type="search" placeholder="Search" />
+</form>
+
+<aside>
+ <nav>
+ <ul>
+ {{ range . }}
+ <li>
+ <article>
+ <div>
+ <a href="/app/{{ .Slug }}" class="logo">
+ {{ .Icon }}
+ </a>
+ </div>
+ <div>
+ <a href="/app/{{ .Slug }}">{{ .Name }}</a>
+ {{ .ShortDescription }}
+ </div>
+ </article>
+ </li>
+ {{ end }}
+ </ul>
+ </nav>
+</aside>
+
+<style>
+ article {
+ margin: 0.3em;
+ margin-bottom: 0.3em;
+
+ display: flex;
+ flex-direction: row;
+ }
+
+ .logo {
+ display: table-cell;
+ vertical-align: middle;
+ }
+ nav li {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ input[type="search"] {
+ margin-bottom: 0;
+ }
+</style>
+{{ end }}
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
new file mode 100644
index 0000000..242faed
--- /dev/null
+++ b/core/installer/welcome/appmanager.go
@@ -0,0 +1,370 @@
+package welcome
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io/ioutil"
+ "log"
+ "net/http"
+ // "net/http/httputil"
+ // "net/url"
+
+ "github.com/Masterminds/sprig/v3"
+ "github.com/labstack/echo/v4"
+
+ "github.com/giolekva/pcloud/core/installer"
+)
+
+//go:embed appmanager-tmpl
+var mgrTmpl embed.FS
+
+//go:embed appmanager-tmpl/base.html
+var baseHtmlTmpl string
+
+//go:embed appmanager-tmpl/app.html
+var appHtmlTmpl string
+
+type AppManagerServer struct {
+ port int
+ webAppAddr string
+ m *installer.AppManager
+ r installer.AppRepository[installer.StoreApp]
+}
+
+func NewAppManagerServer(
+ port int,
+ webAppAddr string,
+ m *installer.AppManager,
+ r installer.AppRepository[installer.StoreApp],
+) *AppManagerServer {
+ return &AppManagerServer{
+ port,
+ webAppAddr,
+ m,
+ r,
+ }
+}
+
+func (s *AppManagerServer) Start() {
+ e := echo.New()
+ e.StaticFS("/static", echo.MustSubFS(staticAssets, "static"))
+ e.GET("/api/app-repo", s.handleAppRepo)
+ e.POST("/api/app/:slug/render", s.handleAppRender)
+ e.POST("/api/app/:slug/install", s.handleAppInstall)
+ e.GET("/api/app/:slug", s.handleApp)
+ e.GET("/api/instance/:slug", s.handleInstance)
+ e.POST("/api/instance/:slug/update", s.handleAppUpdate)
+ e.POST("/api/instance/:slug/remove", s.handleAppRemove)
+ e.GET("/", s.handleIndex)
+ e.GET("/app/:slug", s.handleAppUI)
+ e.GET("/instance/:slug", s.handleInstanceUI)
+ fmt.Printf("Starting HTTP server on port: %d\n", s.port)
+ log.Fatal(e.Start(fmt.Sprintf(":%d", s.port)))
+}
+
+type app struct {
+ Name string `json:"name"`
+ Icon template.HTML `json:"icon"`
+ ShortDescription string `json:"shortDescription"`
+ Slug string `json:"slug"`
+ Schema string `json:"schema"`
+ Instances []installer.AppConfig `json:"instances,omitempty"`
+}
+
+func (s *AppManagerServer) handleAppRepo(c echo.Context) error {
+ all, err := s.r.GetAll()
+ if err != nil {
+ return err
+ }
+ resp := make([]app, len(all))
+ for i, a := range all {
+ resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+ }
+ return c.JSON(http.StatusOK, resp)
+}
+
+func (s *AppManagerServer) handleApp(c echo.Context) error {
+ slug := c.Param("slug")
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ instances, err := s.m.FindAllInstances(slug)
+ if err != nil {
+ return err
+ }
+ for _, instance := range instances {
+ values, ok := instance.Config["Values"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ for k, v := range values {
+ if k == "Network" {
+ n, ok := v.(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ values["Network"], ok = n["Name"]
+ if !ok {
+ return fmt.Errorf("Missing Name")
+ }
+ break
+ }
+ }
+ }
+ return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, instances})
+}
+
+func (s *AppManagerServer) handleInstance(c echo.Context) error {
+ slug := c.Param("slug")
+ instance, err := s.m.FindInstance(slug)
+ if err != nil {
+ return err
+ }
+ values, ok := instance.Config["Values"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ for k, v := range values {
+ if k == "Network" {
+ n, ok := v.(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ values["Network"], ok = n["Name"]
+ if !ok {
+ return fmt.Errorf("Missing Name")
+ }
+ break
+ }
+ }
+ a, err := s.r.Find(instance.AppId)
+ if err != nil {
+ return err
+ }
+ return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, []installer.AppConfig{instance}})
+}
+
+type file struct {
+ Name string `json:"name"`
+ Contents string `json:"contents"`
+}
+
+type rendered struct {
+ Readme string `json:"readme"`
+ Files []file `json:"files"`
+}
+
+func (s *AppManagerServer) handleAppRender(c echo.Context) error {
+ slug := c.Param("slug")
+ contents, err := ioutil.ReadAll(c.Request().Body)
+ if err != nil {
+ return err
+ }
+ global, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ var values map[string]any
+ if err := json.Unmarshal(contents, &values); err != nil {
+ return err
+ }
+ if network, ok := values["Network"]; ok {
+ for _, n := range installer.CreateNetworks(global) {
+ if n.Name == network { // TODO(giolekva): handle not found
+ values["Network"] = n
+ }
+ }
+ }
+ all := map[string]any{
+ "Global": global.Values,
+ "Values": values,
+ }
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ var readme bytes.Buffer
+ if err := a.Readme.Execute(&readme, all); err != nil {
+ return err
+ }
+ var resp rendered
+ resp.Readme = readme.String()
+ for _, tmpl := range a.Templates { // TODO(giolekva): deduplicate with Install
+ var f bytes.Buffer
+ if err := tmpl.Execute(&f, all); err != nil {
+ fmt.Printf("%+v\n", all)
+ fmt.Println(err.Error())
+ return err
+ } else {
+ resp.Files = append(resp.Files, file{tmpl.Name(), f.String()})
+ }
+ }
+ out, err := json.Marshal(resp)
+ if err != nil {
+ return err
+ }
+ if _, err := c.Response().Writer.Write(out); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *AppManagerServer) handleAppInstall(c echo.Context) error {
+ slug := c.Param("slug")
+ contents, err := ioutil.ReadAll(c.Request().Body)
+ if err != nil {
+ return err
+ }
+ var values map[string]any
+ if err := json.Unmarshal(contents, &values); err != nil {
+ return err
+ }
+ fmt.Println(values)
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ config, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
+ suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
+ if err := s.m.Install(a.App, nsGen, suffixGen, values); err != nil {
+ return err
+ }
+ return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppUpdate(c echo.Context) error {
+ slug := c.Param("slug")
+ appConfig, err := s.m.AppConfig(slug)
+ if err != nil {
+ return err
+ }
+ contents, err := ioutil.ReadAll(c.Request().Body)
+ if err != nil {
+ return err
+ }
+ var values map[string]any
+ if err := json.Unmarshal(contents, &values); err != nil {
+ return err
+ }
+ a, err := s.r.Find(appConfig.AppId)
+ if err != nil {
+ return err
+ }
+ if err := s.m.Update(a.App, slug, values); err != nil {
+ return err
+ }
+ return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppRemove(c echo.Context) error {
+ slug := c.Param("slug")
+ if err := s.m.Remove(slug); err != nil {
+ return err
+ }
+ return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleIndex(c echo.Context) error {
+ tmpl, err := template.ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/index.html")
+ if err != nil {
+ return err
+ }
+ all, err := s.r.GetAll()
+ if err != nil {
+ return err
+ }
+ resp := make([]app, len(all))
+ for i, a := range all {
+ resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+ }
+ return tmpl.Execute(c.Response(), resp)
+}
+
+type appContext[T any] struct {
+ App *T
+ Instance *installer.AppConfig
+ Instances []installer.AppConfig
+ AvailableNetworks []installer.Network
+}
+
+func (s *AppManagerServer) handleAppUI(c echo.Context) error {
+ baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+ if err != nil {
+ return err
+ }
+ appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+ if err != nil {
+ fmt.Println(err)
+ return err
+ }
+ global, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ slug := c.Param("slug")
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ instances, err := s.m.FindAllInstances(slug)
+ if err != nil {
+ return err
+ }
+ err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+ App: a,
+ Instances: instances,
+ AvailableNetworks: installer.CreateNetworks(global),
+ })
+ fmt.Println(err)
+ return err
+}
+
+func (s *AppManagerServer) handleInstanceUI(c echo.Context) error {
+ baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+ if err != nil {
+ return err
+ }
+ appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+ // tmpl, err := newTemplate().ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/app.html")
+ if err != nil {
+ fmt.Println(err)
+ return err
+ }
+ global, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ slug := c.Param("slug")
+ instance, err := s.m.FindInstance(slug)
+ if err != nil {
+ return err
+ }
+ a, err := s.r.Find(instance.AppId)
+ if err != nil {
+ return err
+ }
+ instances, err := s.m.FindAllInstances(a.Name)
+ if err != nil {
+ return err
+ }
+ err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+ App: a,
+ Instance: &instance,
+ Instances: instances,
+ AvailableNetworks: installer.CreateNetworks(global),
+ })
+ fmt.Println(err)
+ return err
+}
+
+func newTemplate() *template.Template {
+ return template.New("base").Funcs(template.FuncMap(sprig.FuncMap()))
+}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index ab1504d..6779035 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -262,6 +262,29 @@
return err
}
}
+ {
+ keys, err := installer.NewSSHKeyPair()
+ if err != nil {
+ return err
+ }
+ user := fmt.Sprintf("%s-appmanager", req.Name)
+ if err := ss.AddUser(user, keys.Public); err != nil {
+ return err
+ }
+ if err := ss.AddCollaborator(req.Name, user); err != nil {
+ return err
+ }
+ app, err := appsRepo.Find("app-manager") // TODO(giolekva): configure
+ if err != nil {
+ return err
+ }
+ if err := appManager.Install(*app, nsGen, suffixGen, map[string]any{
+ "RepoAddr": ss.GetRepoAddress(req.Name),
+ "SSHPrivateKey": keys.Private,
+ }); err != nil {
+ return err
+ }
+ }
return nil
}