Skip to content

Commit 0821d03

Browse files
authored
feat: add multi-host machine filtering and labels (#227)
## Summary - add true multi-select machine filtering end to end so the machine filter supports 1..N selected hosts - render each session's machine/hostname as a compact secondary line under the agent area in the sidebar - make machine filter selected state explicit with the same checkbox-style treatment used for multi-select filters ## Test Plan - [x] `cd frontend && npm run check` - [x] `cd frontend && npm test` - [x] `CC=gcc go test ./internal/db -run TestSessionFilterMachineMultiSelect -count=1` - [x] `CC=gcc go test -tags pgtest ./internal/postgres -run TestStoreListSessions_MachineMultiSelect -count=1` - [x] manual PG-backed review of the PR build against the real multi-host dataset (`dgx`, `nv1`, `spark`) via `./agentsview pg serve -host 127.0.0.1 -port 18081` Closes #220.
1 parent 4b1d382 commit 0821d03

File tree

9 files changed

+314
-24
lines changed

9 files changed

+314
-24
lines changed

frontend/src/lib/api/client.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,14 @@ describe("query serialization", () => {
568568
},
569569
expected: "/api/v1/sessions",
570570
},
571+
{
572+
name: "preserves comma-separated machine filters",
573+
params: {
574+
machine: "host-a,host-b,host-c",
575+
},
576+
expected:
577+
"/api/v1/sessions?machine=host-a%2Chost-b%2Chost-c",
578+
},
571579
];
572580

