feat: formatted JSON and certificates, errors in the status pane, no scope access list #19
No files matched your search
+15
-8
@@ -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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 := ""
|
||||||
|
|||||||
Reference in new issue
Block a user