env: status page

Updates page asynchronously every 5 seconds.
Introduces beforeStart and afterStart trigger points to update setup status information.

Change-Id: Ic2f6a9bb7a0fefeefc4d6a1a7338d506a4f99e80
diff --git a/core/installer/go.mod b/core/installer/go.mod
index a71cc2d..6952631 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -9,10 +9,10 @@
 	github.com/Masterminds/sprig/v3 v3.2.3
 	github.com/cenkalti/backoff/v4 v4.2.1
 	github.com/charmbracelet/keygen v0.5.0
-	github.com/fluxcd/source-controller/api v1.2.3
 	github.com/giolekva/pcloud/core/ns-controller v0.0.0-20231212095918-378ea88919ca
 	github.com/go-git/go-billy/v5 v5.5.0
 	github.com/go-git/go-git/v5 v5.10.1
+	github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0
 	github.com/gorilla/mux v1.8.1
 	github.com/labstack/echo/v4 v4.11.3
 	github.com/libdns/gandi v1.0.2
@@ -61,8 +61,6 @@
 	github.com/evanphx/json-patch v5.6.0+incompatible // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
 	github.com/fatih/color v1.15.0 // indirect
-	github.com/fluxcd/pkg/apis/acl v0.1.0 // indirect
-	github.com/fluxcd/pkg/apis/meta v1.2.0 // indirect
 	github.com/frankban/quicktest v1.14.5 // indirect
 	github.com/go-errors/errors v1.4.2 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
diff --git a/core/installer/go.sum b/core/installer/go.sum
index bdf96ba..cbba603 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -126,12 +126,6 @@
 github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
 github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
 github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/fluxcd/pkg/apis/acl v0.1.0 h1:EoAl377hDQYL3WqanWCdifauXqXbMyFuK82NnX6pH4Q=
-github.com/fluxcd/pkg/apis/acl v0.1.0/go.mod h1:zfEZzz169Oap034EsDhmCAGgnWlcWmIObZjYMusoXS8=
-github.com/fluxcd/pkg/apis/meta v1.2.0 h1:O766PzGAdMdQKybSflGL8oV0+GgCNIkdsxfalRyzeO8=
-github.com/fluxcd/pkg/apis/meta v1.2.0/go.mod h1:fU/Az9AoVyIxC0oI4ihG0NVMNnvrcCzdEym3wxjIQsc=
-github.com/fluxcd/source-controller/api v1.2.3 h1:71mXv3Qg9HEhcpqOq1ObmoE+P/HuZNaAvxfI7dqZMo8=
-github.com/fluxcd/source-controller/api v1.2.3/go.mod h1:5gaIVVH7hgb8p3HKFp8P6hGmZEC8fKSt4EcrG3g5vZI=
 github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI=
 github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4=
 github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
