feat: formatted JSON and certificates, errors in the status pane, no scope access list #19

Merged
keb merged 3 commits from feat/views-errors-ssh into main 2026-10-11 21:16:32 +02:00
6 changed files with 117 additions and 10 deletions
Showing only changes of commit b8cd217e24 - Show all commits

No files matched your search

+15 -8
View File
@@ -49,7 +49,7 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `space` | reveal / hide the selected value (public material for keys and certificates) |
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group | | `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
@@ -60,6 +60,11 @@ match case exactly.
| `L` | sign in again (shown when a login has expired) | | `L` | sign in again (shown when a login has expired) |
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one | | `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored.
A revealed value hides again after `revealTimeout` (60s) and when the A revealed value hides again after `revealTimeout` (60s) and when the
terminal loses focus (`hideOnBlur`); see [config](config.md). terminal loses focus (`hideOnBlur`); see [config](config.md).
@@ -114,13 +119,15 @@ it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing. Focused, the right pane lists more under the your accounts are doing. Focused, the right pane lists more under the
logo (when there's room): the version logo (when there's room): the version and updates (`U`), each account
and updates (`U`), each account (`L` on one that needs it signs in), the (`L` on one that needs it signs in), what's expiring (`enter` goes to
config, state and cache files (`e` opens one in your editor), and links it), this session's errors, newest first (what a pane showed, or the
to the docs, source, issues, releases and changelog (`o` opens one; on footer flashed before you moved on; not kept after quitting), the config,
the version or update, its release), then the changelog of state and cache files (`e` opens one in your editor), and links to the
every release up to yours, built in so it needs no network: `/` filters docs, source, issues, releases and changelog (`o` opens one; on the
it (`/fixed`, `/search`). `y` copies any row. version or update, its release), then the changelog of every release up
to yours, built in so it needs no network: `/` filters it (`/fixed`,
`/search`). `y` copies any row.
| Key | Action | | Key | Action |
|---|---| |---|---|
+14
View File
@@ -177,6 +177,10 @@ type Model struct {
fields panes.Fields fields panes.Fields
lastLeft pane lastLeft pane
details map[string]detailsState // by containerKey details map[string]detailsState // by containerKey
// errLog is this session's errors, newest first; loggedFlash the
// footer's text when last looked at (errlog.go).
errLog []loggedErr
loggedFlash string
getenv func(string) string getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time now func() time.Time
@@ -623,6 +627,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotContainers) m.done(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
delete(m.cache.containers, msg.key) delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err) m.containers.SetError(msg.err)
} else { } else {
@@ -641,6 +648,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.closeVersions() m.closeVersions()
} else if msg.err != nil { } else if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
delete(m.cache.objects, msg.key) delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err) m.secrets.SetError(msg.err)
} else { } else {
@@ -663,6 +673,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotValue) m.done(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetValueError(msg.err) m.detail.SetValueError(msg.err)
} else { } else {
m.detail.SetValue(msg.value) m.detail.SetValue(msg.value)
@@ -689,6 +700,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotDescribe) m.done(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetDescribeError(msg.err) m.detail.SetDescribeError(msg.err)
} else { } else {
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
@@ -759,6 +771,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.focus != paneDetail { if m.focus != paneDetail {
m.lastLeft = m.focus m.lastLeft = m.focus
} }
m.logFlash()
cmds = append(cmds, m.syncFields()) cmds = append(cmds, m.syncFields())
if m.anyLoading() && !m.spinning { if m.anyLoading() && !m.spinning {
m.spinning = true m.spinning = true
@@ -1353,6 +1366,7 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil { if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
m.noteErr(r.err) m.noteErr(r.err)
m.logErrOf(p.Label(), r.err)
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err)) errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
} }
} }
+22
View File
@@ -1300,3 +1300,25 @@ func TestExpiringInStatus(t *testing.T) {
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen()) t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
} }
} }
func TestErrorLog(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, fake.Azure(0))
d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed
d.m.flash = flash{text: "copy failed: no clipboard", isErr: true}
d.send(nil)
d.send(nil) // still showing: logged once
d.keys("0")
scr := d.screen()
if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") {
t.Errorf("errors not listed:\n%s", scr)
}
if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") {
t.Errorf("log %+v", d.m.errLog)
}
d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top
d.m.logErr(d.m.errLog[2].text)
if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" {
t.Errorf("log %+v", d.m.errLog)
}
}
+64
View File
@@ -0,0 +1,64 @@
package app
import (
"errors"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists this session's errors, newest first: what a pane
// showed (a list that couldn't load) and what the footer flashed (a copy
// that failed), which go when you move on. Only the first line of each,
// and never kept after quitting.
// maxErrors is how many errors are kept.
const maxErrors = 20
type loggedErr struct {
at time.Time
text string
}
// logErr records text; an error already there moves to the top, with
// the time it happened again.
func (m *Model) logErr(text string) {
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
if text == "" {
return
}
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
}
// logErrOf records err, about what, except a sign-in, which the footer
// and the account's details say.
func (m *Model) logErrOf(what string, err error) {
var auth *provider.AuthError
if !errors.As(err, &auth) {
m.logErr(what + ": " + err.Error())
}
}
// logFlash records an error the footer is showing, once.
func (m *Model) logFlash() {
if m.flash.isErr && m.flash.text != m.loggedFlash {
m.logErr(m.flash.text)
}
m.loggedFlash = m.flash.text
}
// errorFields are the status pane's rows for them.
func (m Model) errorFields() []panes.Field {
out := make([]panes.Field, len(m.errLog))
for i, e := range m.errLog {
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
}
if len(out) > 0 {
out[0].Label = "errors"
}
return out
}
+1 -2
View File
@@ -3,7 +3,6 @@ package app
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -244,6 +243,6 @@ func (m *Model) restorePlace() {
func (m *Model) handlePersisted(msg persistedMsg) { func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil { if msg.err != nil {
m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err)) m.logErrOf("saving the cache or place", msg.err)
} }
} }
+1
View File
@@ -102,6 +102,7 @@ func (m Model) statusFields() []panes.Field {
} }
out = append(out, m.expiringFields()...) out = append(out, m.expiringFields()...)
out = append(out, m.errorFields()...)
for i, path := range m.configFiles { for i, path := range m.configFiles {
label := "" label := ""