Files
mesh-catalog/modules/messenger/cmd/messenger/status.go
T

306 lines
11 KiB
Go

package main
import (
"sort"
"time"
)
// ChannelStatus is one channel as the status says it.
type ChannelStatus struct {
Name string `json:"name"`
CanSend bool `json:"can_send"`
NotReady string `json:"not_ready,omitempty"`
LastError string `json:"last_error,omitempty"`
LastSent string `json:"last_sent,omitempty"`
SentLastHour int `json:"sent_last_hour"`
Cap int `json:"cap_per_hour"`
Held int `json:"held"` // waiting for their window, the digest or the cap
}
// Status is the holder's whole account of itself.
type Status struct {
Verdict string `json:"verdict"`
Channels []ChannelStatus `json:"channels"`
Open int `json:"open"`
Silenced int `json:"silenced"`
Unsent []string `json:"unsent,omitempty"`
Refused int `json:"refused_since_start"`
Unreadable int `json:"unreadable_events_since_start"`
LastBad string `json:"last_unreadable,omitempty"`
Heard map[string]int `json:"events_heard_since_start"`
LastHeard string `json:"last_event_heard,omitempty"`
Listening string `json:"listening"`
StateRead string `json:"state_read,omitempty"` // when the open conditions were last read from the controller
StateUnread string `json:"state_unreadable,omitempty"` // why they could not be, lately
History int `json:"events_already_in_state"` // older than the reading: not acted on
Stale int `json:"events_taken_as_state"` // older than the freshness bound: recorded, never said
Grace bool `json:"in_start_grace,omitempty"`
StateProblem string `json:"state_problem,omitempty"`
TimeZone string `json:"time_zone"` // the zone every time in a message is said in
Rules []string `json:"rules"`
}
// Status answers at once from what the holder keeps.
func (h *Holder) Status(listening string) Status {
now := h.Now()
var st Status
for _, ch := range h.kindChannels() {
cs := ChannelStatus{Name: ch.Name(), Cap: CapPerHour, SentLastHour: h.sentLastHour(ch.Name())}
if err := ch.Ready(); err != nil {
cs.NotReady = err.Error()
} else {
cs.CanSend = true
}
h.mu.Lock()
cs.LastError = h.chanErr[ch.Name()]
if t, ok := h.chanOK[ch.Name()]; ok {
cs.LastSent = t.UTC().Format(time.RFC3339)
}
cs.Held = len(h.outbox[ch.Name()])
h.mu.Unlock()
if cs.LastError != "" {
cs.CanSend = false
}
st.Channels = append(st.Channels, cs)
}
h.mu.Lock()
for _, r := range h.open {
if !r.Cleared.IsZero() {
continue
}
st.Open++
if r.silenced(now) {
st.Silenced++
}
if r.Pending != "" {
st.Unsent = append(st.Unsent, r.Key+" ("+r.Pending+")")
}
}
st.Refused = len(h.refusals)
st.Unreadable = h.unreadable
st.LastBad = h.lastBad
st.Heard = map[string]int{}
for k, v := range h.heard {
st.Heard[k] = v
}
if !h.lastHeard.IsZero() {
st.LastHeard = h.lastHeard.UTC().Format(time.RFC3339)
}
st.StateProblem = h.storeErr
if !h.syncedAt.IsZero() {
st.StateRead = h.syncedAt.UTC().Format(time.RFC3339)
}
st.StateUnread = h.syncErr
st.History, st.Stale = h.history, h.stale
st.Grace = h.inGrace(now)
h.mu.Unlock()
sort.Strings(st.Unsent)
st.Listening = listening
st.TimeZone = h.zone().String()
st.Rules = []string{
"a message reads in one glance: the condition's headline as its title, its explanation, and since when in the operator's time zone; no key, id, commit or command — those stay in the controller's conditions and in this holder's history (novox/hq ADR 0253)",
"a clearance is one line: the condition's resolved line and how long it was open",
"history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message",
"a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message",
"nothing is sent in the first minute after start but that one message",
"urgent: every channel kind that can send; warning: the desk where it can send, otherwise the channel that reaches the operator away",
"the channels are kinds of the channel bench, each a module of its own; which exist and what each promises is the controller's word, in this module's membership; whether each can send is its own (novox/hq ADR 0259)",
"an ask is held here and offered on each kind whose capabilities carry its answers; an answer is judged here and closes the ask with a warrant its asker acts on; nothing is performed here",
"an account linked as the operator acknowledges at once and approves an hour after it was linked; every link and unlink is said on every channel",
"the desk proves nothing about who clicked: it answers acknowledgements only",
"bursts are one message: what arrives within 30 s goes out together, a digest when it is several; urgent goes at once when the channel was quiet for 30 s",
"the desk is gentle: warnings at most one message every 15 min",
"deduplicated by the condition's key; reminded once after 1 h (urgent) or 12 h (warning); cleared by editing the first message",
"at most 20 messages an hour per channel, a last line of defence: reached, it is said once and the rest held for one digest",
"refused: anything carrying an address, a path or a secret; channel-refused is sent with the words withheld",
"silenced conditions send nothing; a silence is the controller's, asked of the operator or set through the mesh",
}
anyCan := false
for _, c := range st.Channels {
anyCan = anyCan || c.CanSend
}
switch {
case !anyCan:
st.Verdict = "CANNOT SEND: no channel can reach the operator — see channels"
case len(st.Unsent) > 0:
st.Verdict = "BEHIND: some messages could not be sent yet and are tried every minute"
case listening != "listening":
st.Verdict = "NOT LISTENING: condition events are not reaching this holder — " + listening
case st.StateProblem != "":
st.Verdict = "STATE: open messages are held in memory only — " + st.StateProblem
default:
st.Verdict = "ok"
for _, c := range st.Channels {
if !c.CanSend {
st.Verdict = "ok on one channel: " + c.Name + " cannot send"
}
}
}
return st
}
// OpenMessage is one open message as `open` answers it.
type OpenMessage struct {
Key string `json:"key"`
Severity string `json:"severity"`
Summary string `json:"summary"`
Subject string `json:"subject,omitempty"`
Since string `json:"since"`
Origin string `json:"origin"`
SentOn []string `json:"sent_on,omitempty"`
Silenced string `json:"silenced_until,omitempty"`
Reminded bool `json:"reminded"`
Unsent string `json:"unsent,omitempty"`
Held bool `json:"held,omitempty"`
History bool `json:"never_said,omitempty"`
Refused string `json:"refused,omitempty"`
Count int `json:"times_opened"`
}
// Open is what is unresolved now, urgent first, oldest first.
func (h *Holder) Open() []OpenMessage {
now := h.Now()
h.mu.Lock()
defer h.mu.Unlock()
queued := map[string]bool{}
for _, list := range h.outbox {
for _, it := range list {
queued[it.Key] = true
}
}
var out []OpenMessage
for _, r := range h.open {
if !r.Cleared.IsZero() {
continue
}
o := OpenMessage{Key: r.Key, Severity: r.Severity, Summary: r.Summary, Subject: r.Subject,
Since: r.Raised.UTC().Format(time.RFC3339), Origin: r.Origin, Reminded: r.Reminded,
Unsent: r.Pending, Held: r.Folded || queued[r.Key], History: r.History, Refused: r.Refused, Count: r.Count}
for ch := range r.Sent {
o.SentOn = append(o.SentOn, ch)
}
sort.Strings(o.SentOn)
if r.silenced(now) {
o.Silenced = r.SilencedTill.UTC().Format(time.RFC3339)
}
out = append(out, o)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Severity != out[j].Severity {
return out[i].Severity == Urgent
}
return out[i].Since < out[j].Since
})
return out
}
// History is what went out, newest first, and the refusals.
func (h *Holder) History(limit int) map[string]any {
h.mu.Lock()
defer h.mu.Unlock()
sends := []Sent{}
for i := len(h.recent) - 1; i >= 0 && len(sends) < limit; i-- {
sends = append(sends, h.recent[i])
}
refusals := []RefusalNote{}
for i := len(h.refusals) - 1; i >= 0 && len(refusals) < limit; i-- {
refusals = append(refusals, h.refusals[i])
}
return map[string]any{"sends": sends, "refusals": refusals}
}
// Test sends a test message on one channel or both, through the content rule and the cap.
func (h *Holder) Test(channel, text string) map[string]string {
h.work.Lock()
defer h.work.Unlock()
if text == "" {
text = "a test from the operator-channel's holder: this channel reaches you"
}
out := map[string]string{}
if refusal, ok := Check(text); !ok {
out["refused"] = "it carried " + refusal.String() + "; nothing was sent"
return out
}
m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."}
for _, ch := range h.kindChannels() {
if channel != "" && channel != "both" && channel != ch.Name() {
continue
}
if err := ch.Ready(); err != nil {
out[ch.Name()] = "not sent: " + err.Error()
continue
}
if !h.allow(ch.Name()) {
out[ch.Name()] = "not sent: the channel is at its cap of 20 an hour"
continue
}
if h.sendNow(ch, "messenger.test", "test", m) {
out[ch.Name()] = "sent"
} else {
h.mu.Lock()
out[ch.Name()] = "failed: " + h.chanErr[ch.Name()]
h.mu.Unlock()
}
}
return out
}
// Notify takes a message from a module that uses the seat (research 028 Q5, modules as sources):
// the same shape, rule, cap and deduplication as a condition. Its key is put under `notify.` so it
// can never be taken for one of the controller's conditions.
func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plain ...string) (map[string]any, error) {
if key == "" {
return nil, errorf("key is required: the same key is the same message")
}
if len(key) < 7 || key[:7] != "notify." {
key = "notify." + key
}
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
h.mu.Unlock()
if clear {
h.cleared(key, false)
return map[string]any{"key": key, "cleared": true}, nil
}
switch severity {
case Urgent, Warning:
case "":
return nil, errorf("severity is required: urgent or warning")
default:
return nil, errorf("severity %q is neither urgent nor warning", severity)
}
if summary == "" {
return nil, errorf("summary is required: one line in the mesh's words")
}
rec := Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary,
Origin: "notify", More: "operator-channel.open"}
// The plain words a module may give (novox/hq ADR 0253): a headline, an explanation, a resolved line.
for i, w := range plain {
switch i {
case 0:
rec.Headline = w
case 1:
rec.Explanation = w
case 2:
rec.Resolved = w
}
}
h.raised(rec, false)
h.mu.Lock()
r := h.open[key]
var sent []string
refused, pending := "", ""
if r != nil {
for ch := range r.Sent {
sent = append(sent, ch)
}
refused, pending = r.Refused, r.Pending
}
h.mu.Unlock()
sort.Strings(sent)
held := h.queued(key)
return map[string]any{"key": key, "sent_on": sent, "held": held, "refused": refused, "unsent": pending}, nil
}