mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): add workflow editor + run viewer + trigger manager views
This commit is contained in:
parent
67c0efe8ae
commit
579e26ecc1
@ -768,3 +768,122 @@ export const hotCacheApi = {
|
||||
regenerate: (kbId: number) => http.post(`/wiki/hot-cache/${kbId}/regenerate`),
|
||||
reset: (kbId: number) => http.delete(`/wiki/hot-cache/${kbId}`),
|
||||
}
|
||||
|
||||
// ==================== Workflow ====================
|
||||
|
||||
export interface WorkflowSummary {
|
||||
id: number
|
||||
workspaceId: number
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
draftJson?: string
|
||||
draftUpdatedAt?: string
|
||||
latestRevisionId?: number
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface WorkflowCompileError {
|
||||
code: string
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface WorkflowCompileFailure {
|
||||
errorCount: number
|
||||
errors: WorkflowCompileError[]
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
id: number
|
||||
workflowId: number
|
||||
revisionId: number
|
||||
workspaceId: number
|
||||
state: string
|
||||
triggeredBy?: string
|
||||
initialInputRef?: string
|
||||
finalOutputRef?: string
|
||||
errorMessage?: string
|
||||
startedAt?: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export interface WorkflowRunStep {
|
||||
id: number
|
||||
runId: number
|
||||
stepIndex: number
|
||||
iterationIndex?: number
|
||||
stepName?: string
|
||||
state: string
|
||||
outputRef?: string
|
||||
outputSummary?: string
|
||||
outputContentType?: string
|
||||
errorMessage?: string
|
||||
durationMs?: number
|
||||
startedAt?: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export const workflowApi = {
|
||||
list: (workspaceId: number) =>
|
||||
http.get<WorkflowSummary[]>('/workflows', { params: { workspaceId } }),
|
||||
get: (id: number) => http.get<WorkflowSummary>(`/workflows/${id}`),
|
||||
create: (data: Partial<WorkflowSummary>) =>
|
||||
http.post<WorkflowSummary>('/workflows', data),
|
||||
update: (id: number, data: Partial<WorkflowSummary>) =>
|
||||
http.put<WorkflowSummary>(`/workflows/${id}`, data),
|
||||
delete: (id: number) => http.delete(`/workflows/${id}`),
|
||||
saveDraft: (id: number, draftJson: string, userId?: number) =>
|
||||
http.put(`/workflows/${id}/draft`, { draftJson }, { params: userId ? { userId } : {} }),
|
||||
compile: (id: number) => http.post(`/workflows/${id}/compile`),
|
||||
publish: (id: number, note?: string, userId?: number) =>
|
||||
http.post(`/workflows/${id}/publish`,
|
||||
note ? { note } : {},
|
||||
{ params: userId ? { userId } : {} }),
|
||||
runs: (id: number, limit = 50) =>
|
||||
http.get<WorkflowRun[]>(`/workflows/${id}/runs`, { params: { limit } }),
|
||||
runDetail: (runId: number) =>
|
||||
http.get<{ run: WorkflowRun; steps: WorkflowRunStep[] }>(`/workflows/runs/${runId}`),
|
||||
}
|
||||
|
||||
// ==================== Trigger ====================
|
||||
|
||||
export interface TriggerSummary {
|
||||
id: number
|
||||
workspaceId: number
|
||||
name?: string
|
||||
patternType: string
|
||||
patternJson: string
|
||||
targetType: string
|
||||
targetId: number
|
||||
payloadTemplate?: string
|
||||
rateLimitPerMin: number
|
||||
dedupWindowSecs: number
|
||||
botSelfFilter: boolean
|
||||
enabled: boolean
|
||||
fireCount: number
|
||||
maxFires: number
|
||||
lastFiredAt?: string
|
||||
patternVersion: number
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export const triggerApi = {
|
||||
list: (workspaceId: number) =>
|
||||
http.get<TriggerSummary[]>('/triggers', { params: { workspaceId } }),
|
||||
get: (id: number) => http.get<TriggerSummary>(`/triggers/${id}`),
|
||||
create: (data: Partial<TriggerSummary>) =>
|
||||
http.post<TriggerSummary>('/triggers', data),
|
||||
update: (id: number, data: Partial<TriggerSummary>) =>
|
||||
http.put<TriggerSummary>(`/triggers/${id}`, data),
|
||||
delete: (id: number) => http.delete(`/triggers/${id}`),
|
||||
ingestEvent: (envelope: {
|
||||
workspaceId: number
|
||||
patternType: string
|
||||
eventId?: string
|
||||
senderId?: string
|
||||
data?: Record<string, unknown>
|
||||
}) => http.post('/triggers/events', envelope),
|
||||
}
|
||||
|
||||
@ -164,6 +164,18 @@ const router = createRouter({
|
||||
component: () => import('@/views/CronJobs.vue'),
|
||||
meta: { title: 'Settings - Cron Jobs' },
|
||||
},
|
||||
{
|
||||
path: 'workflows',
|
||||
name: 'SettingsWorkflows',
|
||||
component: () => import('@/views/Workflows.vue'),
|
||||
meta: { title: 'Settings - Workflows' },
|
||||
},
|
||||
{
|
||||
path: 'triggers',
|
||||
name: 'SettingsTriggers',
|
||||
component: () => import('@/views/Triggers.vue'),
|
||||
meta: { title: 'Settings - Triggers' },
|
||||
},
|
||||
{
|
||||
path: 'datasources',
|
||||
name: 'SettingsDatasources',
|
||||
|
||||
@ -157,6 +157,18 @@ const sections = computed(() => [
|
||||
label: t('nav.cronJobs'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
path: '/settings/workflows',
|
||||
label: t('nav.workflows', 'Workflows'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'triggers',
|
||||
path: '/settings/triggers',
|
||||
label: t('nav.triggers', 'Triggers'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'datasources',
|
||||
path: '/settings/datasources',
|
||||
|
||||
355
mateclaw-ui/src/views/Triggers.vue
Normal file
355
mateclaw-ui/src/views/Triggers.vue
Normal file
@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="mc-page-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner triggers-page">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Triggers</div>
|
||||
<h1 class="mc-page-title">Workflow Triggers</h1>
|
||||
<p class="mc-page-desc">
|
||||
Connect cron schedules and channel events to your published workflows.
|
||||
Pattern_version increments on every cron expression change so multi-node
|
||||
deployments coordinate correctly.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreate">+ New Trigger</button>
|
||||
</div>
|
||||
|
||||
<table class="triggers-table mc-surface-card">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Pattern</th>
|
||||
<th>Target Workflow</th>
|
||||
<th>Rate</th>
|
||||
<th>Fires</th>
|
||||
<th>State</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in triggers" :key="t.id">
|
||||
<td>{{ t.name || '(unnamed)' }}</td>
|
||||
<td>
|
||||
<code>{{ t.patternType }}</code>
|
||||
<div class="pattern-detail">{{ t.patternJson }}</div>
|
||||
</td>
|
||||
<td>{{ t.targetType }}#{{ t.targetId }}</td>
|
||||
<td>{{ t.rateLimitPerMin }}/min</td>
|
||||
<td>{{ t.fireCount }}<span v-if="t.maxFires > 0"> / {{ t.maxFires }}</span></td>
|
||||
<td>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" :checked="t.enabled" @change="toggleEnabled(t)" />
|
||||
<span>{{ t.enabled ? 'enabled' : 'disabled' }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-ghost" @click="openEdit(t)">Edit</button>
|
||||
<button class="btn-danger" @click="remove(t)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!triggers.length">
|
||||
<td colspan="7" class="empty-row">No triggers configured.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Inline form (replaces modal for v0 simplicity) -->
|
||||
<section v-if="formOpen" class="form-card mc-surface-card">
|
||||
<header>
|
||||
<strong>{{ editing?.id ? 'Edit Trigger' : 'New Trigger' }}</strong>
|
||||
<button class="btn-ghost" @click="closeForm">Close</button>
|
||||
</header>
|
||||
<div class="form-grid">
|
||||
<label>Name <input v-model="formState.name" placeholder="hourly-cleanup" /></label>
|
||||
<label>Pattern type
|
||||
<select v-model="formState.patternType">
|
||||
<option value="cron">cron</option>
|
||||
<option value="channel_message">channel_message</option>
|
||||
<option value="webhook">webhook</option>
|
||||
<option value="workflow_completion">workflow_completion</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="span-2">Pattern JSON
|
||||
<textarea v-model="formState.patternJson" rows="3"
|
||||
placeholder='{"cron":"0 0 * * * *","timezone":"UTC"}' />
|
||||
</label>
|
||||
<label>Target type
|
||||
<select v-model="formState.targetType">
|
||||
<option value="workflow">workflow</option>
|
||||
<option value="agent">agent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Target id <input v-model.number="formState.targetId" type="number" /></label>
|
||||
<label>Rate / min <input v-model.number="formState.rateLimitPerMin" type="number" /></label>
|
||||
<label>Dedup window (s) <input v-model.number="formState.dedupWindowSecs" type="number" /></label>
|
||||
<label>Max fires (0 = unlimited)
|
||||
<input v-model.number="formState.maxFires" type="number" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="formState.botSelfFilter" />
|
||||
Bot-self filter (recommended)
|
||||
</label>
|
||||
<label class="span-2">Payload template (Pebble)
|
||||
<textarea v-model="formState.payloadTemplate" rows="3"
|
||||
placeholder='{"who":"{{ event.who }}"}' />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
<input type="checkbox" v-model="formState.enabled" />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<footer>
|
||||
<button class="btn-ghost" @click="closeForm">Cancel</button>
|
||||
<button class="btn-primary" :disabled="busy" @click="save">Save</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { triggerApi, type TriggerSummary } from '@/api'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
|
||||
const triggers = ref<TriggerSummary[]>([])
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editing = ref<TriggerSummary | null>(null)
|
||||
const busy = ref(false)
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
patternType: string
|
||||
patternJson: string
|
||||
targetType: string
|
||||
targetId: number
|
||||
rateLimitPerMin: number
|
||||
dedupWindowSecs: number
|
||||
botSelfFilter: boolean
|
||||
enabled: boolean
|
||||
maxFires: number
|
||||
payloadTemplate: string
|
||||
}
|
||||
|
||||
const formState = ref<FormState>(emptyForm())
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
name: '',
|
||||
patternType: 'cron',
|
||||
patternJson: '{"cron":"0 0 * * * *","timezone":"UTC"}',
|
||||
targetType: 'workflow',
|
||||
targetId: 0,
|
||||
rateLimitPerMin: 60,
|
||||
dedupWindowSecs: 60,
|
||||
botSelfFilter: true,
|
||||
enabled: true,
|
||||
maxFires: 0,
|
||||
payloadTemplate: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (!workspaceId.value) return
|
||||
try {
|
||||
const res = await triggerApi.list(workspaceId.value)
|
||||
triggers.value = (res.data as unknown as TriggerSummary[]) ?? []
|
||||
} catch (e) {
|
||||
console.error('listTriggers failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
formState.value = emptyForm()
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(t: TriggerSummary) {
|
||||
editing.value = t
|
||||
formState.value = {
|
||||
name: t.name ?? '',
|
||||
patternType: t.patternType,
|
||||
patternJson: t.patternJson,
|
||||
targetType: t.targetType,
|
||||
targetId: t.targetId,
|
||||
rateLimitPerMin: t.rateLimitPerMin ?? 60,
|
||||
dedupWindowSecs: t.dedupWindowSecs ?? 60,
|
||||
botSelfFilter: t.botSelfFilter ?? true,
|
||||
enabled: t.enabled,
|
||||
maxFires: t.maxFires ?? 0,
|
||||
payloadTemplate: t.payloadTemplate ?? '',
|
||||
}
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
formOpen.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!workspaceId.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const payload: Partial<TriggerSummary> = {
|
||||
workspaceId: workspaceId.value,
|
||||
...formState.value,
|
||||
}
|
||||
if (editing.value?.id) {
|
||||
await triggerApi.update(editing.value.id, payload)
|
||||
} else {
|
||||
await triggerApi.create(payload)
|
||||
}
|
||||
formOpen.value = false
|
||||
await reload()
|
||||
} catch (e) {
|
||||
window.alert('Save failed: ' + (e as Error).message)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(t: TriggerSummary) {
|
||||
try {
|
||||
await triggerApi.update(t.id, { ...t, enabled: !t.enabled })
|
||||
await reload()
|
||||
} catch (e) {
|
||||
window.alert('Toggle failed: ' + (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(t: TriggerSummary) {
|
||||
if (!window.confirm(`Delete trigger "${t.name || t.id}"?`)) return
|
||||
try {
|
||||
await triggerApi.delete(t.id)
|
||||
await reload()
|
||||
} catch (e) {
|
||||
window.alert('Delete failed: ' + (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
watch(workspaceId, reload)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.triggers-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.triggers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.triggers-table th,
|
||||
.triggers-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--mc-border, rgba(0, 0, 0, 0.06));
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.triggers-table th {
|
||||
font-weight: 600;
|
||||
background: var(--mc-surface, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.pattern-detail {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
padding: 24px;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-card {
|
||||
padding: 16px;
|
||||
}
|
||||
.form-card header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.form-grid .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-grid input,
|
||||
.form-grid select,
|
||||
.form-grid textarea {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.form-grid textarea {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-card footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-primary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--mc-primary, #4084ff);
|
||||
border-color: var(--mc-primary, #4084ff);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
border-color: rgba(231, 76, 60, 0.6);
|
||||
color: #c0392b;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
583
mateclaw-ui/src/views/Workflows.vue
Normal file
583
mateclaw-ui/src/views/Workflows.vue
Normal file
@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<div class="mc-page-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner workflows-page">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Workflows</div>
|
||||
<h1 class="mc-page-title">Workflow Editor</h1>
|
||||
<p class="mc-page-desc">
|
||||
Author multi-step workflows. Save the draft as you go, compile to surface diagnostics,
|
||||
then publish a revision when it is ready to run.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreate">+ New Workflow</button>
|
||||
</div>
|
||||
|
||||
<div class="workflows-grid">
|
||||
<!-- left: list -->
|
||||
<aside class="workflows-list mc-surface-card">
|
||||
<div class="list-header">
|
||||
<span>Defined ({{ workflows.length }})</span>
|
||||
<button class="btn-ghost" @click="reload">Refresh</button>
|
||||
</div>
|
||||
<ul class="list-body">
|
||||
<li
|
||||
v-for="wf in workflows"
|
||||
:key="wf.id"
|
||||
class="list-row"
|
||||
:class="{ active: selectedId === wf.id }"
|
||||
@click="select(wf.id)"
|
||||
>
|
||||
<div class="list-row-name">
|
||||
{{ wf.name || '(unnamed)' }}
|
||||
<span v-if="wf.latestRevisionId" class="badge published">v{{ wf.latestRevisionId }}</span>
|
||||
<span v-else class="badge draft">draft</span>
|
||||
</div>
|
||||
<div class="list-row-desc">{{ wf.description || '-' }}</div>
|
||||
</li>
|
||||
<li v-if="!workflows.length" class="list-empty">No workflows yet — create one to get started.</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- middle: editor -->
|
||||
<section class="workflows-editor mc-surface-card" v-if="selected">
|
||||
<header class="editor-header">
|
||||
<input v-model="selected.name" class="editor-name" placeholder="Workflow name" />
|
||||
<input v-model="selected.description" class="editor-desc" placeholder="Optional description" />
|
||||
<div class="editor-actions">
|
||||
<button class="btn-ghost" :disabled="busy" @click="saveMeta">Save Meta</button>
|
||||
<button class="btn-ghost" :disabled="busy" @click="saveDraft">Save Draft</button>
|
||||
<button class="btn-ghost" :disabled="busy" @click="compile">Compile</button>
|
||||
<button class="btn-primary" :disabled="busy" @click="publish">Publish</button>
|
||||
<button class="btn-danger" :disabled="busy" @click="remove">Delete</button>
|
||||
</div>
|
||||
</header>
|
||||
<textarea
|
||||
v-model="draftJson"
|
||||
class="editor-body"
|
||||
spellcheck="false"
|
||||
placeholder='{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}'
|
||||
/>
|
||||
<div v-if="compileErrors.length" class="errors-panel">
|
||||
<div class="errors-title">{{ compileErrors.length }} compile error(s)</div>
|
||||
<ul>
|
||||
<li v-for="(err, idx) in compileErrors" :key="idx">
|
||||
<code>{{ err.code }}</code>
|
||||
<span class="err-path">@ {{ err.path }}</span>
|
||||
<span class="err-msg">— {{ err.message }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="lastStatus" class="status-panel" :class="lastStatusKind">
|
||||
{{ lastStatus }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workflows-empty mc-surface-card" v-else>
|
||||
<p>Select a workflow on the left, or click "New Workflow" to start a fresh draft.</p>
|
||||
</section>
|
||||
|
||||
<!-- right: runs -->
|
||||
<aside class="workflows-runs mc-surface-card" v-if="selected">
|
||||
<header class="runs-header">
|
||||
<span>Recent runs ({{ runs.length }})</span>
|
||||
<button class="btn-ghost" @click="reloadRuns">Refresh</button>
|
||||
</header>
|
||||
<ul class="runs-list">
|
||||
<li v-for="run in runs" :key="run.id" class="run-row" @click="loadRun(run.id)">
|
||||
<div class="run-row-line">
|
||||
<span class="run-state" :class="'state-' + run.state">{{ run.state }}</span>
|
||||
<span class="run-time">{{ formatTime(run.startedAt) }}</span>
|
||||
</div>
|
||||
<div class="run-row-meta">
|
||||
<span>run #{{ run.id }}</span>
|
||||
<span v-if="run.triggeredBy">· {{ run.triggeredBy }}</span>
|
||||
<span v-if="run.errorMessage" class="run-err">· {{ run.errorMessage }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!runs.length" class="runs-empty">No runs yet.</li>
|
||||
</ul>
|
||||
<section v-if="runDetail" class="run-detail">
|
||||
<div class="run-detail-title">Run #{{ runDetail.run.id }} — {{ runDetail.run.state }}</div>
|
||||
<ol class="run-steps">
|
||||
<li v-for="step in runDetail.steps" :key="step.id">
|
||||
<span class="step-state" :class="'state-' + step.state">{{ step.state }}</span>
|
||||
<span class="step-name">{{ step.stepName || '(unnamed)' }}</span>
|
||||
<span v-if="step.durationMs != null" class="step-duration">{{ step.durationMs }} ms</span>
|
||||
<span v-if="step.errorMessage" class="step-err">{{ step.errorMessage }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
workflowApi,
|
||||
type WorkflowSummary,
|
||||
type WorkflowRun,
|
||||
type WorkflowRunStep,
|
||||
type WorkflowCompileError,
|
||||
type WorkflowCompileFailure,
|
||||
} from '@/api'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
|
||||
|
||||
const workflows = ref<WorkflowSummary[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const selected = ref<WorkflowSummary | null>(null)
|
||||
const draftJson = ref('')
|
||||
const compileErrors = ref<WorkflowCompileError[]>([])
|
||||
const lastStatus = ref('')
|
||||
const lastStatusKind = ref<'ok' | 'err'>('ok')
|
||||
const busy = ref(false)
|
||||
|
||||
const runs = ref<WorkflowRun[]>([])
|
||||
const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(null)
|
||||
|
||||
async function reload() {
|
||||
if (!workspaceId.value) return
|
||||
try {
|
||||
const res = await workflowApi.list(workspaceId.value)
|
||||
workflows.value = (res.data as unknown as WorkflowSummary[]) ?? []
|
||||
} catch (e) {
|
||||
console.error('listWorkflows failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id: number) {
|
||||
selectedId.value = id
|
||||
try {
|
||||
const res = await workflowApi.get(id)
|
||||
selected.value = res.data as unknown as WorkflowSummary
|
||||
draftJson.value = selected.value?.draftJson ?? ''
|
||||
compileErrors.value = []
|
||||
lastStatus.value = ''
|
||||
await reloadRuns()
|
||||
} catch (e) {
|
||||
console.error('getWorkflow failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadRuns() {
|
||||
if (!selectedId.value) return
|
||||
try {
|
||||
const res = await workflowApi.runs(selectedId.value, 50)
|
||||
runs.value = (res.data as unknown as WorkflowRun[]) ?? []
|
||||
} catch (e) {
|
||||
console.error('listRuns failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRun(runId: number) {
|
||||
try {
|
||||
const res = await workflowApi.runDetail(runId)
|
||||
runDetail.value = res.data as unknown as { run: WorkflowRun; steps: WorkflowRunStep[] }
|
||||
} catch (e) {
|
||||
console.error('getRun failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
if (!workspaceId.value) return
|
||||
const name = window.prompt('New workflow name:', 'untitled-workflow')
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await workflowApi.create({
|
||||
workspaceId: workspaceId.value,
|
||||
name,
|
||||
enabled: true,
|
||||
})
|
||||
const created = res.data as unknown as WorkflowSummary
|
||||
await reload()
|
||||
if (created?.id) await select(created.id)
|
||||
} catch (e) {
|
||||
setStatus('Create failed: ' + (e as Error).message, 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMeta() {
|
||||
if (!selected.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await workflowApi.update(selected.value.id, {
|
||||
name: selected.value.name,
|
||||
description: selected.value.description,
|
||||
enabled: selected.value.enabled,
|
||||
})
|
||||
setStatus('Metadata saved.', 'ok')
|
||||
await reload()
|
||||
} catch (e) {
|
||||
setStatus('Save failed: ' + (e as Error).message, 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!selected.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await workflowApi.saveDraft(selected.value.id, draftJson.value)
|
||||
setStatus('Draft saved.', 'ok')
|
||||
} catch (e) {
|
||||
setStatus('Save draft failed: ' + (e as Error).message, 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function compile() {
|
||||
if (!selected.value) return
|
||||
busy.value = true
|
||||
compileErrors.value = []
|
||||
try {
|
||||
await workflowApi.saveDraft(selected.value.id, draftJson.value)
|
||||
await workflowApi.compile(selected.value.id)
|
||||
setStatus('Compile OK.', 'ok')
|
||||
} catch (e) {
|
||||
handleCompileError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!selected.value) return
|
||||
busy.value = true
|
||||
compileErrors.value = []
|
||||
try {
|
||||
await workflowApi.saveDraft(selected.value.id, draftJson.value)
|
||||
const note = window.prompt('Optional publish note:', '') ?? undefined
|
||||
await workflowApi.publish(selected.value.id, note)
|
||||
setStatus('Published.', 'ok')
|
||||
await reload()
|
||||
} catch (e) {
|
||||
handleCompileError(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!selected.value) return
|
||||
if (!window.confirm(`Delete workflow "${selected.value.name}"? This is reversible via the audit log.`)) return
|
||||
busy.value = true
|
||||
try {
|
||||
await workflowApi.delete(selected.value.id)
|
||||
selected.value = null
|
||||
selectedId.value = null
|
||||
draftJson.value = ''
|
||||
await reload()
|
||||
setStatus('Deleted.', 'ok')
|
||||
} catch (e) {
|
||||
setStatus('Delete failed: ' + (e as Error).message, 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompileError(e: unknown) {
|
||||
// The HTTP layer rejects with an `Error` whose message is the body's
|
||||
// msg field. The structured errors list lives on the response body's
|
||||
// data field; we have to dig it out of the raw axios error if present.
|
||||
const err = e as { response?: { data?: { data?: WorkflowCompileFailure } }; message?: string }
|
||||
const failure = err.response?.data?.data
|
||||
if (failure?.errors?.length) {
|
||||
compileErrors.value = failure.errors
|
||||
setStatus(`${failure.errorCount} compile error(s) — see panel below.`, 'err')
|
||||
} else {
|
||||
setStatus(err.message || 'Compile / publish failed.', 'err')
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg: string, kind: 'ok' | 'err') {
|
||||
lastStatus.value = msg
|
||||
lastStatusKind.value = kind
|
||||
}
|
||||
|
||||
function formatTime(iso?: string) {
|
||||
if (!iso) return '-'
|
||||
return iso.replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
watch(workspaceId, reload)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflows-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.workflows-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 320px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
min-height: 480px;
|
||||
}
|
||||
.workflows-list,
|
||||
.workflows-editor,
|
||||
.workflows-runs,
|
||||
.workflows-empty {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.list-header,
|
||||
.runs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.list-body,
|
||||
.runs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.list-row,
|
||||
.run-row {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.list-row:hover,
|
||||
.run-row:hover {
|
||||
background: var(--mc-surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
.list-row.active {
|
||||
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.18));
|
||||
}
|
||||
.list-row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.list-row-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--mc-surface-hover, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
.badge.published {
|
||||
background: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
.badge.draft {
|
||||
background: #ffb84d;
|
||||
color: white;
|
||||
}
|
||||
.list-empty,
|
||||
.runs-empty {
|
||||
font-size: 13px;
|
||||
opacity: 0.6;
|
||||
padding: 12px 4px;
|
||||
}
|
||||
.editor-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.editor-name {
|
||||
flex: 0 0 200px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.editor-desc {
|
||||
flex: 1;
|
||||
}
|
||||
.editor-name,
|
||||
.editor-desc {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 6px;
|
||||
background: var(--mc-surface, rgba(0, 0, 0, 0.02));
|
||||
color: inherit;
|
||||
resize: vertical;
|
||||
min-height: 320px;
|
||||
}
|
||||
.errors-panel {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 80, 80, 0.08);
|
||||
border: 1px solid rgba(255, 80, 80, 0.4);
|
||||
}
|
||||
.errors-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.errors-panel ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
font-size: 12px;
|
||||
list-style: disc;
|
||||
}
|
||||
.errors-panel code {
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.err-path {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.err-msg {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.status-panel {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.status-panel.ok {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
border: 1px solid rgba(46, 204, 113, 0.4);
|
||||
}
|
||||
.status-panel.err {
|
||||
background: rgba(255, 80, 80, 0.08);
|
||||
border: 1px solid rgba(255, 80, 80, 0.4);
|
||||
}
|
||||
.runs-list {
|
||||
max-height: 340px;
|
||||
}
|
||||
.run-row-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
.run-state,
|
||||
.step-state {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.state-succeeded { background: rgba(46, 204, 113, 0.2); color: #1e8449; }
|
||||
.state-failed { background: rgba(231, 76, 60, 0.18); color: #c0392b; }
|
||||
.state-paused { background: rgba(255, 184, 77, 0.22); color: #b8730a; }
|
||||
.state-skipped { background: rgba(149, 165, 166, 0.22); color: #444; }
|
||||
.state-running { background: rgba(52, 152, 219, 0.18); color: #1a5276; }
|
||||
.run-row-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.run-err {
|
||||
color: #c0392b;
|
||||
}
|
||||
.run-detail {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--mc-surface, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.run-detail-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.run-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.run-steps li {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.step-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.step-duration,
|
||||
.step-err {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.step-err {
|
||||
color: #c0392b;
|
||||
}
|
||||
.btn-primary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--mc-primary, #4084ff);
|
||||
border-color: var(--mc-primary, #4084ff);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
border-color: rgba(231, 76, 60, 0.6);
|
||||
color: #c0392b;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.workflows-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user