go-process/ui/src/process-output.ts
Snider 0ae76c2414 feat(ui): add Lit custom elements for process management
Five Lit custom elements following the go-scm UI pattern:
- <core-process-panel> tabbed container (Daemons/Processes/Pipelines)
- <core-process-daemons> daemon registry with health checks and stop
- <core-process-list> managed processes with status badges
- <core-process-output> live stdout/stderr WS stream viewer
- <core-process-runner> pipeline execution results display

Also adds provider.Renderable interface to ProcessProvider with
Element() returning core-process-panel tag, extends WS channels
with process-level events, and embeds the built UI bundle via
go:embed.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-14 12:25:06 +00:00

252 lines
5.7 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';
interface OutputLine {
text: string;
stream: 'stdout' | 'stderr';
timestamp: number;
}
/**
* <core-process-output> — Live stdout/stderr stream for a selected process.
*
* Connects to the WS endpoint and filters for process.output events matching
* the given process-id. Displays output in a terminal-style scrollable area
* with colour-coded stream indicators (stdout/stderr).
*/
@customElement('core-process-output')
export class ProcessOutput extends LitElement {
static styles = css`
:host {
display: block;
font-family: system-ui, -apple-system, sans-serif;
margin-top: 0.75rem;
}
.output-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #1e1e1e;
border-radius: 0.5rem 0.5rem 0 0;
colour: #d4d4d4;
font-size: 0.75rem;
}
.output-title {
font-weight: 600;
}
.output-actions {
display: flex;
gap: 0.5rem;
}
button.clear-btn {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
cursor: pointer;
background: #333;
colour: #d4d4d4;
border: 1px solid #555;
transition: background 0.15s;
}
button.clear-btn:hover {
background: #444;
}
.auto-scroll-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
colour: #d4d4d4;
cursor: pointer;
}
.auto-scroll-toggle input {
cursor: pointer;
}
.output-body {
background: #1e1e1e;
border-radius: 0 0 0.5rem 0.5rem;
padding: 0.5rem 0.75rem;
max-height: 24rem;
overflow-y: auto;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 0.8125rem;
line-height: 1.5;
}
.line {
white-space: pre-wrap;
word-break: break-all;
}
.line.stdout {
colour: #d4d4d4;
}
.line.stderr {
colour: #f87171;
}
.stream-tag {
display: inline-block;
width: 3rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
opacity: 0.5;
margin-right: 0.5rem;
}
.empty {
text-align: center;
padding: 2rem;
colour: #6b7280;
font-size: 0.8125rem;
}
.waiting {
colour: #9ca3af;
font-style: italic;
padding: 1rem;
text-align: center;
font-size: 0.8125rem;
}
`;
@property({ attribute: 'api-url' }) apiUrl = '';
@property({ attribute: 'ws-url' }) wsUrl = '';
@property({ attribute: 'process-id' }) processId = '';
@state() private lines: OutputLine[] = [];
@state() private autoScroll = true;
@state() private connected = false;
private ws: WebSocket | null = null;
connectedCallback() {
super.connectedCallback();
if (this.wsUrl && this.processId) {
this.connect();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.disconnect();
}
updated(changed: Map<string, unknown>) {
if (changed.has('processId') || changed.has('wsUrl')) {
this.disconnect();
this.lines = [];
if (this.wsUrl && this.processId) {
this.connect();
}
}
if (this.autoScroll) {
this.scrollToBottom();
}
}
private connect() {
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
const data = event.data;
if (!data) return;
// Filter for output events matching our process ID
const channel = event.channel ?? event.type ?? '';
if (channel === 'process.output' && data.id === this.processId) {
this.lines = [
...this.lines,
{
text: data.line ?? '',
stream: data.stream === 'stderr' ? 'stderr' : 'stdout',
timestamp: Date.now(),
},
];
}
});
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 handleClear() {
this.lines = [];
}
private handleAutoScrollToggle() {
this.autoScroll = !this.autoScroll;
}
private scrollToBottom() {
const body = this.shadowRoot?.querySelector('.output-body');
if (body) {
body.scrollTop = body.scrollHeight;
}
}
render() {
if (!this.processId) {
return html`<div class="empty">Select a process to view its output.</div>`;
}
return html`
<div class="output-header">
<span class="output-title">Output: ${this.processId}</span>
<div class="output-actions">
<label class="auto-scroll-toggle">
<input
type="checkbox"
?checked=${this.autoScroll}
@change=${this.handleAutoScrollToggle}
/>
Auto-scroll
</label>
<button class="clear-btn" @click=${this.handleClear}>Clear</button>
</div>
</div>
<div class="output-body">
${this.lines.length === 0
? html`<div class="waiting">Waiting for output\u2026</div>`
: this.lines.map(
(line) => html`
<div class="line ${line.stream}">
<span class="stream-tag">${line.stream}</span>${line.text}
</div>
`,
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'core-process-output': ProcessOutput;
}
}