The overview page read MESH_KNOWLEDGE_PAGE, which nothing in the mesh sets; only its bus test did, to serve no page. checks/environment, which came with A3's merge, refuses such a read. The page listens on the port its manifest declares; the test's bundle sets the package's address empty instead. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
146 lines
6.9 KiB
Go
146 lines
6.9 KiB
Go
package main
|
|
|
|
// The overview page's handler and the process that keeps it (novox/hq to-be 55 §11, ADR 0616 §4): GET only, no form and no
|
|
// verb behind it, served by the module's bundle (the one process with the bus: the ledger and the sources' verbs are reached
|
|
// through it) on a port the manifest declares for this machine only, reached through the route proxy by a route the module requires.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// PageAddress is where the page listens: the port the manifest declares under `listens`, which the node's packet filter
|
|
// opens for this machine only.
|
|
const PageAddress = ":8480"
|
|
|
|
// pageAddress is where this process serves the page; empty serves none (the bus test's bundle, which runs beside other
|
|
// tests on one machine). It is no word of the environment: nothing in the mesh sets one (checks/environment).
|
|
var pageAddress = PageAddress
|
|
|
|
// PageHandler renders the model as a page; any method but GET is refused, nothing is written.
|
|
func PageHandler(o *Overview) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.Header().Set("Allow", http.MethodGet)
|
|
http.Error(w, "this page is read-only: GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
m, err := o.Model()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
|
if err := pageTemplate.Execute(w, m); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[mesh-knowledge] the page could not be rendered: %v\n", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
var pageFuncs = template.FuncMap{
|
|
"clock": func(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
return t.Local().Format("2006-01-02 15:04")
|
|
},
|
|
"join": strings.Join,
|
|
}
|
|
|
|
var pageTemplate = template.Must(template.New("page").Funcs(pageFuncs).Parse(`<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>The mesh at a glance</title>
|
|
<style>
|
|
:root{color-scheme:light dark;--bg:#fff;--fg:#1c1c1e;--mute:#6b6b70;--line:#d8d8dc;--warn:#9a5b00;--bad:#a3001d}
|
|
@media (prefers-color-scheme:dark){:root{--bg:#161618;--fg:#ececf0;--mute:#9a9aa2;--line:#35353a;--warn:#e0a040;--bad:#ff6b80}}
|
|
body{font:15px/1.5 system-ui,sans-serif;background:var(--bg);color:var(--fg);max-width:60rem;margin:2rem auto;padding:0 1rem}
|
|
h1{font-size:1.4rem}h2{font-size:1.1rem;margin-top:2rem;border-bottom:1px solid var(--line);padding-bottom:.25rem}
|
|
.mute{color:var(--mute)}.warn{color:var(--warn)}.bad{color:var(--bad)}li{margin:.25rem 0}ul{padding-left:1.2rem}
|
|
code{font-size:.9em}.src{display:inline-block;margin-right:1rem}
|
|
</style></head><body>
|
|
<h1>The mesh at a glance</h1>
|
|
<p class="mute">Read at {{clock .At}}. Sources:
|
|
{{range .Sources}}<span class="src">{{.Name}}: {{if .Answered}}answered {{clock .Last}}{{else}}<span class="bad">does not answer</span>{{if .Mark}} ({{.Mark.Cause}}: {{.Mark.Why}}; last answer {{clock .Last}}){{end}}{{end}}</span>{{end}}</p>
|
|
|
|
<h2>Designs in progress</h2>
|
|
{{range .Sources}}{{if and (eq .Name "issue-tracker") (not .Answered)}}<p class="bad">The issue tracker does not answer: this block is not empty, it is unknown (last answer {{clock .Last}}).</p>{{end}}{{end}}
|
|
{{if .Designs}}<ul>{{range .Designs}}<li><strong>Design {{.Design}}, milestone {{.ID}}</strong> ({{.Status}}): {{.Outcome}}
|
|
{{if .Issues}}<ul>{{range .Issues}}<li>issue {{.Number}}{{if .Status}} <span class="mute">[{{.Status}}]</span>{{end}} {{.Title}}</li>{{end}}</ul>{{end}}</li>{{end}}</ul>
|
|
{{else}}<p class="mute">No design has a milestone open.</p>{{end}}
|
|
|
|
<h2>Records</h2>
|
|
<h3>Accepted in the last seven days</h3>
|
|
{{if .Accepted}}<ul>{{range .Accepted}}<li>{{.Record}}: {{.Title}} <span class="mute">({{.Status}}, {{.Date}})</span></li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
|
<h3>Waiting</h3>
|
|
{{if .Waiting}}<ul>{{range .Waiting}}<li>{{.Record}}: {{.Title}} <span class="mute">waits for {{.Waits}}</span></li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
|
|
|
<h2>Waiting on you</h2>
|
|
{{range .Sources}}{{if and (eq .Name "operator-channel") (not .Answered)}}<p class="bad">The operator channel does not answer: the asks below are not complete (last answer {{clock .Last}}).</p>{{end}}{{if and (eq .Name "mesh-delivery") (not .Answered)}}<p class="bad">Delivery does not answer: the groups below are not complete (last answer {{clock .Last}}).</p>{{end}}{{end}}
|
|
<h3>Records waiting for your Approve</h3>
|
|
{{if .Operator.Records}}<ul>{{range .Operator.Records}}<li>{{.Record}}: {{.Title}}</li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
|
<h3>Open asks</h3>
|
|
{{if .Operator.Asks}}<ul>{{range .Operator.Asks}}<li>{{.Headline}} <span class="mute">from {{.Asker}}{{if .Offered}}, offered on {{join .Offered ", "}}{{end}}</span></li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
|
<h3>Delivery groups waiting</h3>
|
|
{{if .Operator.Groups}}<ul>{{range .Operator.Groups}}<li><code>{{.ID}}</code> {{.State}}{{if .Why}}: {{.Why}}{{end}}</li>{{end}}</ul>{{else}}<p class="mute">None.</p>{{end}}
|
|
</body></html>`))
|
|
|
|
// ServePage starts the overview: the model's first view, the events kept, the unanswered sources asked again on their backoff,
|
|
// and the page on its address. It runs for the life of the process.
|
|
func ServePage(k *KB) {
|
|
o := NewOverview(k.Seats, func() ([]*Entry, error) { return k.Store.All(context.Background()) }, nil)
|
|
k.Overview = o
|
|
<-stdio.Served()
|
|
for source, events := range OverviewEvents {
|
|
for _, pattern := range events {
|
|
source, pattern := source, pattern
|
|
go func() {
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
err := stdio.Subscribe(pattern, func(e stdio.Envelope) error {
|
|
o.Heard(source, pattern, e.Body)
|
|
return nil
|
|
})
|
|
if err == nil {
|
|
return
|
|
}
|
|
logf("[mesh-knowledge] the page does not hear %s yet (%v); asking again in %s", pattern, err, wait)
|
|
time.Sleep(wait)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
// The runtime's word on the module's consumer between events: a beat is each subscription heard, a missed heartbeat
|
|
// marks the sources at once (stdio.OnHeartbeat).
|
|
if err := stdio.OnHeartbeat(o.Beat); err != nil {
|
|
logf("[mesh-knowledge] the page hears no heartbeat of its subscriptions (%v): a silent source is marked after %s", err, HeartbeatWithin)
|
|
}
|
|
o.Start()
|
|
go func() {
|
|
for range time.Tick(5 * time.Second) {
|
|
o.Silent()
|
|
o.Due()
|
|
}
|
|
}()
|
|
addr := pageAddress
|
|
if addr == "" {
|
|
return
|
|
}
|
|
srv := &http.Server{Addr: addr, Handler: PageHandler(o), ReadHeaderTimeout: 10 * time.Second}
|
|
logf("[mesh-knowledge] the overview page listens on %s", addr)
|
|
if err := srv.ListenAndServe(); err != nil {
|
|
logf("[mesh-knowledge] the overview page stopped: %v", err)
|
|
}
|
|
}
|