Skip to content

Commit 0d56523

Browse files
committed
feat: show device lock state in list output
ListResponse now carries structured DeviceInfo with per-device lock state instead of bare device names. The dutctl client renders locked devices with a "[locked by ...]" annotation and adds a lock-result output type for the upcoming lock/unlock commands. Signed-off-by: Fabian Wienand <fabian.wienand@blindspot.software>
1 parent 9c33e80 commit 0d56523

13 files changed

Lines changed: 526 additions & 120 deletions

File tree

cmds/dutagent/rpc.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,27 @@ func (a *rpcService) List(
4444
) (*connect.Response[pb.ListResponse], error) {
4545
log.Println("Server received List request")
4646

47+
locks := a.locker.StatusAll()
48+
49+
names := a.devices.Names()
50+
infos := make([]*pb.DeviceInfo, 0, len(names))
51+
52+
for _, name := range names {
53+
info := &pb.DeviceInfo{Name: name}
54+
55+
if explicit := locks[name].Explicit; explicit != nil {
56+
info.Lock = &pb.LockInfo{
57+
Owner: explicit.Owner,
58+
LockedAt: explicit.LockedAt.Unix(),
59+
ExpiresAt: explicit.ExpiresAt.Unix(),
60+
}
61+
}
62+
63+
infos = append(infos, info)
64+
}
65+
4766
res := connect.NewResponse(&pb.ListResponse{
48-
Devices: a.devices.Names(),
67+
Devices: infos,
4968
})
5069

5170
log.Print("List-RPC finished")

cmds/dutagent/rpc_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"strings"
99
"testing"
10+
"time"
1011

1112
"connectrpc.com/connect"
1213
"github.com/BlindspotSoftware/dutctl/internal/dutagent"
@@ -160,3 +161,57 @@ func TestLockRPCZeroDurationRejected(t *testing.T) {
160161
}
161162
}
162163
}
164+
165+
func TestListRPCHidesAutoOnlyLock(t *testing.T) {
166+
svc := newTestService()
167+
168+
if _, err := svc.locker.AutoLock("devA", "alice"); err != nil {
169+
t.Fatalf("AutoLock: %v", err)
170+
}
171+
172+
res, err := svc.List(context.Background(), connect.NewRequest(&pb.ListRequest{}))
173+
if err != nil {
174+
t.Fatalf("List: %v", err)
175+
}
176+
177+
var got *pb.LockInfo
178+
179+
for _, info := range res.Msg.GetDevices() {
180+
if info.GetName() == "devA" {
181+
got = info.GetLock()
182+
}
183+
}
184+
185+
if got != nil {
186+
t.Errorf("auto-only lock surfaced in List: %+v, want no lock info", got)
187+
}
188+
}
189+
190+
func TestListRPCExplicitShadowsAuto(t *testing.T) {
191+
svc := newTestService()
192+
193+
if _, err := svc.locker.AutoLock("devA", "alice"); err != nil {
194+
t.Fatalf("AutoLock: %v", err)
195+
}
196+
197+
if _, err := svc.locker.Lock("devA", "alice", time.Minute); err != nil {
198+
t.Fatalf("Lock: %v", err)
199+
}
200+
201+
res, err := svc.List(context.Background(), connect.NewRequest(&pb.ListRequest{}))
202+
if err != nil {
203+
t.Fatalf("List: %v", err)
204+
}
205+
206+
var got *pb.LockInfo
207+
208+
for _, info := range res.Msg.GetDevices() {
209+
if info.GetName() == "devA" {
210+
got = info.GetLock()
211+
}
212+
}
213+
214+
if got.GetExpiresAt() == 0 {
215+
t.Error("expected explicit-slot expires_at to win, got 0")
216+
}
217+
}

cmds/dutctl/rpc.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,23 @@ func (app *application) listRPC() error {
3030
return err
3131
}
3232

