go-process/ui/src/process-list.ts

434 lines
11 KiB
TypeScript

// SPDX-Licence-Identifier: EUPL-1.2
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
import { ProcessApi, type ProcessInfo } from './shared/api.js';
/**
* <core-process-list> — Running processes with status and actions.
*
* Displays managed processes from the process service. Shows status badges,
* uptime, exit codes, and provides kill/select actions.
*
* Emits `process-selected` event when a process row is clicked, carrying
* the process ID for the output viewer.
*
* The list is seeded from the REST API and then kept in sync with the live
* process event stream when a WebSocket URL is configured.
*/
@customElement('core-process-list')
export class ProcessList extends LitElement {
static styles = css`
:host {
display: block;
font-family: system-ui, -apple-system, sans-serif;
}
.list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.item {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: box-shadow 0.15s, border-colour 0.15s;
}
.item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.item.selected {
border-colour: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.item-info {
flex: 1;
}
.item-command {
font-weight: 600;
font-size: 0.9375rem;
font-family: monospace;
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-meta {
font-size: 0.75rem;
colour: #6b7280;
margin-top: 0.25rem;
display: flex;
gap: 1rem;
}
.status-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-weight: 600;
}
.status-badge.running {
background: #dbeafe;
colour: #1e40af;
}
.status-badge.pending {
background: #fef3c7;
colour: #92400e;
}
.status-badge.exited {
background: #dcfce7;
colour: #166534;
}
.status-badge.failed {
background: #fef2f2;
colour: #991b1b;
}
.status-badge.killed {
background: #fce7f3;
colour: #9d174d;
}
.exit-code {
font-family: monospace;
font-size: 0.6875rem;
background: #f3f4f6;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
}
.exit-code.nonzero {
background: #fef2f2;
colour: #991b1b;
}
.pid-badge {
font-family: monospace;
background: #f3f4f6;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
}
.item-actions {
display: flex;
gap: 0.5rem;
}
button.kill-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s;
background: #fff;
colour: #dc2626;
border: 1px solid #dc2626;
}
button.kill-btn:hover {
background: #fef2f2;
}
button.kill-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
text-align: center;
padding: 2rem;
colour: #9ca3af;
font-size: 0.875rem;
}
.loading {
text-align: center;
padding: 2rem;
colour: #6b7280;
}
.error {
colour: #dc2626;
padding: 0.75rem;
background: #fef2f2;
border-radius: 0.375rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.info-notice {
padding: 0.75rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.375rem;
font-size: 0.8125rem;
colour: #1e40af;
margin-bottom: 1rem;
}
`;
@property({ attribute: 'api-url' }) apiUrl = '';
@property({ attribute: 'ws-url' }) wsUrl = '';
@property({ attribute: 'selected-id' }) selectedId = '';
@state() private processes: ProcessInfo[] = [];
@state() private loading = false;
@state() private error = '';
@state() private connected = false;
@state() private killing = new Set<string>();
private api!: ProcessApi;
private ws: WebSocket | null = null;
connectedCallback() {
super.connectedCallback();
this.api = new ProcessApi(this.apiUrl);
this.loadProcesses();
}
disconnectedCallback() {
super.disconnectedCallback();
this.disconnect();
}
updated(changed: Map<string, unknown>) {
if (changed.has('apiUrl')) {
this.api = new ProcessApi(this.apiUrl);
}
if (changed.has('wsUrl') || changed.has('apiUrl')) {
this.disconnect();
void this.loadProcesses();
}
}
async loadProcesses() {
this.loading = true;
this.error = '';
try {
this.processes = await this.api.listProcesses();
if (this.wsUrl) {
this.connect();
}
} catch (e: any) {
this.error = e.message ?? 'Failed to load processes';
this.processes = [];
} finally {
this.loading = false;
}
}
private handleSelect(proc: ProcessInfo) {
this.dispatchEvent(
new CustomEvent('process-selected', {
detail: { id: proc.id },
bubbles: true,
composed: true,
}),
);
}
private async handleKill(proc: ProcessInfo) {
this.killing = new Set([...this.killing, proc.id]);
try {
await this.api.killProcess(proc.id);
await this.loadProcesses();
} catch (e: any) {
this.error = e.message ?? 'Failed to kill process';
} finally {
const next = new Set(this.killing);
next.delete(proc.id);
this.killing = next;
}
}
private connect() {
if (!this.wsUrl || this.ws) {
return;
}
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
this.applyEvent(event);
});
this.ws.onopen = () => {
this.connected = true;
};
this.ws.onclose = () => {
this.connected = false;
};
}
private disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.connected = false;
}
private applyEvent(event: ProcessEvent) {
const channel = event.channel ?? event.type ?? '';
const data = (event.data ?? {}) as Partial<ProcessInfo> & { id?: string };
if (!data.id) {
return;
}
const next = new Map(this.processes.map((proc) => [proc.id, proc] as const));
const current = next.get(data.id);
switch (channel) {
case 'process.started':
next.set(data.id, this.normalizeProcess(data, current, 'running'));
break;
case 'process.exited':
next.set(data.id, this.normalizeProcess(data, current, data.exitCode === -1 && data.error ? 'failed' : 'exited'));
break;
case 'process.killed':
next.set(data.id, this.normalizeProcess(data, current, 'killed'));
break;
default:
return;
}
this.processes = this.sortProcesses(next);
}
private normalizeProcess(
data: Partial<ProcessInfo> & { id: string; error?: unknown },
current: ProcessInfo | undefined,
status: ProcessInfo['status'],
): ProcessInfo {
const startedAt = data.startedAt ?? current?.startedAt ?? new Date().toISOString();
return {
id: data.id,
command: data.command ?? current?.command ?? '',
args: data.args ?? current?.args ?? [],
dir: data.dir ?? current?.dir ?? '',
startedAt,
running: status === 'running',
status,
exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0),
duration: data.duration ?? current?.duration ?? 0,
pid: data.pid ?? current?.pid ?? 0,
};
}
private sortProcesses(processes: Map<string, ProcessInfo>): ProcessInfo[] {
return [...processes.values()].sort((a, b) => {
const aStarted = new Date(a.startedAt).getTime();
const bStarted = new Date(b.startedAt).getTime();
if (aStarted === bStarted) {
return a.id.localeCompare(b.id);
}
return aStarted - bStarted;
});
}
private formatUptime(started: string): string {
try {
const ms = Date.now() - new Date(started).getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
} catch {
return 'unknown';
}
}
render() {
if (this.loading) {
return html`<div class="loading">Loading processes\u2026</div>`;
}
return html`
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
${this.processes.length === 0
? html`
<div class="info-notice">
${this.wsUrl
? this.connected
? 'Receiving live process updates.'
: 'Connecting to the process event stream...'
: 'Managed processes are loaded from the process REST API.'}
</div>
<div class="empty">No managed processes.</div>
`
: html`
<div class="list">
${this.processes.map(
(proc) => html`
<div
class="item ${this.selectedId === proc.id ? 'selected' : ''}"
@click=${() => this.handleSelect(proc)}
>
<div class="item-info">
<div class="item-command">
<span>${proc.command} ${proc.args?.join(' ') ?? ''}</span>
<span class="status-badge ${proc.status}">${proc.status}</span>
</div>
<div class="item-meta">
<span class="pid-badge">PID ${proc.pid}</span>
<span>${proc.id}</span>
${proc.dir ? html`<span>${proc.dir}</span>` : nothing}
${proc.status === 'running'
? html`<span>Up ${this.formatUptime(proc.startedAt)}</span>`
: nothing}
${proc.status === 'exited'
? html`<span class="exit-code ${proc.exitCode !== 0 ? 'nonzero' : ''}">
exit ${proc.exitCode}
</span>`
: nothing}
</div>
</div>
${proc.status === 'running'
? html`
<div class="item-actions">
<button
class="kill-btn"
?disabled=${this.killing.has(proc.id)}
@click=${(e: Event) => {
e.stopPropagation();
void this.handleKill(proc);
}}
>
${this.killing.has(proc.id) ? 'Killing\u2026' : 'Kill'}
</button>
</div>
`
: nothing}
</div>
`,
)}
</div>
`}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'core-process-list': ProcessList;
}
}