feat: initial commit - pipeline runner with real-time streaming
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user