Files
zaloopipe/pipeline-runner/static/index.html
T

433 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>