33+
devices := make([]output.DeviceEntry, 0, len(res.Msg.GetDevices()))
34+
35+
for _, info := range res.Msg.GetDevices() {
36+
entry := output.DeviceEntry{Name: info.GetName()}
37+
38+
if lock := info.GetLock(); lock != nil {
39+
entry.Locked = true
40+
entry.Owner = lock.GetOwner()
41+
entry.ExpiresAt = lock.GetExpiresAt()
42+
}
43+
44+
devices = append(devices, entry)
45+
}
46+
3347
app.formatter.WriteContent(output.Content{
3448
Type: output.TypeDeviceList,
35-
Data: res.Msg.GetDevices(),
49+
Data: devices,
3650
Metadata: map[string]string{
3751
"server": app.serverAddr,
3852
"msg": "List Response",

cmds/exp/dutserver/rpc.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,16 @@ func (s *rpcService) List(
100100
) (*connect.Response[pb.ListResponse], error) {
101101
log.Println("Server received List request")
102102

103+
names := slices.Sorted(maps.Keys(s.agents))
104+
infos := make([]*pb.DeviceInfo, 0, len(names))
105+
106+
// dutserver does not track lock state; Lock is left unset.
107+
for _, name := range names {
108+
infos = append(infos, &pb.DeviceInfo{Name: name})
109+
}
110+
103111
res := connect.NewResponse(&pb.ListResponse{
104-
Devices: slices.Sorted(maps.Keys(s.agents)),
112+
Devices: infos,
105113
})
106114

107115
log.Print("List-RPC finished")

internal/output/json_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func TestJSONFormatter(t *testing.T) {
2929
// Test case 2: Output with metadata
3030
formatter.WriteContent(Content{
3131
Type: TypeDeviceList,
32-
Data: []string{"device1", "device2", "device3"},
32+
Data: []DeviceEntry{{Name: "device1"}, {Name: "device2"}, {Name: "device3"}},
3333
Metadata: map[string]string{
3434
"server": "localhost:1024",
3535
"device": "test-device",

internal/output/oneline.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,31 @@ func formatDataValue(data interface{}, separator string) string {
112112
return formatQuotedString(joined, separator)
113113
case []byte:
114114
return formatQuotedString(string(dataValue), separator)
115+
case []DeviceEntry:
116+
entries := make([]string, 0, len(dataValue))
117+
for _, d := range dataValue {
118+
entries = append(entries, deviceEntryString(d))
119+
}
120+
121+
return formatQuotedString(strings.Join(entries, "|"), separator)
122+
case DeviceEntry:
123+
return formatQuotedString(deviceEntryString(dataValue), separator)
115124
default:
116125
// Convert anything else to string
117126
return formatQuotedString(fmt.Sprintf("%v", dataValue), separator)
118127
}
119128
}
120129

130+
// deviceEntryString renders a DeviceEntry as a compact "name" or
131+
// "name=locked:owner" token for single-line output.
132+
func deviceEntryString(d DeviceEntry) string {
133+
if !d.Locked {
134+
return d.Name
135+
}
136+
137+
return fmt.Sprintf("%s=locked:%s", d.Name, d.Owner)
138+
}
139+
121140
// output writes the formatted line to the appropriate destination.
122141
func (f *OneLineFormatter) output(line string, isError bool) {
123142
if f.buffering {

internal/output/oneline_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestOneLineFormatter(t *testing.T) {
2828
// Test case 2: Output with metadata
2929
formatter.WriteContent(Content{
3030
Type: TypeDeviceList,
31-
Data: []string{"device1", "device2", "device3"},
31+
Data: []DeviceEntry{{Name: "device1"}, {Name: "device2"}, {Name: "device3"}},
3232
Metadata: map[string]string{
3333
"server": "localhost:1024",
3434
"device": "test-device",

internal/output/output.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,19 @@ const (
3030

3131
// TypeVersion represents version information.
3232
TypeVersion ContentType = "version"
33+
34+
// TypeLockResult represents the result of a lock or unlock operation.
35+
TypeLockResult ContentType = "lock-result"
3336
)
3437

38+
// DeviceEntry describes a device and its lock state for TypeDeviceList output.
39+
type DeviceEntry struct {
40+
Name string
41+
Locked bool
42+
Owner string
43+
ExpiresAt int64 // Unix seconds, 0 means no expiry.
44+
}
45+
3546
// Content is a structured data unit to be formatted and displayed.
3647
type Content struct {
3748
// Type identifies the category of this content.

internal/output/text.go

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"slices"
1313
"strings"
14+
"time"
1415
)
1516

1617
// TextFormatter implements Formatter with plain text formatting capabilities.
@@ -53,9 +54,9 @@ func newTextFormatter(config Config) *TextFormatter {
5354
}
5455
}
5556

56-
// WriteContent formats and outputs structured content.
57-
func (f *TextFormatter) WriteContent(content Content) {
58-
// Get appropriate writer based on buffering mode and error state
57+
// selectWriter returns the writer for content based on buffering mode and
58+
// error state.
59+
func (f *TextFormatter) selectWriter(content Content) io.Writer {
5960
var writer io.Writer
6061

6162
if f.buffering {
@@ -72,6 +73,13 @@ func (f *TextFormatter) WriteContent(content Content) {
7273
}
7374
}
7475

76+
return writer
77+
}
78+
79+
// WriteContent formats and outputs structured content.
80+
func (f *TextFormatter) WriteContent(content Content) {
81+
writer := f.selectWriter(content)
82+
7583
// Format and write content based on type, regardless of error state
7684
switch content.Type {
7785
case TypeDeviceList:
@@ -84,6 +92,8 @@ func (f *TextFormatter) WriteContent(content Content) {
8492
f.writeDetailTo(content, writer)
8593
case TypeModuleOutput:
8694
f.writeModuleOutputTo(content, writer)
95+
case TypeLockResult:
96+
f.writeLockResultTo(content, writer)
8797
default:
8898
// For general text or unrecognized types
8999
f.writeGeneralTo(content, writer)
@@ -148,17 +158,79 @@ func (f *TextFormatter) Flush() error {
148158

149159
// Helper methods for different content types
150160

161+
// humanDuration renders dur as a compact "1h30m"-style string, rounded to the
162+
// minute. A non-positive duration renders as "0m".
163+
func humanDuration(dur time.Duration) string {
164+
dur = dur.Round(time.Minute)
165+
if dur <= 0 {
166+
return "0m"
167+
}
168+
169+
hours := dur / time.Hour
170+
minutes := (dur % time.Hour) / time.Minute
171+
172+
switch {
173+
case hours > 0 && minutes > 0:
174+
return fmt.Sprintf("%dh%dm", hours, minutes)
175+
case hours > 0:
176+
return fmt.Sprintf("%dh", hours)
177+
default:
178+
return fmt.Sprintf("%dm", minutes)
179+
}
180+
}
181+
182+
// lockAnnotation renders the bracketed lock note for a locked device, e.g.
183+
// ` [locked by "alice@host" for 25m]`. ExpiresAt of 0 omits the duration.
184+
func lockAnnotation(entry DeviceEntry) string {
185+
if entry.ExpiresAt == 0 {
186+
return fmt.Sprintf(" [locked by %q]", entry.Owner)
187+
}
188+
189+
remaining := humanDuration(time.Until(time.Unix(entry.ExpiresAt, 0)))
190+
191+
return fmt.Sprintf(" [locked by %q for %s]", entry.Owner, remaining)
192+
}
193+
151194
// writeDeviceListTo formats and writes a list of devices with bullet points.
152195
func (f *TextFormatter) writeDeviceListTo(content Content, writer io.Writer) {
153-
if devices, ok := content.Data.([]string); ok {
154-
// Print metadata before content
155-
f.writeMetadata(content, writer)
196+
devices, ok := content.Data.([]DeviceEntry)
197+
if !ok {
198+
f.writeGeneralTo(content, writer)
199+
200+
return
201+
}
156202

157-
for _, device := range devices {
158-
fmt.Fprintf(writer, "- %s\n", device)
203+
// Print metadata before content
204+
f.writeMetadata(content, writer)
205+
206+
for _, device := range devices {
207+
if device.Locked {
208+
fmt.Fprintf(writer, "- %s%s\n", device.Name, lockAnnotation(device))
209+
} else {
210+
fmt.Fprintf(writer, "- %s\n", device.Name)
159211
}
160-
} else {
212+
}
213+
}
214+
215+
// writeLockResultTo formats and writes the result of a lock or unlock operation.
216+
func (f *TextFormatter) writeLockResultTo(content Content, writer io.Writer) {
217+
entry, ok := content.Data.(DeviceEntry)
218+
if !ok {
161219
f.writeGeneralTo(content, writer)
220+
221+
return
222+
}
223+
224+
f.writeMetadata(content, writer)
225+
226+
switch {
227+
case !entry.Locked:
228+
fmt.Fprintf(writer, "Device %q unlocked\n", entry.Name)
229+
case entry.ExpiresAt == 0:
230+
fmt.Fprintf(writer, "Device %q locked by %q\n", entry.Name, entry.Owner)
231+
default:
232+
remaining := humanDuration(time.Until(time.Unix(entry.ExpiresAt, 0)))
233+
fmt.Fprintf(writer, "Device %q locked by %q for %s\n", entry.Name, entry.Owner, remaining)
162234
}
163235
}
164236

0 commit comments

Comments
 (0)