Files
mesh-delivery/modules/mesh-delivery-view/internal/view/serve.go
T
jschoubben 9bd84941ee Show a fingerprint of the new form against the builder's key of it, since the indexer no longer writes the old form and no build record names the new (issue 1851, I5)
The view watches the builder's builds; a value written before R4e keeps today's comparison. Pins mesh-sdk feat/r4e-sdk
9cb4d59.
2026-10-22 18:16:18 +02:00

287 lines
8.9 KiB
Go

package view
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
)
// ListenSocket listens on the unix socket at path, in a directory of mode 0755, with mode 0666 on the socket: the
// status it carries is no secret and the socket can change nothing.
func ListenSocket(path string) (net.Listener, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
if err := os.Chmod(dir, 0o755); err != nil {
return nil, err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
l, err := net.Listen("unix", path)
if err != nil {
return nil, err
}
if err := os.Chmod(path, 0o666); err != nil {
l.Close()
return nil, err
}
return l, nil
}
type ask struct {
Ask string `json:"ask"`
}
// ServeSocket answers {"ask":"rows"} on l, line-delimited JSON, until ctx ends. Any other ask is refused.
func ServeSocket(ctx context.Context, l net.Listener, m *Model) error {
go func() { <-ctx.Done(); l.Close() }()
for {
c, err := l.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
go answer(c, m)
}
}
func answer(c net.Conn, m *Model) {
defer c.Close()
c.SetDeadline(time.Now().Add(10 * time.Second))
line, err := bufio.NewReader(io.LimitReader(c, 4096)).ReadBytes('\n')
if err != nil && len(line) == 0 {
return
}
var a ask
if json.Unmarshal(line, &a) != nil || a.Ask != "rows" {
json.NewEncoder(c).Encode(map[string]string{"error": `refused: this socket answers {"ask":"rows"} and nothing else`})
return
}
json.NewEncoder(c).Encode(m.Snapshot())
}
// Ask asks the socket at path for the rows.
func Ask(path string) (Snapshot, error) {
c, err := net.DialTimeout("unix", path, 5*time.Second)
if err != nil {
return Snapshot{}, fmt.Errorf("cannot reach the view at %s: %w", path, err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(10 * time.Second))
if _, err := c.Write([]byte(`{"ask":"rows"}` + "\n")); err != nil {
return Snapshot{}, err
}
var reply struct {
Snapshot
Error string `json:"error"`
}
if err := json.NewDecoder(c).Decode(&reply); err != nil {
return Snapshot{}, fmt.Errorf("the view's answer is not rows: %w", err)
}
if reply.Error != "" {
return Snapshot{}, errors.New(reply.Error)
}
return reply.Snapshot, nil
}
func since(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Local().Format("2006-01-02 15:04:05")
}
func rowText(r Row) []string {
what := r.Waits
if r.Error != "" {
what = "unreadable: " + r.Error
}
if (r.Status == StatusLeftBehind || r.Status == StatusRemoved) && what == "" {
what = r.Status
} else if r.Status == StatusLeftBehind || r.Status == StatusRemoved {
what = r.Status + "; " + what
}
return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what}
}
// groupText is one line per pull request of a group: the branch, `<repository>#<number>`, its head, its modules.
func groupText(s Snapshot) [][]string {
var out [][]string
for _, g := range s.Groups {
for i, p := range g.Pulls {
group := ""
if i == 0 {
group = g.Group
}
out = append(out, []string{group, fmt.Sprintf("%s#%d", p.Repository, p.Number), p.Head, strings.Join(p.Modules, ", ")})
}
}
return out
}
// fingerprintText is one line per module: the indexer's fingerprint beside the build's, or the reason it has none.
func fingerprintText(s Snapshot) [][]string {
var out [][]string
for _, f := range s.Fingerprints {
what := f.Indexer
switch f.State {
case FPDiffers:
what = "indexer " + f.Indexer + "; build " + f.Build
case FPNone:
what = f.Reason
case FPNoBuild:
what = "indexer " + f.Indexer + "; no successful trunk build recorded a fingerprint"
case FPBuildFailed, FPBuilding:
what = "indexer " + f.Indexer + "; builder: " + f.Reason
case FPNoKey:
what = "indexer " + f.Indexer + "; the builder holds no key of it"
}
out = append(out, []string{f.Module, f.State, what})
}
return out
}
// askText is one line per open ask: its key, headline, words on one line, the pull requests it covers and how to answer.
func askText(s Snapshot) [][]string {
var out [][]string
for _, a := range s.Asks {
out = append(out, []string{a.Key, a.Headline, strings.Join(strings.Fields(strings.ReplaceAll(a.Words, "\n", " / ")), " "), strings.Join(a.Pulls, ", "), a.How})
}
return out
}
var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"}
// Table prints the snapshot as a table.
func Table(w io.Writer, s Snapshot) {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
row := func(cells []string) {
for i, c := range cells {
if i > 0 {
fmt.Fprint(tw, "\t")
}
fmt.Fprint(tw, c)
}
fmt.Fprintln(tw)
}
row(head)
for _, r := range s.Rows {
row(rowText(r))
}
if len(s.Nodes) > 0 {
fmt.Fprintln(tw)
row([]string{"NODE", "WINDOWS", "BUSY", "STRAYS", "SINCE"})
for _, n := range s.Nodes {
row([]string{n.Node, fmt.Sprint(n.Windows), fmt.Sprint(n.Busy), fmt.Sprint(n.Strays), since(n.Since)})
}
}
if g := groupText(s); len(g) > 0 {
fmt.Fprintln(tw)
row([]string{"CANDIDATES", "PULL REQUEST", "HEAD", "MODULES"})
for _, cells := range g {
row(cells)
}
}
if a := askText(s); len(a) > 0 {
fmt.Fprintln(tw)
row([]string{"ASKS", "HEADLINE", "WORDS", "PULL REQUESTS", "ANSWER"})
for _, cells := range a {
row(cells)
}
}
if f := fingerprintText(s); len(f) > 0 {
fmt.Fprintln(tw)
row([]string{"MODULE", "FINGERPRINT", "INDEXER AGAINST BUILD"})
for _, cells := range f {
row(cells)
}
}
if len(s.Offline) > 0 {
fmt.Fprintln(tw)
row([]string{"OFFLINE", "NAME", "STATE", "SINCE"})
for _, o := range s.Offline {
row([]string{o.Kind, o.name(), "offline", since(o.Since)})
}
}
if len(s.Stuck) > 0 {
fmt.Fprintln(tw)
row([]string{"STUCK", "KEY", "WAITED FOR", "SINCE"})
for _, k := range s.Stuck {
row([]string{k.Bucket, k.Key, k.Waited, since(k.Since)})
}
}
if len(s.Unreadable) > 0 {
fmt.Fprintln(tw)
row([]string{"UNREADABLE", "KEY", "WHY"})
for _, u := range s.Unreadable {
row([]string{u.Bucket, u.Key, u.Error})
}
}
tw.Flush()
}
func (o OfflineRow) name() string {
if o.Kind == "part" {
return o.Part + "." + o.Instance
}
return o.Node
}
var page = template.Must(template.New("p").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="5">
<title>Delivery</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
<body><h1>What each node runs</h1><table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
{{if .Groups}}<h2>CANDIDATES</h2><table><tr><th>GROUP</th><th>PULL REQUEST</th><th>HEAD</th><th>MODULES</th></tr>{{range .Groups}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Asks}}<h2>ASKS</h2><table><tr><th>ASK</th><th>HEADLINE</th><th>WORDS</th><th>PULL REQUESTS</th><th>ANSWER</th></tr>{{range .Asks}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Fingerprints}}<h2>FINGERPRINTS</h2><table><tr><th>MODULE</th><th>STATE</th><th>INDEXER AGAINST BUILD</th></tr>{{range .Fingerprints}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Offline}}<h2>OFFLINE</h2><table>{{range .Offline}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Stuck}}<h2>STUCK</h2><table><tr><th>BUCKET</th><th>KEY</th><th>WAITED FOR</th><th>SINCE</th></tr>{{range .Stuck}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Unreadable}}<h2>UNREADABLE</h2><table>{{range .Unreadable}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}</body></html>`))
// Web is the read-only web view of the same rows, refreshed by the browser.
func Web(m *Model) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "this view is read-only", http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
var rows [][]string
for _, row := range m.Snapshot().Rows {
rows = append(rows, rowText(row))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
snap := m.Snapshot()
var off, stuck, bad [][]string
for _, o := range snap.Offline {
off = append(off, []string{o.Kind, o.name(), "offline", since(o.Since)})
}
for _, k := range snap.Stuck {
stuck = append(stuck, []string{k.Bucket, k.Key, k.Waited, since(k.Since)})
}
for _, u := range snap.Unreadable {
bad = append(bad, []string{u.Bucket, u.Key, u.Error})
}
page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad})
})
}