package view import ( "bufio" "context" "encoding/json" "errors" "fmt" "html/template" "io" "net" "net/http" "os" "path/filepath" "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 && what == "" { what = r.Status } else if r.Status == StatusLeftBehind { what = r.Status + "; " + what } return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what} } 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 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(` Delivery

What each node runs

{{range .Head}}{{end}} {{range .Rows}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{if .Offline}}

OFFLINE

{{range .Offline}}{{range .}}{{end}}{{end}}
{{.}}
{{end}} {{if .Stuck}}

STUCK

{{range .Stuck}}{{range .}}{{end}}{{end}}
BUCKETKEYWAITED FORSINCE
{{.}}
{{end}} {{if .Unreadable}}

UNREADABLE

{{range .Unreadable}}{{range .}}{{end}}{{end}}
{{.}}
{{end}}`)) // 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, "Offline": off, "Stuck": stuck, "Unreadable": bad}) }) }