feat(ui): add workflow editor + run viewer + trigger manager views

This commit is contained in:
matevip 2026-05-08 15:05:04 +08:00
parent 67c0efe8ae
commit 579e26ecc1
5 changed files with 1081 additions and 0 deletions

View File

@ -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),
}

View File

@ -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',

View File

@ -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',

View 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>

View 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>