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>
This commit is contained in:
parent
ae321f5344
commit
0ae76c2414
19 changed files with 5131 additions and 3 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
ui/node_modules/
|
||||
163
docs/plans/2026-03-14-process-ui-elements.md
Normal file
163
docs/plans/2026-03-14-process-ui-elements.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Process UI Elements — Lit Custom Elements
|
||||
|
||||
**Date**: 2026-03-14
|
||||
**Status**: Complete
|
||||
|
||||
## Overview
|
||||
|
||||
Add Lit custom elements for process management, following the go-scm UI pattern exactly. The UI provides a tabbed panel with views for daemons, processes, process output, and pipeline runner status.
|
||||
|
||||
## 1. Review Existing ProcessProvider
|
||||
|
||||
The ProcessProvider (`pkg/api/provider.go`) already has:
|
||||
|
||||
- **REST endpoints**:
|
||||
- `GET /api/process/daemons` — List running daemons (auto-prunes dead PIDs)
|
||||
- `GET /api/process/daemons/:code/:daemon` — Get single daemon status
|
||||
- `POST /api/process/daemons/:code/:daemon/stop` — Stop daemon (SIGTERM + unregister)
|
||||
- `GET /api/process/daemons/:code/:daemon/health` — Health check probe
|
||||
|
||||
- **WS channels** (via `provider.Streamable`):
|
||||
- `process.daemon.started`
|
||||
- `process.daemon.stopped`
|
||||
- `process.daemon.health`
|
||||
|
||||
- **Implements**: `provider.Provider`, `provider.Streamable`, `provider.Describable`
|
||||
|
||||
- **Missing**: `provider.Renderable` — needs `Element()` method returning `ElementSpec{Tag, Source}`
|
||||
|
||||
- **Missing channels**: Process-level events (`process.output`, `process.started`, `process.exited`, `process.killed`) — the Go IPC actions exist but are not declared as WS channels
|
||||
|
||||
### Changes needed to provider.go
|
||||
|
||||
1. Add `provider.Renderable` interface compliance
|
||||
2. Add `Element()` method returning `{Tag: "core-process-panel", Source: "/assets/core-process.js"}`
|
||||
3. Add process-level WS channels to `Channels()`
|
||||
|
||||
## 2. Create ui/ directory
|
||||
|
||||
Scaffold the same structure as go-scm/ui:
|
||||
|
||||
```
|
||||
ui/
|
||||
├── package.json # @core/process-ui, lit dep
|
||||
├── tsconfig.json # ES2021 + decorators
|
||||
├── vite.config.ts # lib mode → ../pkg/api/ui/dist/core-process.js
|
||||
├── index.html # Demo page
|
||||
└── src/
|
||||
├── index.ts # Bundle entry, exports all elements
|
||||
├── process-panel.ts # <core-process-panel> — top-level tabbed panel
|
||||
├── process-daemons.ts # <core-process-daemons> — daemon registry list
|
||||
├── process-list.ts # <core-process-list> — running processes
|
||||
├── process-output.ts # <core-process-output> — live stdout/stderr stream
|
||||
├── process-runner.ts # <core-process-runner> — pipeline run results
|
||||
└── shared/
|
||||
├── api.ts # ProcessApi fetch wrapper
|
||||
└── events.ts # WS event filter for process.* events
|
||||
```
|
||||
|
||||
## 3. Lit Elements
|
||||
|
||||
### 3.1 shared/api.ts — ProcessApi
|
||||
|
||||
Typed fetch wrapper for `/api/process/*` endpoints:
|
||||
|
||||
- `listDaemons()` → `GET /daemons`
|
||||
- `getDaemon(code, daemon)` → `GET /daemons/:code/:daemon`
|
||||
- `stopDaemon(code, daemon)` → `POST /daemons/:code/:daemon/stop`
|
||||
- `healthCheck(code, daemon)` → `GET /daemons/:code/:daemon/health`
|
||||
|
||||
### 3.2 shared/events.ts — connectProcessEvents
|
||||
|
||||
WebSocket event filter for `process.*` channels. Same pattern as go-scm `connectScmEvents`.
|
||||
|
||||
### 3.3 process-panel.ts — `<core-process-panel>`
|
||||
|
||||
Top-level panel with tabs: Daemons / Processes / Pipelines. HLCRF layout:
|
||||
- H: Title bar ("Process Manager") + Refresh button
|
||||
- H-L: Tab navigation
|
||||
- C: Active tab content
|
||||
- F: WS connection status + last event
|
||||
|
||||
### 3.4 process-daemons.ts — `<core-process-daemons>`
|
||||
|
||||
Daemon registry list showing:
|
||||
- Code + Daemon name
|
||||
- PID + Started timestamp
|
||||
- Health status badge (healthy/unhealthy/no endpoint)
|
||||
- Project + Binary metadata
|
||||
- Stop button (POST stop endpoint)
|
||||
- Health check button (GET health endpoint)
|
||||
|
||||
### 3.5 process-list.ts — `<core-process-list>`
|
||||
|
||||
Running processes from Service.List():
|
||||
- Process ID + Command + Args
|
||||
- Status badge (pending/running/exited/failed/killed)
|
||||
- PID + uptime (since StartedAt)
|
||||
- Exit code (if exited)
|
||||
- Kill button (for running processes)
|
||||
- Click to select → emits `process-selected` event for output viewer
|
||||
|
||||
Note: This element requires process-level REST endpoints that do not exist in the current provider. The element will be built but will show placeholder state until those endpoints are added.
|
||||
|
||||
### 3.6 process-output.ts — `<core-process-output>`
|
||||
|
||||
Live stdout/stderr stream for a selected process:
|
||||
- Receives process ID via attribute
|
||||
- Subscribes to `process.output` WS events filtered by ID
|
||||
- Terminal-style output area with monospace font
|
||||
- Auto-scroll to bottom
|
||||
- Stream type indicator (stdout/stderr with colour coding)
|
||||
|
||||
### 3.7 process-runner.ts — `<core-process-runner>`
|
||||
|
||||
Pipeline execution results display:
|
||||
- RunSpec list with name, command, status
|
||||
- Dependency chain visualisation (After field)
|
||||
- Pass/Fail/Skip badges
|
||||
- Duration display
|
||||
- Aggregate summary (passed/failed/skipped counts)
|
||||
|
||||
Note: Like process-list, this needs runner REST endpoints. Built as display-ready element.
|
||||
|
||||
## 4. Create pkg/api/embed.go
|
||||
|
||||
```go
|
||||
//go:embed all:ui/dist
|
||||
var Assets embed.FS
|
||||
```
|
||||
|
||||
Same pattern as go-scm. The `ui/dist/` directory must exist (created by `npm run build` in ui/).
|
||||
|
||||
## 5. Update ProcessProvider
|
||||
|
||||
Add to `provider.go`:
|
||||
|
||||
1. Compile-time check: `_ provider.Renderable = (*ProcessProvider)(nil)`
|
||||
2. `Element()` method
|
||||
3. Extend `Channels()` with process-level events
|
||||
|
||||
## 6. Build Verification
|
||||
|
||||
1. `cd ui && npm install && npm run build` — produces `pkg/api/ui/dist/core-process.js`
|
||||
2. `go build ./...` from go-process root — verifies embed and provider compile
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write implementation plan
|
||||
- [x] Create ui/package.json, tsconfig.json, vite.config.ts
|
||||
- [x] Create ui/index.html demo page
|
||||
- [x] Create ui/src/shared/api.ts
|
||||
- [x] Create ui/src/shared/events.ts
|
||||
- [x] Create ui/src/process-panel.ts
|
||||
- [x] Create ui/src/process-daemons.ts
|
||||
- [x] Create ui/src/process-list.ts
|
||||
- [x] Create ui/src/process-output.ts
|
||||
- [x] Create ui/src/process-runner.ts
|
||||
- [x] Create ui/src/index.ts
|
||||
- [x] Create pkg/api/ui/dist/.gitkeep (placeholder for embed)
|
||||
- [x] Create pkg/api/embed.go
|
||||
- [x] Update pkg/api/provider.go — add Renderable + channels
|
||||
- [x] Build UI: npm install + npm run build (57.50 kB, gzip: 13.64 kB)
|
||||
- [x] Verify: go build ./... (pass) + go test ./pkg/api/... (8/8 pass)
|
||||
3
go.sum
3
go.sum
|
|
@ -1,6 +1,5 @@
|
|||
forge.lthn.ai/core/api v0.1.0 h1:ZKnQx+L9vxLQSEjwpsD1eNcIQrE4YKV1c2AlMtseM6o=
|
||||
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
|
||||
forge.lthn.ai/core/go-api v0.1.2 h1:zGmU2CqCQ0n0cntNvprdc7HoucD4E631wBdZw+taK1w=
|
||||
forge.lthn.ai/core/go-api v0.1.2/go.mod h1:/MGxVudBh3k4K2hOkAC74HMVWssPz4pPNQTbGyiBUDs=
|
||||
forge.lthn.ai/core/go-ws v0.1.3 h1:TzqFpEcDYcZUFFmrTznfEuVcVdnp2jsRNwAGHeTyXN0=
|
||||
forge.lthn.ai/core/go-ws v0.1.3/go.mod h1:iDbJuR1NT27czjtNIluxnEdLrnfsYQdEBIrsoZnpkCk=
|
||||
github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8=
|
||||
|
|
|
|||
11
pkg/api/embed.go
Normal file
11
pkg/api/embed.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets holds the built UI bundle (core-process.js and related files).
|
||||
// The directory is populated by running `npm run build` in the ui/ directory.
|
||||
//
|
||||
//go:embed all:ui/dist
|
||||
var Assets embed.FS
|
||||
|
|
@ -18,7 +18,8 @@ import (
|
|||
)
|
||||
|
||||
// ProcessProvider wraps the go-process daemon Registry as a service provider.
|
||||
// It implements provider.Provider, provider.Streamable, and provider.Describable.
|
||||
// It implements provider.Provider, provider.Streamable, provider.Describable,
|
||||
// and provider.Renderable.
|
||||
type ProcessProvider struct {
|
||||
registry *process.Registry
|
||||
hub *ws.Hub
|
||||
|
|
@ -29,6 +30,7 @@ var (
|
|||
_ provider.Provider = (*ProcessProvider)(nil)
|
||||
_ provider.Streamable = (*ProcessProvider)(nil)
|
||||
_ provider.Describable = (*ProcessProvider)(nil)
|
||||
_ provider.Renderable = (*ProcessProvider)(nil)
|
||||
)
|
||||
|
||||
// NewProvider creates a process provider backed by the given daemon registry.
|
||||
|
|
@ -50,12 +52,24 @@ func (p *ProcessProvider) Name() string { return "process" }
|
|||
// BasePath implements api.RouteGroup.
|
||||
func (p *ProcessProvider) BasePath() string { return "/api/process" }
|
||||
|
||||
// Element implements provider.Renderable.
|
||||
func (p *ProcessProvider) Element() provider.ElementSpec {
|
||||
return provider.ElementSpec{
|
||||
Tag: "core-process-panel",
|
||||
Source: "/assets/core-process.js",
|
||||
}
|
||||
}
|
||||
|
||||
// Channels implements provider.Streamable.
|
||||
func (p *ProcessProvider) Channels() []string {
|
||||
return []string{
|
||||
"process.daemon.started",
|
||||
"process.daemon.stopped",
|
||||
"process.daemon.health",
|
||||
"process.started",
|
||||
"process.output",
|
||||
"process.exited",
|
||||
"process.killed",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1958
pkg/api/ui/dist/core-process.js
vendored
Normal file
1958
pkg/api/ui/dist/core-process.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
79
ui/index.html
Normal file
79
ui/index.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Core Process — Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #f3f4f6;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
colour: #111827;
|
||||
}
|
||||
|
||||
.demo-panel {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
height: 80vh;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
margin: 2rem auto 1rem;
|
||||
max-width: 960px;
|
||||
colour: #374151;
|
||||
}
|
||||
|
||||
.standalone {
|
||||
max-width: 960px;
|
||||
margin: 0 auto 2rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="./src/index.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Core Process — Custom Element Demo</h1>
|
||||
|
||||
<div class="demo-panel">
|
||||
<core-process-panel api-url=""></core-process-panel>
|
||||
</div>
|
||||
|
||||
<h2>Standalone Elements</h2>
|
||||
|
||||
<div class="standalone">
|
||||
<core-process-daemons api-url=""></core-process-daemons>
|
||||
</div>
|
||||
|
||||
<div class="standalone">
|
||||
<core-process-list api-url=""></core-process-list>
|
||||
</div>
|
||||
|
||||
<div class="standalone">
|
||||
<core-process-output api-url="" process-id=""></core-process-output>
|
||||
</div>
|
||||
|
||||
<div class="standalone">
|
||||
<core-process-runner api-url=""></core-process-runner>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1213
ui/package-lock.json
generated
Normal file
1213
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
ui/package.json
Normal file
18
ui/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@core/process-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
11
ui/src/index.ts
Normal file
11
ui/src/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
// Bundle entry — exports all process management custom elements.
|
||||
|
||||
export { ProcessPanel } from './process-panel.js';
|
||||
export { ProcessDaemons } from './process-daemons.js';
|
||||
export { ProcessList } from './process-list.js';
|
||||
export { ProcessOutput } from './process-output.js';
|
||||
export { ProcessRunner } from './process-runner.js';
|
||||
export { ProcessApi } from './shared/api.js';
|
||||
export { connectProcessEvents } from './shared/events.js';
|
||||
334
ui/src/process-daemons.ts
Normal file
334
ui/src/process-daemons.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ProcessApi, type DaemonEntry, type HealthResult } from './shared/api.js';
|
||||
|
||||
/**
|
||||
* <core-process-daemons> — Daemon registry list.
|
||||
* Shows registered daemons with status badges, health indicators, and stop buttons.
|
||||
*/
|
||||
@customElement('core-process-daemons')
|
||||
export class ProcessDaemons extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
colour: #6366f1;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 0.75rem;
|
||||
colour: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pid-badge {
|
||||
font-family: monospace;
|
||||
background: #f3f4f6;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.health-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.health-badge.healthy {
|
||||
background: #dcfce7;
|
||||
colour: #166534;
|
||||
}
|
||||
|
||||
.health-badge.unhealthy {
|
||||
background: #fef2f2;
|
||||
colour: #991b1b;
|
||||
}
|
||||
|
||||
.health-badge.unknown {
|
||||
background: #f3f4f6;
|
||||
colour: #6b7280;
|
||||
}
|
||||
|
||||
.health-badge.checking {
|
||||
background: #fef3c7;
|
||||
colour: #92400e;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button.health-btn {
|
||||
background: #fff;
|
||||
colour: #6366f1;
|
||||
border: 1px solid #6366f1;
|
||||
}
|
||||
|
||||
button.health-btn:hover {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
button.health-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.stop-btn {
|
||||
background: #fff;
|
||||
colour: #dc2626;
|
||||
border: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
button.stop-btn:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
button.stop-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;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: 'api-url' }) apiUrl = '';
|
||||
|
||||
@state() private daemons: DaemonEntry[] = [];
|
||||
@state() private loading = true;
|
||||
@state() private error = '';
|
||||
@state() private stopping = new Set<string>();
|
||||
@state() private checking = new Set<string>();
|
||||
@state() private healthResults = new Map<string, HealthResult>();
|
||||
|
||||
private api!: ProcessApi;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.api = new ProcessApi(this.apiUrl);
|
||||
this.loadDaemons();
|
||||
}
|
||||
|
||||
async loadDaemons() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
this.daemons = await this.api.listDaemons();
|
||||
} catch (e: any) {
|
||||
this.error = e.message ?? 'Failed to load daemons';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private daemonKey(d: DaemonEntry): string {
|
||||
return `${d.code}/${d.daemon}`;
|
||||
}
|
||||
|
||||
private async handleStop(d: DaemonEntry) {
|
||||
const key = this.daemonKey(d);
|
||||
this.stopping = new Set([...this.stopping, key]);
|
||||
try {
|
||||
await this.api.stopDaemon(d.code, d.daemon);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('daemon-stopped', {
|
||||
detail: { code: d.code, daemon: d.daemon },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
await this.loadDaemons();
|
||||
} catch (e: any) {
|
||||
this.error = e.message ?? 'Failed to stop daemon';
|
||||
} finally {
|
||||
const next = new Set(this.stopping);
|
||||
next.delete(key);
|
||||
this.stopping = next;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleHealthCheck(d: DaemonEntry) {
|
||||
const key = this.daemonKey(d);
|
||||
this.checking = new Set([...this.checking, key]);
|
||||
try {
|
||||
const result = await this.api.healthCheck(d.code, d.daemon);
|
||||
const next = new Map(this.healthResults);
|
||||
next.set(key, result);
|
||||
this.healthResults = next;
|
||||
} catch (e: any) {
|
||||
this.error = e.message ?? 'Health check failed';
|
||||
} finally {
|
||||
const next = new Set(this.checking);
|
||||
next.delete(key);
|
||||
this.checking = next;
|
||||
}
|
||||
}
|
||||
|
||||
private formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
private renderHealthBadge(d: DaemonEntry) {
|
||||
const key = this.daemonKey(d);
|
||||
|
||||
if (this.checking.has(key)) {
|
||||
return html`<span class="health-badge checking">Checking\u2026</span>`;
|
||||
}
|
||||
|
||||
const result = this.healthResults.get(key);
|
||||
if (result) {
|
||||
return html`<span class="health-badge ${result.healthy ? 'healthy' : 'unhealthy'}">
|
||||
${result.healthy ? 'Healthy' : 'Unhealthy'}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
if (!d.health) {
|
||||
return html`<span class="health-badge unknown">No health endpoint</span>`;
|
||||
}
|
||||
|
||||
return html`<span class="health-badge unknown">Unchecked</span>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
return html`<div class="loading">Loading daemons\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${this.error ? html`<div class="error">${this.error}</div>` : nothing}
|
||||
${this.daemons.length === 0
|
||||
? html`<div class="empty">No daemons registered.</div>`
|
||||
: html`
|
||||
<div class="list">
|
||||
${this.daemons.map((d) => {
|
||||
const key = this.daemonKey(d);
|
||||
return html`
|
||||
<div class="item">
|
||||
<div class="item-info">
|
||||
<div class="item-name">
|
||||
<span class="item-code">${d.code}</span>
|
||||
<span>${d.daemon}</span>
|
||||
${this.renderHealthBadge(d)}
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="pid-badge">PID ${d.pid}</span>
|
||||
<span>Started ${this.formatDate(d.started)}</span>
|
||||
${d.project ? html`<span>${d.project}</span>` : nothing}
|
||||
${d.binary ? html`<span>${d.binary}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
${d.health
|
||||
? html`
|
||||
<button
|
||||
class="health-btn"
|
||||
?disabled=${this.checking.has(key)}
|
||||
@click=${() => this.handleHealthCheck(d)}
|
||||
>
|
||||
${this.checking.has(key) ? 'Checking\u2026' : 'Health'}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<button
|
||||
class="stop-btn"
|
||||
?disabled=${this.stopping.has(key)}
|
||||
@click=${() => this.handleStop(d)}
|
||||
>
|
||||
${this.stopping.has(key) ? 'Stopping\u2026' : 'Stop'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-process-daemons': ProcessDaemons;
|
||||
}
|
||||
}
|
||||
301
ui/src/process-list.ts
Normal file
301
ui/src/process-list.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import 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.
|
||||
*
|
||||
* Note: Requires process-level REST endpoints (GET /processes, POST /processes/:id/kill)
|
||||
* that are not yet in the provider. The element renders from WS events and local state
|
||||
* until those endpoints are available.
|
||||
*/
|
||||
@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: 'selected-id' }) selectedId = '';
|
||||
|
||||
@state() private processes: ProcessInfo[] = [];
|
||||
@state() private loading = false;
|
||||
@state() private error = '';
|
||||
@state() private killing = new Set<string>();
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadProcesses();
|
||||
}
|
||||
|
||||
async loadProcesses() {
|
||||
// Process-level REST endpoints are not yet available.
|
||||
// This element will populate via WS events once endpoints exist.
|
||||
this.loading = false;
|
||||
this.processes = [];
|
||||
}
|
||||
|
||||
private handleSelect(proc: ProcessInfo) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('process-selected', {
|
||||
detail: { id: proc.id },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
Process list endpoints are pending. Processes will appear here once
|
||||
the REST API for managed processes is available.
|
||||
</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();
|
||||
}}
|
||||
>
|
||||
${this.killing.has(proc.id) ? 'Killing\u2026' : 'Kill'}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-process-list': ProcessList;
|
||||
}
|
||||
}
|
||||
252
ui/src/process-output.ts
Normal file
252
ui/src/process-output.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
275
ui/src/process-panel.ts
Normal file
275
ui/src/process-panel.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// 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';
|
||||
|
||||
// Side-effect imports to register child elements
|
||||
import './process-daemons.js';
|
||||
import './process-list.js';
|
||||
import './process-output.js';
|
||||
import './process-runner.js';
|
||||
|
||||
type TabId = 'daemons' | 'processes' | 'pipelines';
|
||||
|
||||
/**
|
||||
* <core-process-panel> — Top-level process management panel with tabs.
|
||||
*
|
||||
* Arranges child elements in HLCRF layout:
|
||||
* - H: Title bar with refresh button
|
||||
* - H-L: Navigation tabs
|
||||
* - C: Active tab content (one of the child elements)
|
||||
* - F: Status bar (connection state, last refresh)
|
||||
*/
|
||||
@customElement('core-process-panel')
|
||||
export class ProcessPanel extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
height: 100%;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* H — Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
colour: #111827;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* H-L — Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
colour: #6b7280;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
colour: #374151;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
colour: #6366f1;
|
||||
border-bottom-colour: #6366f1;
|
||||
}
|
||||
|
||||
/* C — Content */
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* F — Footer / Status bar */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 0.75rem;
|
||||
colour: #9ca3af;
|
||||
}
|
||||
|
||||
.ws-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.ws-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ws-dot.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.ws-dot.disconnected {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.ws-dot.idle {
|
||||
background: #d1d5db;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: 'api-url' }) apiUrl = '';
|
||||
@property({ attribute: 'ws-url' }) wsUrl = '';
|
||||
|
||||
@state() private activeTab: TabId = 'daemons';
|
||||
@state() private wsConnected = false;
|
||||
@state() private lastEvent = '';
|
||||
@state() private selectedProcessId = '';
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.wsUrl) {
|
||||
this.connectWs();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private connectWs() {
|
||||
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
|
||||
this.lastEvent = event.channel ?? event.type ?? '';
|
||||
this.requestUpdate();
|
||||
});
|
||||
this.ws.onopen = () => {
|
||||
this.wsConnected = true;
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.wsConnected = false;
|
||||
};
|
||||
}
|
||||
|
||||
private handleTabClick(tab: TabId) {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
private handleRefresh() {
|
||||
const content = this.shadowRoot?.querySelector('.content');
|
||||
if (content) {
|
||||
const child = content.firstElementChild;
|
||||
if (child && 'loadDaemons' in child) {
|
||||
(child as any).loadDaemons();
|
||||
} else if (child && 'loadProcesses' in child) {
|
||||
(child as any).loadProcesses();
|
||||
} else if (child && 'loadResults' in child) {
|
||||
(child as any).loadResults();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessSelected(e: CustomEvent<{ id: string }>) {
|
||||
this.selectedProcessId = e.detail.id;
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
switch (this.activeTab) {
|
||||
case 'daemons':
|
||||
return html`<core-process-daemons api-url=${this.apiUrl}></core-process-daemons>`;
|
||||
case 'processes':
|
||||
return html`
|
||||
<core-process-list
|
||||
api-url=${this.apiUrl}
|
||||
@process-selected=${this.handleProcessSelected}
|
||||
></core-process-list>
|
||||
${this.selectedProcessId
|
||||
? html`<core-process-output
|
||||
api-url=${this.apiUrl}
|
||||
ws-url=${this.wsUrl}
|
||||
process-id=${this.selectedProcessId}
|
||||
></core-process-output>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'pipelines':
|
||||
return html`<core-process-runner api-url=${this.apiUrl}></core-process-runner>`;
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
private tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'daemons', label: 'Daemons' },
|
||||
{ id: 'processes', label: 'Processes' },
|
||||
{ id: 'pipelines', label: 'Pipelines' },
|
||||
];
|
||||
|
||||
render() {
|
||||
const wsState = this.wsUrl
|
||||
? this.wsConnected
|
||||
? 'connected'
|
||||
: 'disconnected'
|
||||
: 'idle';
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<span class="title">Process Manager</span>
|
||||
<button class="refresh-btn" @click=${this.handleRefresh}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
${this.tabs.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
class="tab ${this.activeTab === tab.id ? 'active' : ''}"
|
||||
@click=${() => this.handleTabClick(tab.id)}
|
||||
>
|
||||
${tab.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="content">${this.renderContent()}</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="ws-status">
|
||||
<span class="ws-dot ${wsState}"></span>
|
||||
<span>${wsState === 'connected' ? 'Connected' : wsState === 'disconnected' ? 'Disconnected' : 'No WebSocket'}</span>
|
||||
</div>
|
||||
${this.lastEvent ? html`<span>Last: ${this.lastEvent}</span>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-process-panel': ProcessPanel;
|
||||
}
|
||||
}
|
||||
325
ui/src/process-runner.ts
Normal file
325
ui/src/process-runner.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import type { RunResult, RunAllResult } from './shared/api.js';
|
||||
|
||||
/**
|
||||
* <core-process-runner> — Pipeline execution status display.
|
||||
*
|
||||
* Shows RunSpec execution results with pass/fail/skip badges, duration,
|
||||
* dependency chains, and aggregate summary.
|
||||
*
|
||||
* Note: Pipeline runner REST endpoints are not yet in the provider.
|
||||
* This element renders from WS events and accepts data via properties
|
||||
* until those endpoints are available.
|
||||
*/
|
||||
@customElement('core-process-runner')
|
||||
export class ProcessRunner extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.6875rem;
|
||||
colour: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.summary-value.passed {
|
||||
colour: #166534;
|
||||
}
|
||||
|
||||
.summary-value.failed {
|
||||
colour: #991b1b;
|
||||
}
|
||||
|
||||
.summary-value.skipped {
|
||||
colour: #92400e;
|
||||
}
|
||||
|
||||
.summary-duration {
|
||||
margin-left: auto;
|
||||
font-size: 0.8125rem;
|
||||
colour: #6b7280;
|
||||
}
|
||||
|
||||
.overall-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overall-badge.success {
|
||||
background: #dcfce7;
|
||||
colour: #166534;
|
||||
}
|
||||
|
||||
.overall-badge.failure {
|
||||
background: #fef2f2;
|
||||
colour: #991b1b;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.spec {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.spec:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.spec-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spec-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spec-meta {
|
||||
font-size: 0.75rem;
|
||||
colour: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-badge.passed {
|
||||
background: #dcfce7;
|
||||
colour: #166534;
|
||||
}
|
||||
|
||||
.result-badge.failed {
|
||||
background: #fef2f2;
|
||||
colour: #991b1b;
|
||||
}
|
||||
|
||||
.result-badge.skipped {
|
||||
background: #fef3c7;
|
||||
colour: #92400e;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
colour: #6b7280;
|
||||
}
|
||||
|
||||
.deps {
|
||||
font-size: 0.6875rem;
|
||||
colour: #9ca3af;
|
||||
}
|
||||
|
||||
.spec-output {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1e1e1e;
|
||||
border-radius: 0.375rem;
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
colour: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spec-error {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
colour: #dc2626;
|
||||
}
|
||||
|
||||
.toggle-output {
|
||||
font-size: 0.6875rem;
|
||||
colour: #6366f1;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.toggle-output:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
colour: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.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({ type: Object }) result: RunAllResult | null = null;
|
||||
|
||||
@state() private expandedOutputs = new Set<string>();
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadResults();
|
||||
}
|
||||
|
||||
async loadResults() {
|
||||
// Pipeline runner REST endpoints are not yet available.
|
||||
// Results can be passed in via the `result` property.
|
||||
}
|
||||
|
||||
private toggleOutput(name: string) {
|
||||
const next = new Set(this.expandedOutputs);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
this.expandedOutputs = next;
|
||||
}
|
||||
|
||||
private formatDuration(ns: number): string {
|
||||
if (ns < 1_000_000) return `${(ns / 1000).toFixed(0)}\u00b5s`;
|
||||
if (ns < 1_000_000_000) return `${(ns / 1_000_000).toFixed(0)}ms`;
|
||||
return `${(ns / 1_000_000_000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
private resultStatus(r: RunResult): string {
|
||||
if (r.skipped) return 'skipped';
|
||||
if (r.passed) return 'passed';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.result) {
|
||||
return html`
|
||||
<div class="info-notice">
|
||||
Pipeline runner endpoints are pending. Pass pipeline results via the
|
||||
<code>result</code> property, or results will appear here once the REST
|
||||
API for pipeline execution is available.
|
||||
</div>
|
||||
<div class="empty">No pipeline results.</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { results, duration, passed, failed, skipped, success } = this.result;
|
||||
|
||||
return html`
|
||||
<div class="summary">
|
||||
<span class="overall-badge ${success ? 'success' : 'failure'}">
|
||||
${success ? 'Passed' : 'Failed'}
|
||||
</span>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-value passed">${passed}</span>
|
||||
<span class="summary-label">Passed</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-value failed">${failed}</span>
|
||||
<span class="summary-label">Failed</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-value skipped">${skipped}</span>
|
||||
<span class="summary-label">Skipped</span>
|
||||
</div>
|
||||
<span class="summary-duration">${this.formatDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
${results.map(
|
||||
(r) => html`
|
||||
<div class="spec">
|
||||
<div class="spec-header">
|
||||
<div class="spec-name">
|
||||
<span>${r.name}</span>
|
||||
<span class="result-badge ${this.resultStatus(r)}">${this.resultStatus(r)}</span>
|
||||
</div>
|
||||
<span class="duration">${this.formatDuration(r.duration)}</span>
|
||||
</div>
|
||||
<div class="spec-meta">
|
||||
${r.exitCode !== 0 && !r.skipped
|
||||
? html`<span>exit ${r.exitCode}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${r.error ? html`<div class="spec-error">${r.error}</div>` : nothing}
|
||||
${r.output
|
||||
? html`
|
||||
<button class="toggle-output" @click=${() => this.toggleOutput(r.name)}>
|
||||
${this.expandedOutputs.has(r.name) ? 'Hide output' : 'Show output'}
|
||||
</button>
|
||||
${this.expandedOutputs.has(r.name)
|
||||
? html`<div class="spec-output">${r.output}</div>`
|
||||
: nothing}
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'core-process-runner': ProcessRunner;
|
||||
}
|
||||
}
|
||||
105
ui/src/shared/api.ts
Normal file
105
ui/src/shared/api.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
/**
|
||||
* Daemon entry as returned by the process registry REST API.
|
||||
*/
|
||||
export interface DaemonEntry {
|
||||
code: string;
|
||||
daemon: string;
|
||||
pid: number;
|
||||
health?: string;
|
||||
project?: string;
|
||||
binary?: string;
|
||||
started: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check result from the daemon health endpoint.
|
||||
*/
|
||||
export interface HealthResult {
|
||||
healthy: boolean;
|
||||
address: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process info snapshot as returned by the process service.
|
||||
*/
|
||||
export interface ProcessInfo {
|
||||
id: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
dir: string;
|
||||
startedAt: string;
|
||||
status: 'pending' | 'running' | 'exited' | 'failed' | 'killed';
|
||||
exitCode: number;
|
||||
duration: number;
|
||||
pid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline run result for a single spec.
|
||||
*/
|
||||
export interface RunResult {
|
||||
name: string;
|
||||
exitCode: number;
|
||||
duration: number;
|
||||
output: string;
|
||||
error?: string;
|
||||
skipped: boolean;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate pipeline run result.
|
||||
*/
|
||||
export interface RunAllResult {
|
||||
results: RunResult[];
|
||||
duration: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProcessApi provides a typed fetch wrapper for the /api/process/* endpoints.
|
||||
*/
|
||||
export class ProcessApi {
|
||||
constructor(private baseUrl: string = '') {}
|
||||
|
||||
private get base(): string {
|
||||
return `${this.baseUrl}/api/process`;
|
||||
}
|
||||
|
||||
private async request<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.base}${path}`, opts);
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
throw new Error(json.error?.message ?? 'Request failed');
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
/** List all alive daemons from the registry. */
|
||||
listDaemons(): Promise<DaemonEntry[]> {
|
||||
return this.request<DaemonEntry[]>('/daemons');
|
||||
}
|
||||
|
||||
/** Get a single daemon entry by code and daemon name. */
|
||||
getDaemon(code: string, daemon: string): Promise<DaemonEntry> {
|
||||
return this.request<DaemonEntry>(`/daemons/${code}/${daemon}`);
|
||||
}
|
||||
|
||||
/** Stop a daemon (SIGTERM + unregister). */
|
||||
stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }> {
|
||||
return this.request<{ stopped: boolean }>(`/daemons/${code}/${daemon}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/** Check daemon health endpoint. */
|
||||
healthCheck(code: string, daemon: string): Promise<HealthResult> {
|
||||
return this.request<HealthResult>(`/daemons/${code}/${daemon}/health`);
|
||||
}
|
||||
}
|
||||
32
ui/src/shared/events.ts
Normal file
32
ui/src/shared/events.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
export interface ProcessEvent {
|
||||
type: string;
|
||||
channel?: string;
|
||||
data?: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to a WebSocket endpoint and dispatches process events to a handler.
|
||||
* Returns the WebSocket instance for lifecycle management.
|
||||
*/
|
||||
export function connectProcessEvents(
|
||||
wsUrl: string,
|
||||
handler: (event: ProcessEvent) => void,
|
||||
): WebSocket {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
try {
|
||||
const event: ProcessEvent = JSON.parse(e.data);
|
||||
if (event.type?.startsWith?.('process.') || event.channel?.startsWith?.('process.')) {
|
||||
handler(event);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
return ws;
|
||||
}
|
||||
17
ui/tsconfig.json
Normal file
17
ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
ui/vite.config.ts
Normal file
20
ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'CoreProcess',
|
||||
fileName: 'core-process',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, '../pkg/api/ui/dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'core-process.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue