diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index c4b9e90..bbb6f6f 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -38,7 +38,7 @@ 已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。 -控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。下一阶段继续补充分页详情和管理身份,不把页面操作视为绕过服务端事务边界的快捷入口。 +控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。流程实例详情已接入当前视图、步骤状态和有界事件时间线;结果订阅详情已接入配置、积压窗口和交付历史,权限不足时保留明确提示,不把页面操作视为绕过服务端事务边界的快捷入口。 定时计划详情已接入当前版本、规则、下一次触发和 occurrence 历史;暂停/恢复沿用 `schedules:manage`、`expected_revision`、原因和幂等键。计划发布、跨命名空间管理身份和更高风险的交付控制仍按独立权限模型推进。 diff --git a/docs/console.md b/docs/console.md index e82b044..5c8cdf5 100644 --- a/docs/console.md +++ b/docs/console.md @@ -14,6 +14,8 @@ http://127.0.0.1:8080/console/ - 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。 - 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。 - 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。 +- 点击流程实例查看当前权威状态、固定版本、步骤状态和事件时间线;步骤读取沿用 `workflows:read`,事件时间线没有 `workflows:audit:read` 时会明确显示权限提示。 +- 点击结果订阅查看冻结配置、游标、积压窗口和最近交付历史;页面不展示租约秘密,也不绕过 `events:read` / `events:manage` 权限。 - 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。 - 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。 - 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。 @@ -32,4 +34,4 @@ http://127.0.0.1:8080/console/ 控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。 -不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。后续再补独立管理身份、计划版本发布和交付重试等更高风险操作。 +不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、订阅交付重试和独立管理身份仍应通过受保护 API/SDK 设计后再接入页面。 diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 0050b0f..7ca2204 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -1,5 +1,5 @@ (() => { - const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null }; + const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, selectedSubscription: null }; const $ = (id) => document.getElementById(id); const text = (id, value) => { const el = $(id); if (el) el.textContent = value; }; const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' }; @@ -91,14 +91,22 @@ function renderWorkflows(items) { text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例'); const el = $('workflow-list'); if (!items.length) { el.innerHTML = '
⌘
当前没有流程实例需要 workflows:read 权限
'; return; } - el.innerHTML = items.map(item => `
⌘
${escapeHTML(item.business_key || shortID(item.id))}${escapeHTML(item.state || '—')}
${escapeHTML(formatTime(item.created_at))}
`).join(''); + el.innerHTML = items.map(item => `
⌘
${escapeHTML(item.business_key || shortID(item.id))}${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}
${escapeHTML(formatTime(item.created_at))}
`).join(''); + el.querySelectorAll('.workflow-row').forEach(row => { + row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId)); + row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } }); + }); } function renderSubscriptions(items) { text('metric-subscriptions', String(items.length)); const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`; text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅'); const el = $('subscription-list'); if (!items.length) { el.innerHTML = '
⌁
当前没有结果订阅需要 events:read 权限
'; return; } - el.innerHTML = items.map(item => `
⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${item.enabled === false ? '已暂停' : '已启用'}
`).join(''); + el.innerHTML = items.map(item => `
⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${item.enabled === false ? '已暂停' : '已启用'}
`).join(''); + el.querySelectorAll('.subscription-row').forEach(row => { + row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId)); + row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } }); + }); } function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function formatJSON(value) { @@ -207,6 +215,57 @@ catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; } finally { button.disabled = false; } } + function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); } + function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); } + function showSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); } + function closeSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); } + function renderWorkflowDetail(workflow) { + state.selectedWorkflow = workflow; + text('workflow-detail-title', workflow.business_key || '流程实例'); text('workflow-detail-id', workflow.id || '—'); text('workflow-detail-state', workflow.state || 'UNKNOWN'); $('workflow-detail-state').className = `state-pill ${stateClass(workflow.state)}`; + text('workflow-detail-updated', `最后更新 ${formatTime(workflow.updated_at)}`); text('workflow-detail-definition', workflow.definition_id || '—'); text('workflow-detail-version', workflow.version || '—'); text('workflow-detail-revision', workflow.revision || '—'); text('workflow-detail-control-revision', workflow.control_revision || '—'); text('workflow-detail-business-key', workflow.business_key || '—'); text('workflow-detail-deadline', formatTime(workflow.deadline_at)); + const outputBlock = $('workflow-output-block'); const hasOutput = Object.prototype.hasOwnProperty.call(workflow, 'output'); outputBlock.hidden = !hasOutput; if (hasOutput) $('workflow-output').textContent = formatJSON(workflow.output) || 'null'; + } + function renderWorkflowSteps(payload, error) { + const items = Array.isArray(payload?.steps) ? payload.steps : []; + text('workflow-steps-count', error ? '权限不足' : `${items.length} 个`); + renderDetailList('workflow-steps', items, error ? '当前凭据没有 workflows:read 步骤权限。' : '该实例还没有可展示的步骤。', item => { + const link = item.task_id ? `任务 ${shortID(item.task_id)}` : (item.activation_id ? `激活 ${shortID(item.activation_id)}` : '未激活'); + return `
${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}${escapeHTML(item.state || 'UNKNOWN')} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}
${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}
`; + }); + } + function renderWorkflowEvents(payload, error) { + const items = Array.isArray(payload?.items) ? payload.items : []; + text('workflow-events-count', error ? '权限不足' : `${items.length} 条`); + renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `
${escapeHTML(item.type || '状态事件')} · ${escapeHTML(item.state || '—')}${escapeHTML(item.state_scope || item.aggregate_kind || 'WORKFLOW')} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}
修订 ${escapeHTML(item.aggregate_revision || '—')}
`); + } + async function openWorkflowDetail(id) { + if (!id) return; + if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; } + showWorkflowDrawer(); text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '
正在读取步骤…
'; $('workflow-events').innerHTML = '
正在读取事件…
'; + const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]); + if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; } + renderWorkflowDetail(extractData(workflowResult.value)); + renderWorkflowSteps(stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {}, stepsResult.status === 'rejected'); + renderWorkflowEvents(eventsResult.status === 'fulfilled' ? extractData(eventsResult.value) : {}, eventsResult.status === 'rejected'); + } + function renderSubscriptionDetail(subscription) { + state.selectedSubscription = subscription; + text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`; + text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型'); + } + function renderSubscriptionDeliveries(payload, error) { + const items = Array.isArray(payload?.items) ? payload.items : []; + text('subscription-deliveries-count', error ? '权限不足' : `${items.length} 条`); + renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `
${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}
${escapeHTML(shortID(item.event_id))}
`); + } + async function openSubscriptionDetail(id) { + if (!id) return; + if (!state.token) { showToast('请先连接具备 events:read 权限的凭据'); return; } + showSubscriptionDrawer(); text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '
正在读取交付历史…
'; + const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]); + if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; } + renderSubscriptionDetail(extractData(subscriptionResult.value)); renderSubscriptionDeliveries(deliveriesResult.status === 'fulfilled' ? extractData(deliveriesResult.value) : {}, deliveriesResult.status === 'rejected'); + } async function refresh() { if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning'); try { @@ -230,7 +289,7 @@ sessionStorage.setItem('orbit.console.queue', state.queue); refresh(); } $('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', () => $('connection-panel').scrollIntoView({ behavior: 'smooth', block: 'center' })); $('refresh-button').addEventListener('click', refresh); - $('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); } }); + $('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } }); $('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; }); $('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); $('token').value = credential.token || ''; $('queue-id').value = credential.queue_id || ''; showToast('凭据已读取,只保留在当前页面内'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); }); document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); })); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index fd7b333..c43551c 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -178,6 +178,48 @@ + +
diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css index 8a8ec11..05f9c3f 100644 --- a/internal/transport/httpserver/console/styles.css +++ b/internal/transport/httpserver/console/styles.css @@ -2,3 +2,4 @@ [hidden]{display:none!important}.task-row{cursor:pointer;transition:background .2s}.task-row:hover,.task-row:focus{outline:0;background:rgba(98,230,212,.055)}.task-row:focus td:first-child{box-shadow:inset 2px 0 0 var(--cyan)}.drawer-open{overflow:hidden}.drawer-backdrop{position:fixed;inset:0;z-index:20;background:rgba(2,8,16,.68);backdrop-filter:blur(8px)}.task-drawer{position:absolute;right:0;top:0;height:100%;width:min(570px,100%);overflow-y:auto;padding:29px 28px 34px;background:linear-gradient(165deg,#122943,#091828 70%);border-left:1px solid rgba(98,230,212,.18);box-shadow:-22px 0 70px rgba(0,0,0,.35);animation:drawer-in .24s ease-out}@keyframes drawer-in{from{transform:translateX(35px);opacity:.4}to{transform:translateX(0);opacity:1}}.drawer-header{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.drawer-header h2{margin:0 0 8px;font-size:22px;letter-spacing:-.03em}.drawer-id{display:block;max-width:390px;color:#6f87a2;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.drawer-status-line{display:flex;align-items:center;gap:11px;margin:22px 0 19px;color:#748aa4;font-size:10px}.detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;border:1px solid var(--line);border-radius:15px;overflow:hidden;margin-bottom:18px}.detail-grid>div{padding:13px 14px;background:rgba(5,15,27,.34);min-width:0}.detail-grid span{display:block;color:#637b96;font-size:9px;margin-bottom:6px}.detail-grid b{display:block;color:#d4e2f1;font-size:11px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-block{border-top:1px solid rgba(156,183,218,.1);padding-top:19px;margin-top:19px}.detail-block-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;color:#b4c7db;font-size:12px}.detail-block-heading small{color:#617995;font-size:9px}.detail-list{display:grid;gap:0}.detail-list-row{position:relative;display:flex;align-items:center;gap:11px;padding:11px 0;border-bottom:1px solid rgba(156,183,218,.08)}.detail-list-row:last-child{border-bottom:0}.detail-list-row>div{min-width:0;flex:1}.detail-list-row b{display:block;color:#bdcfe1;font-size:10px;font-weight:500}.detail-list-row small{display:block;margin-top:4px;color:#6d829c;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-list-row em{font-style:normal;color:#778da7;font-size:9px}.timeline-dot{width:8px;height:8px;flex:0 0 8px;border-radius:50%;background:#657991;box-shadow:0 0 0 4px rgba(101,121,145,.1)}.timeline-dot.succeeded{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.running{background:var(--blue);box-shadow:0 0 0 4px rgba(112,166,255,.1)}.timeline-dot.failed,.timeline-dot.unknown{background:var(--red);box-shadow:0 0 0 4px rgba(255,120,140,.1)}.timeline-dot.audit{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.detail-loading,.detail-empty{padding:17px 0;color:#607895;font-size:10px}.detail-block pre{max-height:180px;overflow:auto;margin:0;padding:12px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,11,20,.48);color:#a7e9df;font:10px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word}.drawer-actions{display:flex;gap:10px;margin-top:23px;padding-top:19px;border-top:1px solid rgba(156,183,218,.1)}.danger-button{color:#ff9baa;border-color:rgba(255,120,140,.25);background:rgba(255,120,140,.07)}.danger-button:hover{color:#ffd5db;border-color:rgba(255,120,140,.5)}.drawer-note{color:#687f99;font-size:9px;line-height:1.7;margin:15px 0 0}.action-form{margin-top:21px;padding:19px;border:1px solid rgba(98,230,212,.17);border-radius:16px;background:rgba(4,14,25,.45)}.action-form-heading h3{margin:0;color:#d7e6f4;font-size:15px}.action-form p{color:#7890a9;font-size:10px;line-height:1.6;margin:10px 0 4px}.action-form textarea,.action-form input{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px;resize:vertical}.action-form textarea:focus,.action-form input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.check-row{display:flex;gap:9px;align-items:flex-start;margin-top:15px;color:#9eb3c9;font-size:10px;line-height:1.5}.check-row input{accent-color:var(--cyan);margin-top:2px}.action-form-buttons{display:flex;justify-content:flex-end;gap:9px;margin-top:18px}.action-error{min-height:15px;color:#ff9baa;font-size:10px;margin-top:9px}.action-form .quiet-button{padding:10px 14px}@media(max-width:780px){.task-drawer{width:100%;padding:24px 18px 28px}.detail-grid{grid-template-columns:1fr 1fr}} .schedule-row{cursor:pointer;transition:background .2s;border-radius:10px;padding-left:7px;padding-right:7px;margin-left:-7px;margin-right:-7px}.schedule-row:hover,.schedule-row:focus{outline:0;background:rgba(98,230,212,.055)}.schedule-rule-card{padding:14px;border:1px solid rgba(156,183,218,.1);border-radius:12px;background:rgba(4,12,23,.38)}.schedule-rule-card b{display:block;color:#c8e5e6;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-word}.schedule-rule-card small{display:block;margin-top:7px;color:#7189a1;font-size:9px}.timeline-dot.fired{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.coalesced{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.timeline-dot.skipped{background:var(--orange);box-shadow:0 0 0 4px rgba(255,179,107,.1)} .page-view{display:none}.page-view.active{display:block}.content-grid.single-column{grid-template-columns:1fr}.page-view>.content-grid{min-height:360px}.page-view>.content-grid>.panel{min-height:330px}.page-view>.content-grid>.wide-panel{min-height:420px} +.workflow-row,.subscription-row{cursor:pointer;border-radius:10px;padding-left:7px;padding-right:7px;margin-left:-7px;margin-right:-7px;transition:background .2s}.workflow-row:hover,.workflow-row:focus,.subscription-row:hover,.subscription-row:focus{outline:0;background:rgba(98,230,212,.055)}.workflow-row:focus,.subscription-row:focus{box-shadow:inset 2px 0 0 var(--cyan)}.timeline-dot.leased{background:var(--blue);box-shadow:0 0 0 4px rgba(112,166,255,.1)}.timeline-dot.acked{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.expired{background:var(--orange);box-shadow:0 0 0 4px rgba(255,179,107,.1)}.timeline-dot.cancelled{background:var(--red);box-shadow:0 0 0 4px rgba(255,120,140,.1)}.workflow-drawer,.subscription-drawer{width:min(640px,100%)}