mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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`),
|
regenerate: (kbId: number) => http.post(`/wiki/hot-cache/${kbId}/regenerate`),
|
||||||
reset: (kbId: number) => http.delete(`/wiki/hot-cache/${kbId}`),
|
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'),
|
component: () => import('@/views/CronJobs.vue'),
|
||||||
meta: { title: 'Settings - Cron Jobs' },
|
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',
|
path: 'datasources',
|
||||||
name: 'SettingsDatasources',
|
name: 'SettingsDatasources',
|
||||||
|
|||||||
@ -157,6 +157,18 @@ const sections = computed(() => [
|
|||||||
label: t('nav.cronJobs'),
|
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>',
|
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',
|
id: 'datasources',
|
||||||
path: '/settings/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