@@ -203,6 +197,8 @@
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 h1:4gjrh/PN2MuWCCElk8/I4OCKRKWCCo2zEct3VKCbibU=
+github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
 github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k=
 github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
 github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 25af486..e115b2c 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -15,12 +15,19 @@
 type Check func(ch Check) error
 
 func SetupZoneTask(env Env, ingressIP net.IP, st *state) Task {
-	return newSequentialParentTask(
+	ret := newSequentialParentTask(
 		"Configure DNS",
 		true,
 		CreateZoneRecords(env.Domain, st.publicIPs, ingressIP, env, st),
 		WaitToPropagate(env.Domain, st.publicIPs),
 	)
+	ret.beforeStart = func() {
+		st.infoListener(fmt.Sprintf("Generating DNS zone records for %s", env.Domain))
+	}
+	ret.afterDone = func() {
+		st.infoListener("DNS zone records have been propagated.")
+	}
+	return ret
 }
 
 func CreateZoneRecords(
diff --git a/core/installer/tasks/env.go b/core/installer/tasks/env.go
index 80289c4..1d1eaba 100644
--- a/core/installer/tasks/env.go
+++ b/core/installer/tasks/env.go
@@ -12,6 +12,7 @@
 )
 
 type state struct {
+	infoListener   EnvInfoListener
 	publicIPs      []net.IP
 	nsCreator      installer.NamespaceCreator
 	repo           installer.RepoIO
@@ -33,6 +34,8 @@
 	AdminPublicKey string
 }
 
+type EnvInfoListener func(string)
+
 type DNSZoneRef struct {
 	Name      string
 	Namespace string
@@ -44,11 +47,13 @@
 	startIP net.IP,
 	nsCreator installer.NamespaceCreator,
 	repo installer.RepoIO,
+	infoListener EnvInfoListener,
 ) (Task, DNSZoneRef) {
 	st := state{
-		publicIPs: publicIPs,
-		nsCreator: nsCreator,
-		repo:      repo,
+		infoListener: infoListener,
+		publicIPs:    publicIPs,
+		nsCreator:    nsCreator,
+		repo:         repo,
 	}
 	t := newSequentialParentTask(
 		"Create env",
@@ -57,6 +62,9 @@
 		SetupZoneTask(env, startIP, &st),
 		SetupInfra(env, startIP, &st),
 	)
+	t.afterDone = func() {
+		infoListener(fmt.Sprintf("dodo environment for %s has been provisioned successfully. Visit [https://welcome.%s](https://welcome.%s) to create administrative account and log into the system.", env.Domain, env.Domain, env.Domain))
+	}
 	rctx, done := context.WithCancel(context.Background())
 	t.OnDone(func(_ error) {
 		done()
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 0685a6d..39a1cd5 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -30,6 +30,9 @@
 		st.emptySuffixGen = installer.NewEmptySuffixGenerator()
 		return nil
 	})
+	t.beforeStart = func() {
+		st.infoListener("Setting up core infrastructure services.")
+	}
 	return &t
 }
 
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index e6dd47e..cb546c1 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -10,8 +10,8 @@
 )
 
 func SetupConfigRepoTask(env Env, st *state) Task {
-	return newSequentialParentTask(
-		"Configure Git repository for new environment",
+	ret := newSequentialParentTask(
+		"Configure Git repository",
 		true,
 		newSequentialParentTask(
 			"Start up Git server",
@@ -29,6 +29,10 @@
 			ConfigureFirstAccount(env, st),
 		),
 	)
+	ret.beforeStart = func() {
+		st.infoListener("dodo is driven by GitOps, changes are committed to the repository before updating an environment. This unlocks functionalities such as: rolling back to old working state, migrating dodo to new infrastructure (for example from Cloud to on-prem).")
+	}
+	return ret
 }
 
 func NewCreateConfigRepoTask(env Env, st *state) Task {
diff --git a/core/installer/tasks/tasks.go b/core/installer/tasks/tasks.go
index 6d0ec01..c097af3 100644
--- a/core/installer/tasks/tasks.go
+++ b/core/installer/tasks/tasks.go
@@ -21,10 +21,12 @@
 }
 
 type basicTask struct {
-	title     string
-	status    Status
-	err       error
-	listeners []TaskDoneListener
+	title       string
+	status      Status
+	err         error
+	listeners   []TaskDoneListener
+	beforeStart func()
+	afterDone   func()
 }
 
 func newBasicTask(title string) basicTask {
@@ -81,7 +83,15 @@
 }
 
 func (b *leafTask) Start() {
-	b.callDoneListeners(b.start())
+	b.status = StatusRunning
+	if b.beforeStart != nil {
+		b.beforeStart()
+	}
+	err := b.start()
+	defer b.callDoneListeners(err)
+	if b.afterDone != nil {
+		b.afterDone()
+	}
 }
 
 type parentTask struct {
diff --git a/core/installer/welcome/env-manager-tmpl/status.html b/core/installer/welcome/env-manager-tmpl/status.html
index 3a54edf..65e1e15 100644
--- a/core/installer/welcome/env-manager-tmpl/status.html
+++ b/core/installer/welcome/env-manager-tmpl/status.html
@@ -1,6 +1,6 @@
 {{ define "task" }}
 {{ range . }}
-<li aria-busy="{{ eq .Status 0 }}">
+<li aria-busy="{{ eq .Status 1 }}">
 	{{ .Title }}{{ if .Err }} - {{ .Err.Error }} {{ end }}
 	{{ if .Subtasks }}
 	<ul>
@@ -12,33 +12,58 @@
 {{ end }}
 
 {{ define "main" }}
-<article>
-  <ul>
-	  {{ template "task" .Root.Subtasks }}
-  </ul>
-</article>
-{{ if .DNSRecords }}
-<div>
-	<form action="" method="POST">
-		You will have to publish following DNS records via your domain registrar.
-		<textarea rows="7">{{ .DNSRecords }}</textarea>
-		<label for="domain-registrar">Domain Registrar</label>
-		<select id="domain-registrar" required tabindex="1">
-			<option value="" selected>Select registrar</option>
-			<option value="gandi">Gandi</option>
-			<option value="namecheap">Namecheap</option>
-		</select>
-		<label for="api-token">API Token</label>
-		<input
-			type="text"
-			id="api-token"
-			name="api-token"
-			required
-			autofocus
-			tabindex="2"
-		/>
-		<button type="submit" tabindex="3">Update</button>
-	</form>
+<div class="grid contents-header">
+	<div style="border-width: 1px; border-right-style: solid;">
+		setup
+	</div>
+	<div>
+		information
+	</div>
 </div>
-{{ end }}
+<div id="contents" class="grid env-status">
+	<div>
+	<ul class="progress">
+		{{ template "task" .Root.Subtasks }}
+	</ul>
+	</div>
+	<div id="create-instance-form">
+		{{ if .DNSRecords }}
+		<form action="" method="POST">
+			<p>You will have to publish following DNS records via your domain registrar.</p>
+			<textarea rows="7" disabled>{{ .DNSRecords }}</textarea>
+			<label for="domain-registrar">Domain Registrar</label>
+			<select id="domain-registrar" required tabindex="1">
+				<option value="" selected>Select registrar</option>
+				<option value="gandi">Gandi</option>
+				<option value="namecheap">Namecheap</option>
+			</select>
+			<label for="api-token">API Token</label>
+			<input
+				type="text"
+				id="api-token"
+				name="api-token"
+				required
+				autofocus
+				tabindex="2"
+			/>
+			<button type="submit" tabindex="3">Update</button>
+		</form>
+		{{ else if .EnvInfo }}
+		<p>{{ .EnvInfo }}</p>
+		{{ end }}
+	</div>
+</div>
+<script type="text/javascript">
+ async function refresh() {
+	 const resp = await fetch(window.location.href);
+	 if (resp.ok) {
+		 var tmp = document.createElement("html");
+		 tmp.innerHTML = await resp.text();
+		 document.getElementById("contents").innerHTML = tmp.getElementsByClassName("env-status")[0].innerHTML;
+	 }
+	 setTimeout(refresh, 5000);
+ }
+
+ setTimeout(refresh, 5000);
+</script>
 {{ end }}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index f8ee70d..e0d5a3f 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -14,6 +14,7 @@
 	"net/netip"
 	"strings"
 
+	"github.com/gomarkdown/markdown"
 	"github.com/gorilla/mux"
 
 	"github.com/giolekva/pcloud/core/installer"
@@ -83,7 +84,9 @@
 	dnsFetcher    installer.ZoneStatusFetcher
 	nameGenerator installer.NameGenerator
 	tasks         map[string]tasks.Task
+	envInfo       map[string]template.HTML
 	dns           map[string]tasks.DNSZoneRef
+	dnsPublished  map[string]struct{}
 }
 
 func NewEnvServer(
@@ -102,7 +105,9 @@
 		dnsFetcher,
 		nameGenerator,
 		make(map[string]tasks.Task),
+		make(map[string]template.HTML),
 		make(map[string]tasks.DNSZoneRef),
+		make(map[string]struct{}),
 	}
 }
 
