package main import ( "context" "regexp" "strings" ) // Event is one thing that happened to the machine's power, as the journal recorded it. type Event struct { At string `json:"at"` What string `json:"what"` Detail string `json:"detail,omitempty"` } var ( sleptLine = regexp.MustCompile(`(?i)(performing sleep operation|entering sleep state|suspending system)`) wokeLine = regexp.MustCompile(`(?i)(returned from sleep|system resumed|resumed from)`) bootsLine = regexp.MustCompile(`^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\S+\s+\S+\s+\S+(?:\s+\S+)?)\s+(?:—|-)\s+(\S+\s+\S+\s+\S+(?:\s+\S+)?)\s*$`) ) // journal reads the system journal: as the account first, as root when the account may not. func (m *Machine) journal(ctx context.Context, args ...string) (string, error) { out, err := m.Run(ctx, "journalctl", args...) if err == nil && strings.TrimSpace(out) != "" { return out, nil } return m.privileged(ctx, "journalctl", args...) } // History is the machine's recent power events, newest last: each boot and its last entry, and every // sleep, wake and moment run the journal recorded in the boots asked for. func (m *Machine) History(ctx context.Context, boots int) (map[string]any, error) { if boots < 1 { boots = 3 } var events []Event list, err := m.journal(ctx, "--list-boots", "--no-pager", "-q") if err != nil { return nil, err } lines := nonEmpty(strings.Split(list, "\n")) if len(lines) > boots { lines = lines[len(lines)-boots:] } for i, l := range lines { f := bootsLine.FindStringSubmatch(l) if f == nil { continue } events = append(events, Event{At: f[3], What: "booted", Detail: "boot " + f[1]}) if i < len(lines)-1 { events = append(events, Event{At: f[4], What: "last entry before the next boot", Detail: "boot " + f[1]}) } got, err := m.journal(ctx, "-b", f[1], "-q", "--no-pager", "-o", "short-iso", "-t", "systemd-sleep", "-t", "mesh-power", "-t", "systemd-logind") if err != nil { continue } for _, e := range strings.Split(got, "\n") { at, rest, ok := splitJournalLine(e) if !ok { continue } switch { case sleptLine.MatchString(rest): events = append(events, Event{At: at, What: "sleeping", Detail: rest}) case wokeLine.MatchString(rest): events = append(events, Event{At: at, What: "woke", Detail: rest}) case strings.Contains(rest, "mesh-power"): events = append(events, Event{At: at, What: "moment", Detail: rest}) case strings.Contains(rest, "Power key pressed") || strings.Contains(rest, "Lid closed") || strings.Contains(rest, "Lid opened") || strings.Contains(rest, "System is powering down") || strings.Contains(rest, "System is rebooting"): events = append(events, Event{At: at, What: "logind", Detail: rest}) } } } if len(events) > 200 { events = events[len(events)-200:] } return map[string]any{"boots": boots, "events": orEmpty(events)}, nil } // splitJournalLine is a short-iso line's time and the rest (unit and message). func splitJournalLine(line string) (string, string, bool) { f := strings.SplitN(strings.TrimSpace(line), " ", 3) if len(f) < 3 || !strings.Contains(f[0], "T") { return "", "", false } return f[0], f[2], true } func nonEmpty(lines []string) []string { var out []string for _, l := range lines { if strings.TrimSpace(l) != "" { out = append(out, l) } } return out }