feat: initial commit - pipeline runner with real-time streaming

This commit is contained in:
2026-07-03 09:31:45 +03:00
commit 5a29468b7e
9 changed files with 1000 additions and 0 deletions
+432
View File
@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pipeline Runner</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js" defer></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e; color: #eaeaea; padding: 20px; line-height: 1.6;
}
.container { max-width: 1200px; margin: 0 auto; }
h1 { margin-bottom: 20px; color: #00d9ff; }
h2 { margin: 20px 0 10px; color: #00ff88; font-size: 1.2em; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.panel {
background: #16213e; border-radius: 8px; padding: 20px;
border: 1px solid #0f3460;
}
.full-width { grid-column: 1 / -1; }
input, button {
padding: 10px 15px; border-radius: 6px; border: 1px solid #0f3460;
background: #0f3460; color: white; font-size: 14px;
}
input:focus { outline: none; border-color: #00d9ff; }
button {
cursor: pointer; transition: all 0.2s; font-weight: 600;
}
button:hover { transform: translateY(-1px); }
.btn-primary { background: #00d9ff; color: #1a1a2e; border: none; }
.btn-primary:hover { background: #00b8d9; }
.btn-danger { background: #ff4757; color: white; border: none; }
.btn-danger:hover { background: #ff3333; }
.btn-success { background: #00ff88; color: #1a1a2e; border: none; }
.btn-success:hover { background: #00cc6a; }
.btn-secondary { background: #0f3460; border: 1px solid #00d9ff; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; color: #00d9ff; font-size: 0.9em; }
.form-group input { width: 100%; }
.pipeline-list { list-style: none; }
.pipeline-item {
background: #0f3460; padding: 12px 15px; margin: 8px 0;
border-radius: 6px; display: flex; justify-content: space-between;
align-items: center; cursor: pointer; transition: all 0.2s;
}
.pipeline-item:hover { background: #1a4a7a; }
.pipeline-item.selected { border: 2px solid #00d9ff; }
.prompt-list { list-style: none; margin: 10px 0; }
.prompt-item {
background: #0f3460; padding: 12px; margin: 6px 0;
border-radius: 6px; display: flex; gap: 10px; align-items: flex-start;
}
.prompt-item .order { color: #00d9ff; font-weight: bold; min-width: 25px; }
.prompt-item .content { flex: 1; }
.prompt-item .title { color: #00ff88; font-weight: 600; margin-bottom: 4px; }
.prompt-item .text { color: #ccc; font-size: 0.9em; word-break: break-word; }
.prompt-item .actions {
display: flex; gap: 5px; margin-left: 10px;
}
.prompt-item .actions button {
padding: 5px 10px; font-size: 12px;
}
.status-badge {
padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;
text-transform: uppercase;
}
.status-pending { background: #555; color: white; }
.status-running { background: #00d9ff; color: #1a1a2e; }
.status-completed { background: #00ff88; color: #1a1a2e; }
.status-error { background: #ff4757; color: white; }
.results-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px;
margin-top: 15px;
}
.result-card {
background: #0f3460; border-radius: 8px; overflow: hidden;
border: 1px solid #16213e;
}
.result-header {
padding: 10px 15px; background: #16213e; display: flex;
justify-content: space-between; align-items: center; cursor: pointer;
}
.result-header:hover { background: #1a2744; }
.result-title { font-weight: 600; color: #00d9ff; }
.result-body {
padding: 15px; max-height: 300px; overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace; font-size: 12px;
white-space: pre-wrap; word-break: break-word;
}
.result-body .log-line { margin: 2px 0; }
.result-body .log-line.error { color: #ff4757; }
.result-body .log-line.info { color: #00d9ff; }
.result-body .log-line.success { color: #00ff88; }
.result-body .log-line.default { color: #ccc; }
.empty-state {
text-align: center; padding: 40px; color: #666;
}
.run-info {
background: #0f3460; padding: 10px 15px; border-radius: 6px;
margin-bottom: 15px; font-size: 0.9em;
}
.run-info span { color: #00d9ff; }
.close-btn { background: none; border: none; color: #ff4757; cursor: pointer; font-size: 18px; padding: 0; }
</style>
</head>
<body>
<div x-data="app()" class="container">
<h1>🚀 Pipeline Runner</h1>
<div class="grid">
<!-- Left Column: Pipeline Management -->
<div>
<div class="panel">
<h2>Create/Load Pipeline</h2>
<div class="form-group">
<label>Pipeline Name</label>
<input type="text" x-model="newPipelineName" placeholder="My Pipeline">
</div>
<div class="form-group" style="display: flex; gap: 10px;">
<input type="text" x-model="newPromptText" placeholder="Enter prompt text..." style="flex: 1;">
<input type="text" x-model="newPromptTitle" placeholder="Title" style="flex: 0.5;">
</div>
<button @click="addPrompt" class="btn-secondary" style="width: 100%; margin-bottom: 15px;">
+ Add Prompt
</button>
<ul class="prompt-list">
<template x-for="(prompt, index) in prompts" :key="prompt.id">
<li class="prompt-item">
<span class="order" x-text="index + 1"></span>
<div class="content">
<div class="title" x-text="prompt.title"></div>
<div class="text" x-text="prompt.text"></div>
</div>
<div class="actions">
<button @click="removePrompt(index)" class="btn-danger">×</button>
</div>
</li>
</template>
<li x-show="prompts.length === 0" class="empty-state" style="padding: 20px;">
No prompts added yet
</li>
</ul>
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button @click="savePipeline" class="btn-primary" style="flex: 1;">
💾 Save Pipeline
</button>
<button @click="clearPrompts" class="btn-secondary" style="flex: 1;">
🗑️ Clear
</button>
</div>
</div>
<div class="panel" style="margin-top: 20px;">
<h2>Saved Pipelines</h2>
<ul class="pipeline-list">
<template x-for="pipeline in pipelines" :key="pipeline.id">
<li class="pipeline-item"
:class="{ selected: selectedPipeline?.id === pipeline.id }"
@click="loadPipeline(pipeline)">
<div>
<strong x-text="pipeline.name"></strong>
<div style="font-size: 0.85em; color: #888; margin-top: 3px;">
<span x-text="pipeline.prompts.length"></span> prompts
</div>
</div>
<button @click.stop="deletePipeline(pipeline.id)" class="btn-danger" style="padding: 5px 10px;">
Delete
</button>
</li>
</template>
<li x-show="pipelines.length === 0" class="empty-state" style="padding: 20px;">
No saved pipelines
</li>
</ul>
</div>
</div>
<!-- Right Column: Execution -->
<div>
<div class="panel">
<h2>Execution</h2>
<div x-show="!currentRun" class="full-width">
<div class="run-info">
Selected: <span x-text="selectedPipeline?.name || 'None'">None</span>
</div>
<button @click="runPipeline"
class="btn-success"
style="width: 100%; padding: 15px; font-size: 16px;"
:disabled="!selectedPipeline">
▶️ Run Pipeline
</button>
</div>
<div x-show="currentRun" class="full-width">
<div class="run-info">
Run ID: <span x-text="currentRun.run_id"></span> |
Status: <span x-text="runStatus"></span>
</div>
<button @click="stopRun" class="btn-danger" style="width: 100%;">
⏹️ Stop & Close
</button>
<div class="results-grid">
<template x-for="(step, index) in steps" :key="index">
<div class="result-card">
<div class="result-header" @click="step.expanded = !step.expanded">
<span class="result-title" x-text="step.title || 'Step ' + (index + 1)"></span>
<span class="status-badge"
:class="'status-' + (step.status || 'pending')"
x-text="step.status || 'pending'">
</span>
</div>
<div class="result-body" x-show="step.expanded">
<template x-for="line in step.logs" :key="line.id">
<div class="log-line" :class="line.type" x-text="line.text"></div>
</template>
<div x-show="step.logs.length === 0" style="color: #666; font-style: italic;">
No output yet...
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function app() {
return {
newPipelineName: '',
newPromptText: '',
newPromptTitle: '',
prompts: [],
pipelines: [],
selectedPipeline: null,
currentRun: null,
steps: [],
ws: null,
init() {
this.loadPipelines();
this.$watch('prompts', () => {
this.prompts = this.prompts.map((p, i) => ({
...p,
order: i
}));
});
},
async loadPipelines() {
const res = await fetch('/api/pipelines');
this.pipelines = await res.json();
},
addPrompt() {
if (!this.newPromptText.trim() || !this.newPromptTitle.trim()) return;
this.prompts.push({
id: crypto.randomUUID(),
text: this.newPromptText,
title: this.newPromptTitle,
order: this.prompts.length
});
this.newPromptText = '';
this.newPromptTitle = '';
},
removePrompt(index) {
this.prompts.splice(index, 1);
},
clearPrompts() {
this.prompts = [];
this.newPipelineName = '';
},
async savePipeline() {
if (!this.newPipelineName.trim()) {
alert('Enter pipeline name');
return;
}
if (this.prompts.length === 0) {
alert('Add at least one prompt');
return;
}
const pipeline = {
id: crypto.randomUUID(),
name: this.newPipelineName,
prompts: this.prompts,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
await fetch('/api/pipelines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pipeline)
});
this.loadPipelines();
this.clearPrompts();
},
async deletePipeline(id) {
if (!confirm('Delete this pipeline?')) return;
await fetch(`/api/pipelines/${id}`, { method: 'DELETE' });
this.loadPipelines();
if (this.selectedPipeline?.id === id) {
this.selectedPipeline = null;
}
},
loadPipeline(pipeline) {
this.selectedPipeline = pipeline;
this.prompts = [...pipeline.prompts];
},
async runPipeline() {
if (!this.selectedPipeline) return;
const res = await fetch(`/api/pipelines/${this.selectedPipeline.id}/execute`, {
method: 'POST'
});
const data = await res.json();
this.currentRun = data;
this.steps = Array(this.selectedPipeline.prompts.length).fill(null).map((_, i) => ({
title: this.selectedPipeline.prompts[i].title,
status: 'pending',
logs: [],
expanded: true
}));
this.connectWebSocket(data.run_id);
},
connectWebSocket(runId) {
const wsUrl = `ws://${location.host}/ws/${runId}`;
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status') {
if (msg.step_index < this.steps.length) {
this.steps[msg.step_index] = {
...this.steps[msg.step_index],
status: msg.status,
result: msg.result
};
if (msg.result?.output) {
this.steps[msg.step_index].logs = [
...this.steps[msg.step_index].logs,
...this.parseOutput(msg.result.output)
];
}
if (msg.result?.error) {
this.steps[msg.step_index].logs = [
...this.steps[msg.step_index].logs,
...this.parseOutput(msg.result.error, 'error')
];
}
}
} else if (msg.type === 'log') {
const lines = msg.data.split('\n').filter(l => l.trim());
lines.forEach(line => {
const stepMatch = line.match(/\[Step (\d+)\/\d+\]/);
const stepIndex = stepMatch ? parseInt(stepMatch[1]) - 1 : -1;
if (stepIndex >= 0 && stepIndex < this.steps.length) {
this.steps[stepIndex].logs.push({
id: crypto.randomUUID(),
text: line,
type: this.getLogType(line)
});
}
});
}
};
this.ws.onclose = () => {
console.log('WebSocket closed');
};
},
parseOutput(text, type = 'default') {
return text.split('\n').filter(l => l.trim()).map(line => ({
id: crypto.randomUUID(),
text: line,
type: type
}));
},
getLogType(line) {
if (line.includes('Error:') || line.includes('error')) return 'error';
if (line.includes('Starting') || line.includes('Output:')) return 'info';
if (line.includes('completed') || line.includes('finished')) return 'success';
return 'default';
},
get runStatus() {
const statuses = this.steps.map(s => s.status).filter(Boolean);
if (statuses.includes('running')) return 'Running...';
if (statuses.includes('error')) return 'Failed';
if (statuses.length === this.steps.length && statuses.every(s => s === 'completed')) {
return 'Completed';
}
return 'In Progress';
},
stopRun() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.currentRun = null;
this.steps = [];
}
};
}
</script>
</body>
</html>