@@ -130,23 +135,29 @@
 		http.Error(w, "Task not found", http.StatusBadRequest)
 		return
 	}
-	dnsRef, ok := s.dns[key]
-	if !ok {
-		http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
-		return
+	dnsRecords := ""
+	if _, ok := s.dnsPublished[key]; !ok {
+		dnsRef, ok := s.dns[key]
+		if !ok {
+			http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
+			return
+		}
+		err, ready, info := s.dnsFetcher.Fetch(dnsRef.Namespace, dnsRef.Name)
+		// TODO(gio): check error type
+		if err != nil && (ready || len(info.Records) > 0) {
+			panic("!! SHOULD NOT REACH !!")
+		}
+		if !ready && len(info.Records) > 0 {
+			panic("!! SHOULD NOT REACH !!")
+		}
+		dnsRecords = info.Records
 	}
-	err, ready, info := s.dnsFetcher.Fetch(dnsRef.Namespace, dnsRef.Name)
-	// TODO(gio): check error type
-	if err != nil && (ready || len(info.Records) > 0) {
-		panic("!! SHOULD NOT REACH !!")
-	}
-	if !ready && len(info.Records) > 0 {
-		panic("!! SHOULD NOT REACH !!")
-	}
-	if err := tmplsParsed.status.Execute(w, map[string]any{
+	data := map[string]any{
 		"Root":       t,
-		"DNSRecords": info.Records,
-	}); err != nil {
+		"EnvInfo":    s.envInfo[key],
+		"DNSRecords": dnsRecords,
+	}
+	if err := tmplsParsed.status.Execute(w, data); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -184,6 +195,8 @@
 			return
 		}
 	}
+	s.envInfo[key] = "Successfully published DNS records, waiting to propagate."
+	s.dnsPublished[key] = struct{}{}
 	http.Redirect(w, r, fmt.Sprintf("/env/%s", key), http.StatusSeeOther)
 }
 
@@ -359,6 +372,17 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	key := func() string {
+		for {
+			key, err := s.nameGenerator.Generate()
+			if err == nil {
+				return key
+			}
+		}
+	}()
+	infoUpdater := func(info string) {
+		s.envInfo[key] = template.HTML(markdown.ToHTML([]byte(info), nil, nil))
+	}
 	t, dns := tasks.NewCreateEnvTask(
 		tasks.Env{
 			PCloudEnvName:  env.Name,
@@ -374,15 +398,8 @@
 		startIP,
 		s.nsCreator,
 		s.repo,
+		infoUpdater,
 	)
-	key := func() string {
-		for {
-			key, err := s.nameGenerator.Generate()
-			if err == nil {
-				return key
-			}
-		}
-	}()
 	s.tasks[key] = t
 	s.dns[key] = dns
 	go t.Start()
diff --git a/core/installer/welcome/static/main.css b/core/installer/welcome/static/main.css
index 2a01ae8..76485a1 100644
--- a/core/installer/welcome/static/main.css
+++ b/core/installer/welcome/static/main.css
@@ -90,11 +90,15 @@
 	color: #ffffff;
 }
 
-#create-instance-form input, textarea, button {
+#create-instance-form input, textarea, button, select {
 	border-width: 0.5px !important;
 	background-color: #3a3a3a;
 }
 
+#create-instance-form p {
+	color: #ffffff;
+}
+
 #create-instance-form input {
 	height: 3rem;
 	color: #ffffff;
@@ -109,3 +113,19 @@
 	font-weight: bold;
 	color: #3a3a3a;
 }
+
+/* .progress { */
+/* 	padding-top: 10px; */
+/* } */
+
+.progress {
+	padding-left: 0;
+}
+
+.progress ul {
+	padding-left: 15px;
+}
+
+.progress li {
+	list-style-type: none;
+}