mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui/workflows): paused-run resume loop in workflow editor
This commit is contained in:
parent
2a803d2904
commit
247caaac3d
@ -825,6 +825,42 @@ export interface WorkflowRunStep {
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
/** Pause record carried inline on a paused run so the UI can resume
|
||||
* without a second roundtrip. Mirrors mate_workflow_run_pause. */
|
||||
export interface WorkflowRunPause {
|
||||
id: number
|
||||
runId: number
|
||||
stepId?: number
|
||||
pauseKind: string
|
||||
pauseToken: string
|
||||
externalApprovalId?: number
|
||||
pausedAt: string
|
||||
resumeDeadline?: string
|
||||
resumePayloadRef?: string
|
||||
resumedAt?: string
|
||||
resumeOutcome?: string
|
||||
}
|
||||
|
||||
export interface PausedRunSummary {
|
||||
run: WorkflowRun
|
||||
pause: WorkflowRunPause | null
|
||||
}
|
||||
|
||||
export interface RunDetail {
|
||||
run: WorkflowRun
|
||||
steps: WorkflowRunStep[]
|
||||
/** Most recent unresolved pause record; null when the run is not paused. */
|
||||
activePause: WorkflowRunPause | null
|
||||
}
|
||||
|
||||
export type ResumeOutcome = 'approved' | 'rejected' | 'timeout' | 'cancelled'
|
||||
|
||||
export interface ResumeResponse {
|
||||
kind: string
|
||||
runId?: number | null
|
||||
errorMessage?: string | null
|
||||
}
|
||||
|
||||
export const workflowApi = {
|
||||
list: (workspaceId: number) =>
|
||||
http.get<WorkflowSummary[]>('/workflows', { params: { workspaceId } }),
|
||||
@ -844,7 +880,17 @@ export const workflowApi = {
|
||||
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}`),
|
||||
http.get<RunDetail>(`/workflows/runs/${runId}`),
|
||||
/** List paused runs across the workspace so operators can resume them. */
|
||||
listPausedRuns: (limit = 50) =>
|
||||
http.get<PausedRunSummary[]>('/workflows/runs/paused', { params: { limit } }),
|
||||
/** Resume a paused workflow run with one of the four documented outcomes. */
|
||||
resumeRun: (runId: number, pauseToken: string, outcome: ResumeOutcome, payload?: string) =>
|
||||
http.post<ResumeResponse>(`/workflows/runs/${runId}/resume`, {
|
||||
pauseToken,
|
||||
outcome,
|
||||
payload,
|
||||
}),
|
||||
}
|
||||
|
||||
// ==================== Trigger ====================
|
||||
|
||||
@ -2015,6 +2015,20 @@ export default {
|
||||
deleteContent: 'Delete workflow "{name}"? This is reversible via the audit log.',
|
||||
},
|
||||
selectHint: 'Select a workflow on the left, or click "New Workflow" to start a fresh draft.',
|
||||
paused: {
|
||||
header: 'Paused runs ({count})',
|
||||
empty: 'No paused runs.',
|
||||
pauseTokenLabel: 'pause token',
|
||||
deadlineLabel: 'deadline',
|
||||
pausedAtLabel: 'paused at',
|
||||
runHash: 'Run #{id}',
|
||||
resumeApproved: 'Approve',
|
||||
resumeRejected: 'Reject',
|
||||
resumeTimeout: 'Time out',
|
||||
resumeCancelled: 'Cancel',
|
||||
resumeOk: 'Run resumed ({outcome}).',
|
||||
resumeFailed: 'Resume failed: {msg}',
|
||||
},
|
||||
runs: {
|
||||
header: 'Recent runs ({count})',
|
||||
empty: 'No runs yet.',
|
||||
|
||||
@ -2027,6 +2027,20 @@ export default {
|
||||
deleteContent: '将删除工作流「{name}」?此操作可通过审计日志撤销。',
|
||||
},
|
||||
selectHint: '在左侧选择一个工作流,或点击「新建工作流」开始。',
|
||||
paused: {
|
||||
header: '待恢复运行 ({count})',
|
||||
empty: '当前没有暂停中的运行。',
|
||||
pauseTokenLabel: 'pause token',
|
||||
deadlineLabel: '超时时间',
|
||||
pausedAtLabel: '暂停于',
|
||||
runHash: 'Run #{id}',
|
||||
resumeApproved: '通过',
|
||||
resumeRejected: '拒绝',
|
||||
resumeTimeout: '标记超时',
|
||||
resumeCancelled: '取消',
|
||||
resumeOk: '运行已恢复 ({outcome})。',
|
||||
resumeFailed: '恢复失败:{msg}',
|
||||
},
|
||||
runs: {
|
||||
header: '近期运行 ({count})',
|
||||
empty: '暂无运行记录。',
|
||||
|
||||
@ -122,6 +122,49 @@
|
||||
|
||||
<!-- right: runs -->
|
||||
<aside class="workflows-runs mc-surface-card" v-if="selected">
|
||||
<section v-if="pausedRuns.length" class="paused-section">
|
||||
<header class="paused-header">
|
||||
<span>{{ t('workflows.paused.header', { count: pausedRuns.length }) }}</span>
|
||||
<button class="btn-ghost" @click="reloadPausedRuns">{{ t('workflows.refresh') }}</button>
|
||||
</header>
|
||||
<ul class="paused-list">
|
||||
<li v-for="entry in pausedRuns" :key="entry.run.id" class="paused-row">
|
||||
<div class="paused-row-line">
|
||||
<span class="run-state state-paused">paused</span>
|
||||
<span class="paused-run-hash">{{ t('workflows.paused.runHash', { id: entry.run.id }) }}</span>
|
||||
</div>
|
||||
<div class="paused-meta" v-if="entry.pause">
|
||||
<div><span>{{ t('workflows.paused.pauseTokenLabel') }}:</span>
|
||||
<code class="pause-token">{{ truncateToken(entry.pause.pauseToken) }}</code></div>
|
||||
<div v-if="entry.pause.pausedAt">
|
||||
<span>{{ t('workflows.paused.pausedAtLabel') }}:</span> {{ formatTime(entry.pause.pausedAt) }}
|
||||
</div>
|
||||
<div v-if="entry.pause.resumeDeadline">
|
||||
<span>{{ t('workflows.paused.deadlineLabel') }}:</span> {{ formatTime(entry.pause.resumeDeadline) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="paused-actions" v-if="entry.pause">
|
||||
<button class="resume-btn approve" :disabled="resumingId === entry.run.id"
|
||||
@click="onResume(entry, 'approved')">
|
||||
{{ t('workflows.paused.resumeApproved') }}
|
||||
</button>
|
||||
<button class="resume-btn reject" :disabled="resumingId === entry.run.id"
|
||||
@click="onResume(entry, 'rejected')">
|
||||
{{ t('workflows.paused.resumeRejected') }}
|
||||
</button>
|
||||
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
|
||||
@click="onResume(entry, 'timeout')">
|
||||
{{ t('workflows.paused.resumeTimeout') }}
|
||||
</button>
|
||||
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
|
||||
@click="onResume(entry, 'cancelled')">
|
||||
{{ t('workflows.paused.resumeCancelled') }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<header class="runs-header">
|
||||
<span>{{ t('workflows.runs.header', { count: runs.length }) }}</span>
|
||||
<button class="btn-ghost" @click="reloadRuns">{{ t('workflows.refresh') }}</button>
|
||||
@ -165,6 +208,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
workflowApi,
|
||||
type WorkflowSummary,
|
||||
@ -172,6 +216,8 @@ import {
|
||||
type WorkflowRunStep,
|
||||
type WorkflowCompileError,
|
||||
type WorkflowCompileFailure,
|
||||
type PausedRunSummary,
|
||||
type ResumeOutcome,
|
||||
} from '@/api'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
|
||||
@ -201,6 +247,8 @@ const busy = ref(false)
|
||||
|
||||
const runs = ref<WorkflowRun[]>([])
|
||||
const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(null)
|
||||
const pausedRuns = ref<PausedRunSummary[]>([])
|
||||
const resumingId = ref<number | null>(null)
|
||||
|
||||
const templateChoice = ref('')
|
||||
|
||||
@ -377,6 +425,40 @@ async function reloadRuns() {
|
||||
} catch (e) {
|
||||
console.error('listRuns failed', e)
|
||||
}
|
||||
// Paused-run list spans the whole workspace, not just the selected
|
||||
// workflow — operators usually want the global queue when they reach
|
||||
// for "what's blocked right now". Refresh on every workflow switch
|
||||
// so the count accurately reflects the post-publish state.
|
||||
await reloadPausedRuns()
|
||||
}
|
||||
|
||||
async function reloadPausedRuns() {
|
||||
try {
|
||||
const res = await workflowApi.listPausedRuns(50)
|
||||
pausedRuns.value = (res.data as unknown as PausedRunSummary[]) ?? []
|
||||
} catch (e) {
|
||||
console.error('listPausedRuns failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function truncateToken(token: string | undefined): string {
|
||||
if (!token) return '-'
|
||||
return token.length <= 14 ? token : token.slice(0, 6) + '…' + token.slice(-4)
|
||||
}
|
||||
|
||||
async function onResume(entry: PausedRunSummary, outcome: ResumeOutcome) {
|
||||
if (!entry.pause?.pauseToken) return
|
||||
resumingId.value = entry.run.id
|
||||
try {
|
||||
await workflowApi.resumeRun(entry.run.id, entry.pause.pauseToken, outcome)
|
||||
ElMessage.success(t('workflows.paused.resumeOk', { outcome }))
|
||||
await reloadPausedRuns()
|
||||
await reloadRuns()
|
||||
} catch (e) {
|
||||
ElMessage.error(t('workflows.paused.resumeFailed', { msg: (e as Error).message }))
|
||||
} finally {
|
||||
resumingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRun(runId: number) {
|
||||
@ -554,8 +636,14 @@ function formatTime(iso?: string) {
|
||||
return iso.replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
watch(workspaceId, reload)
|
||||
onMounted(async () => {
|
||||
await reload()
|
||||
await reloadPausedRuns()
|
||||
})
|
||||
watch(workspaceId, async () => {
|
||||
await reload()
|
||||
await reloadPausedRuns()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -956,6 +1044,104 @@ button:disabled {
|
||||
gap: 6px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.paused-section {
|
||||
border-bottom: 1px dashed var(--mc-border-light, rgba(0, 0, 0, 0.08));
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.paused-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--mc-text-primary, inherit);
|
||||
}
|
||||
.paused-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.paused-row {
|
||||
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.03));
|
||||
border: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08));
|
||||
border-left: 3px solid var(--node-band, #ffb84d);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.paused-row-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.paused-run-hash {
|
||||
font-weight: 600;
|
||||
}
|
||||
.paused-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.paused-meta span {
|
||||
opacity: 0.6;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.pause-token {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.04));
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.paused-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.resume-btn {
|
||||
flex: 1 0 calc(50% - 2px);
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.resume-btn:hover:not(:disabled) {
|
||||
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.resume-btn.approve {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
border-color: rgba(46, 204, 113, 0.5);
|
||||
color: #1e8449;
|
||||
}
|
||||
.resume-btn.approve:hover:not(:disabled) {
|
||||
background: rgba(46, 204, 113, 0.22);
|
||||
}
|
||||
.resume-btn.reject {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
border-color: rgba(231, 76, 60, 0.5);
|
||||
color: var(--mc-danger, #c0392b);
|
||||
}
|
||||
.resume-btn.reject:hover:not(:disabled) {
|
||||
background: rgba(231, 76, 60, 0.18);
|
||||
}
|
||||
.resume-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive: collapse to a single column below 1100px so the editor
|
||||
can breathe on tablets, and stack everything vertically below 720px
|
||||
|
||||
Loading…
Reference in New Issue
Block a user