573581
for (const { name, params, expected } of cases) {

frontend/src/lib/components/sidebar/SessionItem.svelte

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@
6161
getAgentColor(session.agent),
6262
);
6363
64+
let showMachine = $derived(
65+
!compact &&
66+
!!session.machine &&
67+
session.machine !== "local",
68+
);
69+
6470
/** Whether this session is a team member (received a <teammate-message>). */
6571
let isTeamSession = $derived(
6672
session.first_message?.includes("<teammate-message") ?? false,
@@ -325,8 +331,17 @@
325331
{/if}
326332
</button>
327333
{/if}
328-
{#if !hideAgent && !compact}
329-
<span class="agent-tag" style:color={agentColor}>{session.agent}</span>
334+
{#if !compact && (!hideAgent || showMachine)}
335+
<div class="side-meta">
336+
{#if !hideAgent}
337+
<span class="agent-tag" style:color={agentColor}>{session.agent}</span>
338+
{/if}
339+
{#if showMachine}
340+
<span class="machine-tag" title={session.machine}>
341+
{truncate(session.machine, 18)}
342+
</span>
343+
{/if}
344+
</div>
330345
{/if}
331346
</div>
332347

@@ -449,9 +464,18 @@
449464
}
450465
}
451466
467+
.side-meta {
468+
display: flex;
469+
flex-direction: column;
470+
align-items: flex-end;
471+
gap: 3px;
472+
min-width: 0;
473+
flex-shrink: 0;
474+
margin-left: 4px;
475+
}
476+
452477
/* Agent tag on the right side */
453478
.agent-tag {
454-
flex-shrink: 0;
455479
font-size: 8px;
456480
font-weight: 600;
457481
text-transform: uppercase;
@@ -464,6 +488,17 @@
464488
text-overflow: ellipsis;
465489
}
466490
491+
.machine-tag {
492+
font-size: 9px;
493+
line-height: 1;
494+
color: var(--text-muted);
495+
opacity: 0.9;
496+
white-space: nowrap;
497+
max-width: 74px;
498+
overflow: hidden;
499+
text-overflow: ellipsis;
500+
}
501+
467502
.session-info {
468503
min-width: 0;
469504
flex: 1;

frontend/src/lib/components/sidebar/SessionList.svelte

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -499,17 +499,23 @@
499499
{#each sortedAgents as agent (agent.name)}
500500
{@const selected =
501501
sessions.isAgentSelected(agent.name)}
502-
<button
503-
class="agent-select-row"
504-
class:selected
505-
style:--agent-color={agentColor(agent.name)}
506-
onclick={() =>
507-
sessions.toggleAgentFilter(agent.name)}
508-
>
509-
<span
510-
class="agent-check"
511-
class:on={selected}
512-
></span>
502+
<button
503+
class="agent-select-row"
504+
class:selected
505+
style:--agent-color={agentColor(agent.name)}
506+
onclick={() =>
507+
sessions.toggleAgentFilter(agent.name)}
508+
>
509+
<span
510+
class="agent-check"
511+
class:on={selected}
512+
>
513+
{#if selected}
514+
<svg width="8" height="8" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
515+
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
516+
</svg>
517+
{/if}
518+
</span>
513519
<span
514520
class="agent-dot-mini"
515521
style:background={agentColor(agent.name)}
@@ -542,17 +548,24 @@
542548
<div class="agent-select-list">
543549
{#each sortedMachines as machine (machine)}
544550
{@const selected =
545-
sessions.filters.machine === machine}
551+
sessions.isMachineSelected(machine)}
546552
<button
547553
class="agent-select-row"
548554
class:selected
555+
style:--agent-color={"var(--accent-blue)"}
549556
onclick={() =>
550-
sessions.setMachineFilter(machine)}
557+
sessions.toggleMachineFilter(machine)}
551558
>
552559
<span
553560
class="agent-check"
554561
class:on={selected}
555-
></span>
562+
>
563+
{#if selected}
564+
<svg width="8" height="8" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
565+
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
566+
</svg>
567+
{/if}
568+
</span>
556569
<span class="agent-select-name">
557570
{machine}
558571
</span>
@@ -912,8 +925,13 @@
912925
}
913926
914927
.agent-select-row.selected {
915-
color: var(--agent-color);
928+
color: var(--agent-color, var(--accent-blue));
916929
font-weight: 500;
930+
background: color-mix(
931+
in srgb,
932+
var(--agent-color, var(--accent-blue)) 10%,
933+
transparent
934+
);
917935
}
918936
919937
.agent-check {
@@ -923,11 +941,15 @@
923941
border: 1.5px solid var(--border-default);
924942
flex-shrink: 0;
925943
transition: background 0.1s, border-color 0.1s;
944+
color: white;
945+
display: flex;
946+
align-items: center;
947+
justify-content: center;
926948
}
927949
928950
.agent-check.on {
929-
background: var(--agent-color);
930-
border-color: var(--agent-color);
951+
background: var(--agent-color, var(--accent-blue));
952+
border-color: var(--agent-color, var(--accent-blue));
931953
}
932954
933955
.agent-dot-mini {

frontend/src/lib/stores/sessions.svelte.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,31 @@ class SessionsStore {
470470
this.load();
471471
}
472472

473+
toggleMachineFilter(machine: string) {
474+
const current = this.filters.machine
475+
? this.filters.machine.split(",")
476+
: [];
477+
const idx = current.indexOf(machine);
478+
if (idx >= 0) {
479+
current.splice(idx, 1);
480+
} else {
481+
current.push(machine);
482+
}
483+
this.filters.machine = current.join(",");
484+
this.setActiveSession(null);
485+
this.load();
486+
}
487+
488+
isMachineSelected(machine: string): boolean {
489+
if (!this.filters.machine) return false;
490+
return this.filters.machine.split(",").includes(machine);
491+
}
492+
493+
get selectedMachines(): string[] {
494+
if (!this.filters.machine) return [];
495+
return this.filters.machine.split(",");
496+
}
497+
473498
setAgentFilter(agent: string) {
474499
if (this.filters.agent === agent) {
475500
this.filters.agent = "";

frontend/src/lib/stores/sessions.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,11 @@ describe("SessionsStore", () => {
487487
expect(sessions.hasActiveFilters).toBe(false);
488488
});
489489

490+
it("should be true when machine filter is set", () => {
491+
sessions.filters.machine = "host-a";
492+
expect(sessions.hasActiveFilters).toBe(true);
493+
});
494+
490495
it("should be true when agent filter is set", () => {
491496
sessions.filters.agent = "claude";
492497
expect(sessions.hasActiveFilters).toBe(true);
@@ -529,6 +534,69 @@ describe("SessionsStore", () => {
529534
});
530535
});
531536

537+
describe("machine filter", () => {
538+
it("should toggle one machine on and serialize it", async () => {
539+
sessions.toggleMachineFilter("host-a");
540+
await vi.waitFor(() => {
541+
expect(api.listSessions).toHaveBeenCalled();
542+
});
543+
544+
expect(sessions.filters.machine).toBe("host-a");
545+
expect(sessions.selectedMachines).toEqual(["host-a"]);
546+
expect(sessions.isMachineSelected("host-a")).toBe(true);
547+
expectListSessionsCalledWith({ machine: "host-a" });
548+
});
549+
550+
it("should allow multiple selected machines", async () => {
551+
sessions.toggleMachineFilter("host-a");
552+
await vi.waitFor(() => {
553+
expect(api.listSessions).toHaveBeenCalledTimes(1);
554+
});
555+
556+
sessions.toggleMachineFilter("host-b");
557+
await vi.waitFor(() => {
558+
expect(api.listSessions).toHaveBeenCalledTimes(2);
559+
});
560+
561+
expect(sessions.filters.machine).toBe("host-a,host-b");
562+
expect(sessions.selectedMachines).toEqual([
563+
"host-a",
564+
"host-b",
565+
]);
566+
expect(sessions.isMachineSelected("host-b")).toBe(true);
567+
expectListSessionsCalledWith({
568+
machine: "host-a,host-b",
569+
});
570+
});
571+
572+
it("should toggle an already-selected machine off", async () => {
573+
sessions.filters.machine = "host-a,host-b";
574+
575+
sessions.toggleMachineFilter("host-a");
576+
await vi.waitFor(() => {
577+
expect(api.listSessions).toHaveBeenCalled();
578+
});
579+
580+
expect(sessions.filters.machine).toBe("host-b");
581+
expect(sessions.selectedMachines).toEqual(["host-b"]);
582+
expect(sessions.isMachineSelected("host-a")).toBe(false);
583+
expectListSessionsCalledWith({ machine: "host-b" });
584+
});
585+
586+
it("should clear the filter when the last machine is removed", async () => {
587+
sessions.filters.machine = "host-a";
588+
589+
sessions.toggleMachineFilter("host-a");
590+
await vi.waitFor(() => {
591+
expect(api.listSessions).toHaveBeenCalled();
592+
});
593+
594+
expect(sessions.filters.machine).toBe("");
595+
expect(sessions.selectedMachines).toEqual([]);
596+
expectListSessionsCalledWith({ machine: undefined });
597+
});
598+
});
599+
532600
describe("navigateSession", () => {
533601
function seedSessions(store: typeof sessions) {
534602
store.sessions = [

internal/db/filter_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,51 @@ func TestSessionFilterExcludeProject(t *testing.T) {
237237
}
238238
}
239239

240+
func TestSessionFilterMachineMultiSelect(t *testing.T) {
241+
d := testDB(t)
242+
243+
insertSession(t, d, "laptop", "proj", func(s *Session) {
244+
s.Machine = "laptop"
245+
s.MessageCount = 5
246+
})
247+
insertSession(t, d, "desktop", "proj", func(s *Session) {
248+
s.Machine = "desktop"
249+
s.MessageCount = 5
250+
})
251+
insertSession(t, d, "server", "proj", func(s *Session) {
252+
s.Machine = "server"
253+
s.MessageCount = 5
254+
})
255+
256+
tests := []struct {
257+
name string
258+
filter SessionFilter
259+
want []string
260+
}{
261+
{
262+
name: "SingleMachine",
263+
filter: SessionFilter{Machine: "laptop"},
264+
want: []string{"laptop"},
265+
},
266+
{
267+
name: "MultipleMachines",
268+
filter: SessionFilter{Machine: "laptop,server"},
269+
want: []string{"laptop", "server"},
270+
},
271+
{
272+
name: "UnknownMachineIgnored",
273+
filter: SessionFilter{Machine: "desktop,unknown"},
274+
want: []string{"desktop"},
275+
},
276+
}
277+
278+
for _, tt := range tests {
279+
t.Run(tt.name, func(t *testing.T) {
280+
requireSessions(t, d, tt.filter, tt.want)
281+
})
282+
}
283+
}
284+
240285
func TestListSessionsExcludesRelationshipTypes(t *testing.T) {
241286
d := testDB(t)
242287

internal/db/sessions.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,23 @@ func buildSessionFilter(f SessionFilter) (string, []any) {
232232
filterArgs = append(filterArgs, f.ExcludeProject)
233233
}
234234
if f.Machine != "" {
235-
filterPreds = append(filterPreds, "machine = ?")
236-
filterArgs = append(filterArgs, f.Machine)
235+
machines := strings.Split(f.Machine, ",")
236+
if len(machines) == 1 {
237+
filterPreds = append(filterPreds, "machine = ?")
238+
filterArgs = append(filterArgs, machines[0])
239+
} else {
240+
placeholders := make(
241+
[]string, len(machines),
242+
)
243+
for i, m := range machines {
244+
placeholders[i] = "?"
245+
filterArgs = append(filterArgs, m)
246+
}
247+
filterPreds = append(filterPreds,
248+
"machine IN ("+
249+
strings.Join(placeholders, ",")+
250+
")")
251+
}
237252
}
238253
if f.Agent != "" {
239254
agents := strings.Split(f.Agent, ",")

internal/postgres/sessions.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,21 @@ func buildPGSessionFilter(
129129
"project != "+pb.add(f.ExcludeProject))
130130
}
131131
if f.Machine != "" {
132-
filterPreds = append(filterPreds,
133-
"machine = "+pb.add(f.Machine))
132+
machines := strings.Split(f.Machine, ",")
133+
if len(machines) == 1 {
134+
filterPreds = append(filterPreds,
135+
"machine = "+pb.add(machines[0]))
136+
} else {
137+
placeholders := make([]string, len(machines))
138+
for i, m := range machines {
139+
placeholders[i] = pb.add(m)
140+
}
141+
filterPreds = append(filterPreds,
142+
"machine IN ("+
143+
strings.Join(placeholders, ",")+
144+
")",
145+
)
146+
}
134147
}
135148
if f.Agent != "" {
136149
agents := strings.Split(f.Agent, ",")

0 commit comments

Comments
 (0)