feat: improve machine navigation controls

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 10:22:02 +02:00
parent 753b8bbc8a
commit ccd4a766d9
6 changed files with 182 additions and 32 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Hide the Machines navigation section when only one machine is configured, align Machines list spacing with the other navigation sections, and add a remove action to remote machine rows.
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import type { Machine } from "../api";
import { canRemoveMachine } from "./MachineList";
describe("canRemoveMachine", () => {
it("only allows remote machines to be removed from the machine list", () => {
expect(canRemoveMachine(machine("local", "local"))).toBe(false);
expect(canRemoveMachine(machine("remote-a", "remote"))).toBe(true);
});
});
function machine(id: string, kind: Machine["kind"]): Machine {
return {
id,
name: id,
kind,
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
};
}
+113 -20
View File
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth } from "../api";
import { actionMenuPanelStyle } from "./actionMenu";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -12,33 +13,86 @@ export class MachineList extends LitElement {
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onToggleCollapsed?: () => void;
@state() private openMenuMachineId: string | undefined;
@state() private menuStyle = "";
private readonly onDocumentClick = (event: MouseEvent) => {
if (event.composedPath().includes(this)) return;
this.openMenuMachineId = undefined;
};
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener("click", this.onDocumentClick);
}
override disconnectedCallback(): void {
document.removeEventListener("click", this.onDocumentClick);
super.disconnectedCallback();
}
protected override updated(changed: PropertyValues<this>): void {
if (changed.has("machines") && this.openMenuMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openMenuMachineId)) this.openMenuMachineId = undefined;
if (changed.has("collapsed") && this.collapsed) this.openMenuMachineId = undefined;
}
override render() {
return html`
<section>
<h2>${this.renderHeading()}</h2>
${this.collapsed ? null : this.machines.map((machine) => {
const status = this.statuses[machine.id]?.status ?? machine.status ?? "unknown";
const statusLabel = status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown";
return html`
<div
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
tabindex="0"
title=${machine.baseUrl ?? machine.name}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
>
<div class="action-main">
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
</div>
</div>
`;
})}
${this.collapsed ? null : html`
<div class="list-body">
${this.machines.map((machine) => this.renderMachine(machine))}
</div>
`}
</section>
`;
}
private renderMachine(machine: Machine) {
const status = this.statuses[machine.id]?.status ?? machine.status ?? "unknown";
const statusLabel = status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown";
const hasRemoveAction = canRemoveMachine(machine) && this.onRemove !== undefined;
return html`
<div
class=${`action-row machine-row ${this.selected?.id === machine.id ? "selected" : ""} ${hasRemoveAction ? "" : "no-actions"}`}
tabindex="0"
title=${machine.baseUrl ?? machine.name}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
>
<div class="action-main">
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
</div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
</div>
`;
}
private renderMachineMenu(machine: Machine) {
const open = this.openMenuMachineId === machine.id;
const menuId = machineMenuId(machine.id);
return html`
<div class="action-menu">
<button
class="action-menu-toggle"
title="Machine actions"
aria-label=${`Actions for ${machine.name}`}
aria-expanded=${String(open)}
aria-controls=${menuId}
@click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(machine.id, event.currentTarget); }}
>⋯</button>
${open ? html`
<div class="action-menu-panel machine-menu-panel" id=${menuId} style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
<button class="danger" title=${`Remove ${machine.name}`} @click=${() => { this.removeMachine(machine); }}>Remove</button>
</div>
` : null}
</div>
`;
}
private renderHeading() {
if (!this.collapsible) return "Machines";
const selectedSummary = this.selected?.name ?? "No machine selected";
@@ -46,5 +100,44 @@ export class MachineList extends LitElement {
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
}
static override styles = listStyles;
private toggleMenu(machineId: string, target: EventTarget | null): void {
if (this.openMenuMachineId === machineId) {
this.openMenuMachineId = undefined;
return;
}
this.menuStyle = actionMenuPanelStyle(target);
this.openMenuMachineId = machineId;
}
private removeMachine(machine: Machine): void {
this.openMenuMachineId = undefined;
void this.onRemove?.(machine);
}
private handleMachineKeydown(event: KeyboardEvent, machine: Machine): void {
if (event.key === "Escape" && this.openMenuMachineId === machine.id) {
event.preventDefault();
event.stopPropagation();
this.openMenuMachineId = undefined;
return;
}
activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine));
}
static override styles = [
listStyles,
css`
.machine-row.no-actions .action-main { border-radius: 8px; }
.machine-menu-panel button.danger { color: var(--pi-danger); }
.machine-menu-panel button.danger:hover, .machine-menu-panel button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
`,
];
}
export function canRemoveMachine(machine: Machine): boolean {
return machine.kind === "remote";
}
function machineMenuId(machineId: string): string {
return `machine-menu-${machineId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
+3 -3
View File
@@ -595,6 +595,7 @@ export class PiWebApp extends LitElement {
this.mobileNavigation.expand("projects");
await this.machines.selectMachine(machine);
})}
.onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }}
.projects=${this.state.projects}
.selectedProject=${this.state.selectedProject}
.workspaceActivities=${this.state.workspaceActivities}
@@ -761,7 +762,7 @@ export class PiWebApp extends LitElement {
addProject: () => { this.setState({ projectDialogOpen: true }); },
addMachine: () => this.addMachineFromPrompt(),
refreshSelectedMachine: () => this.machines.refreshMachineHealth(),
removeSelectedMachine: () => this.removeSelectedMachine(),
removeSelectedMachine: () => this.removeMachine(),
openSelectedMachine: () => { this.openSelectedMachine(); },
configureAuth: () => this.auth.openLogin(),
logoutAuth: () => this.auth.openLogout(),
@@ -901,8 +902,7 @@ export class PiWebApp extends LitElement {
await this.machines.addMachine({ name, baseUrl, ...(token === undefined || token === "" ? {} : { token }) });
}
private async removeSelectedMachine(): Promise<void> {
const machine = this.state.selectedMachine;
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
if (machine === undefined || machine.kind === "local") return;
if (!window.confirm(`Remove ${machine.name}?\n\nThis only removes it from this PI WEB gateway.`)) return;
await this.machines.deleteMachine(machine);
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import type { Machine } from "../../api";
import { shouldShowMachinesSection } from "./AppNavigationPanel";
describe("shouldShowMachinesSection", () => {
it("hides the machines section when there is no machine choice", () => {
expect(shouldShowMachinesSection([])).toBe(false);
expect(shouldShowMachinesSection([machine("local")])).toBe(false);
});
it("shows the machines section when there are multiple machines", () => {
expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true);
});
});
function machine(id: string): Machine {
return {
id,
name: id,
kind: id === "local" ? "local" : "remote",
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
};
}
@@ -49,6 +49,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
override render() {
return html`
@@ -59,15 +60,18 @@ export class AppNavigationPanel extends LitElement {
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
</div>
</header>
<machine-list
.machines=${this.machines}
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
></machine-list>
${shouldShowMachinesSection(this.machines) ? html`
<machine-list
.machines=${this.machines}
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
></machine-list>
` : null}
<project-list
.projects=${this.projects}
.selected=${this.selectedProject}
@@ -130,3 +134,7 @@ export class AppNavigationPanel extends LitElement {
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
`;
}
export function shouldShowMachinesSection(machines: readonly Machine[]): boolean {
return machines.length > 1;
}