Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/job-observation-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@inkcre/core': patch
'@inkcre/web': patch
---

修复 Job 详情的观测时间范围与成功提交后的弹窗关闭。详情明确展示信息库中的提交事实,历史诊断支持带时钟余量的默认窗口和最长 31 天的绝对范围,并说明部分生命周期覆盖。Core 导出单次查询宽度常量供界面复用。
6 changes: 5 additions & 1 deletion apps/web/src/components/obsrv/LogsViewer/LogsViewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Job 内嵌观测视图,复用 ObservationExplorer 和 Core 的统一查询接口,不直接访问 PostgreSQL 或选择外部供应商。

`jobId` 同时关联新结构化事件和历史 job.N 日志;`since` 使用 Job 创建时间,旧任务不因默认最近窗口而被遗漏。也保留独立 `traceId` 查询。
`jobId` 同时关联新结构化事件和历史 job.N 日志;默认窗口从创建前一小时延至结束后一小时,给不同 Peer 的时钟偏差留出余量。进行中的作业以刷新时间估算结束端。超过 31 天时显示末段,可在关联筛选选择更早的绝对窗口。也保留独立 `traceId` 查询。

查询有界,记录过多时提示缩窄范围;不承诺全量历史。`enablePolling` 与 `pollingInterval` 控制刷新。连接或业务范围变化使在途响应失效;刷新失败保留同范围已有记录并提示重试。

Job 页面从信息库中的 Job 行展示“提交已保存”、当前状态与创建、开始、结束时间。
这些信息可由其他 Peer 在提交方退出后读取。观测列表和摘要只消费实际返回的记录,
查询为空时仍显示该 Job 的持久信息。
4 changes: 2 additions & 2 deletions apps/web/src/components/obsrv/LogsViewer/LogsViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ export const logsViewerProps = {
/** Business Job correlation also includes legacy job.N log records. */
jobId: { type: Number, default: undefined },
traceId: { type: String, default: undefined },
/** Job creation time keeps an old Job's history in its query window. */
since: { type: String, default: undefined },
createdAt: { type: String, default: undefined },
closedAt: { type: String, default: undefined },
pollingInterval: { type: Number, default: 5000 },
enablePolling: { type: Boolean, default: true },
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/obsrv/LogsViewer/LogsViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ defineEmits(logsViewerEmits)
embedded
:job-id="props.jobId"
:trace-id="props.traceId"
:since="props.since"
:job-created-at="props.createdAt"
:job-closed-at="props.closedAt"
:enable-polling="props.enablePolling"
:polling-interval="props.pollingInterval"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,10 @@
- 查询是有界时间窗口,截断时要求缩窄范围;摘要只统计已返回记录。
- 连接或范围变化使在途结果失效;刷新失败保留同一范围的已有结果并标明陈旧。
- 日志、事件和操作记录共用明细与关联筛选;业务 Job 状态始终来自 Job 页面。
- `jobId`、`traceId`、`since` 限制嵌入范围;`enablePolling`/`pollingInterval` 控制刷新。
- `jobId` 和 `traceId` 限制嵌入范围,`jobCreatedAt` 和 `jobClosedAt` 为默认时间窗提供作业生命周期;`enablePolling`/`pollingInterval` 控制刷新。
- `embedded` 收起全局导航标题和关联筛选,保留时间、严重度、错误与后端状态。

Job 默认范围在创建至结束的时间段两端各留一小时余量,运行中以刷新时间作为结束端。
一小时是可调整的初始范围。每次查询最多 31 天,默认超长范围取靠近结束端的末段并提示更早阶段。
绝对时间输入支持查询任意历史时段,手动窗口覆盖部分生命周期时也显示范围提示。
记录保留由当前后端配置决定。结束时间更新触发一次刷新,之后仍可手动刷新读取晚到记录。
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import { OBSERVATION_MAX_WINDOW_MS } from '@inkcre/core'

export interface ObservationExplorerProps {
embedded?: boolean
jobId?: number
traceId?: string
since?: string
jobCreatedAt?: string
jobClosedAt?: string
enablePolling?: boolean
pollingInterval?: number
}

const hour = 60 * 60 * 1000

/** Job state and observation timestamps can come from different Peer clocks. */
export function defaultObservationWindow(
now: number,
hours: number,
jobCreatedAt?: string,
jobClosedAt?: string
): { since: string; until: string; limited: boolean } {
let until = now
let since = now - hours * hour
if (jobCreatedAt) {
const created = Date.parse(jobCreatedAt)
const end = jobClosedAt ? Date.parse(jobClosedAt) : now
until = Math.max(created, end) + hour
since = Math.min(created, end) - hour
}
const limited = until - since > OBSERVATION_MAX_WINDOW_MS
since = Math.max(since, until - OBSERVATION_MAX_WINDOW_MS)
return { since: new Date(since).toISOString(), until: new Date(until).toISOString(), limited }
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { InkButton, InkDropdown, InkForm, InkInput, InkLoading } from '@inkcre/u
import {
configStore,
createObservabilityClient,
OBSERVATION_MAX_WINDOW_MS,
type ObservationPage,
type ObservationQuery,
type ObservationRecord,
type ObservabilityStatus,
} from '@inkcre/core'
import type { ObservationExplorerProps } from './ObservationExplorer'
import { defaultObservationWindow, type ObservationExplorerProps } from './ObservationExplorer'

const props = withDefaults(defineProps<ObservationExplorerProps>(), {
embedded: false,
Expand All @@ -31,6 +32,7 @@ const chronological = ref(false)
const customSince = ref('')
const customUntil = ref('')
const page = ref<ObservationPage | null>(null)
const windowLimited = ref(false)
const status = ref<ObservabilityStatus | null>(null)
const loading = ref(false)
const error = ref(false)
Expand Down Expand Up @@ -67,6 +69,15 @@ const meanDuration = computed(() =>
: null
)
const history = computed(() => status.value?.history)
const partialJobWindow = computed(() => {
if (!page.value || !props.jobCreatedAt) return false
const created = Date.parse(props.jobCreatedAt)
const end = props.jobClosedAt ? Date.parse(props.jobClosedAt) : Date.now()
return (
Math.min(created, end) < Date.parse(page.value.since) ||
Math.max(created, end) >= Date.parse(page.value.until)
)
})
const windowOptions = computed(() => [
{ value: 1, label: t('observability.lastHour') },
{ value: 24, label: t('observability.lastDay') },
Expand Down Expand Up @@ -109,9 +120,14 @@ async function refresh(): Promise<void> {
}
}
const current = generation
const until = appliedUntil ?? new Date().toISOString()
const since =
appliedSince ?? props.since ?? new Date(Date.now() - appliedHours * 3600000).toISOString()
const defaultWindow = defaultObservationWindow(
Date.now(),
appliedHours,
props.jobCreatedAt,
props.jobClosedAt
)
const since = appliedSince ?? defaultWindow.since
const until = appliedUntil ?? defaultWindow.until
loading.value = true
error.value = false
statusError.value = false
Expand All @@ -123,8 +139,10 @@ async function refresh(): Promise<void> {
if (current !== generation) return
loading.value = false
hasLoaded.value = true
if (queryResult.status === 'fulfilled') page.value = queryResult.value
else error.value = true
if (queryResult.status === 'fulfilled') {
page.value = queryResult.value
windowLimited.value = appliedSince === null && defaultWindow.limited
} else error.value = true
if (statusResult.status === 'fulfilled') status.value = statusResult.value
else statusError.value = true
}
Expand All @@ -139,7 +157,10 @@ function apply(): void {
const end = Date.parse(customUntil.value)
if (
(customSince.value || customUntil.value) &&
(!Number.isFinite(start) || !Number.isFinite(end) || start >= end)
(!Number.isFinite(start) ||
!Number.isFinite(end) ||
start >= end ||
end - start > OBSERVATION_MAX_WINDOW_MS)
) {
validationError.value = t('observability.invalidWindow')
return
Expand Down Expand Up @@ -182,6 +203,14 @@ const { pause, resume } = useIntervalFn(
() => props.pollingInterval,
{ immediate: false }
)
watch(
() => props.jobClosedAt,
() => {
generation++
loading.value = false
void refresh()
}
)
watch(
() => props.enablePolling,
(enabled) => (enabled ? resume() : pause()),
Expand All @@ -194,7 +223,7 @@ watch(
configStore.metaConfig.INKCRE_PEER_ID,
props.jobId,
props.traceId,
props.since,
props.jobCreatedAt,
],
() => {
generation++
Expand Down Expand Up @@ -265,7 +294,7 @@ onUnmounted(() => {
<InkForm layout="col" @submit="apply">
<div class="observation-explorer__filters">
<InkDropdown
v-if="!since"
v-if="!jobCreatedAt"
v-model="hours"
:label="t('observability.window')"
:options="windowOptions"
Expand All @@ -279,7 +308,7 @@ onUnmounted(() => {
<InkInput v-model="text" :label="t('observability.search')" />
</div>
<details class="observation-explorer__correlation">
<summary>{{ t('observability.correlate') }}</summary>
<summary>{{ t(embedded ? 'observability.timeRange' : 'observability.correlate') }}</summary>
<div v-if="!embedded" class="observation-explorer__filters">
<InkInput
v-model="job"
Expand All @@ -292,6 +321,9 @@ onUnmounted(() => {
<InkInput v-model="operation" :label="t('observability.operation')" />
</div>
<p class="observation-explorer__hint">{{ t('observability.absoluteWindow') }}</p>
<p v-if="jobCreatedAt" class="observation-explorer__hint">
{{ t('observability.jobWindow') }}
</p>
<div class="observation-explorer__filters">
<InkInput
v-model="customSince"
Expand Down Expand Up @@ -346,6 +378,12 @@ onUnmounted(() => {
</div>
</dl>
<p>{{ t('observability.sampleNote') }}</p>
<p v-if="windowLimited" class="observation-explorer__warning" role="status">
{{ t('observability.windowLimited') }}
</p>
<p v-else-if="partialJobWindow" class="observation-explorer__warning" role="status">
{{ t('observability.partialJobWindow') }}
</p>
<p v-if="page.truncated" class="observation-explorer__warning" role="status">
{{ t('observability.truncated') }}
</p>
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/locales/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,9 @@
"notAvailable": "N/A",
"notFound": "Job not found",
"sourceNotFound": "Source not found",
"openDiagnostics": "Open diagnostics (search by Job ID)"
"openDiagnostics": "Open diagnostics (search by Job ID)",
"submissionSaved": "Submission saved. The status and times below come from the Job record in the info-base.",
"currentStatus": "Current status"
},
"logs": {
"loading": "Loading logs…",
Expand Down Expand Up @@ -503,22 +505,26 @@
"errors": "Error records",
"meanDuration": "Mean observed operation duration",
"notObserved": "Not observed",
"sampleNote": "These summaries describe returned records only, not deployment-wide error rates, totals or complete performance metrics.",
"sampleNote": "Counts and mean duration are calculated from the returned records.",
"truncated": "The result limit was reached. Narrow the time window or add correlation filters. This timeline may be incomplete.",
"loadFailed": "Observation query failed. Check the filters, connection and backend configuration, then retry. A failed query is not an empty result.",
"keptResults": "Previously returned records remain below and have not been refreshed.",
"empty": "No observation records in this window.",
"emptyExplanation": "This does not prove that no work ran. Older Jobs, disabled recording or expired data may have no records.",
"emptyExplanation": "Adjust the time range or filters. Job details show the submission, status and lifecycle times stored in the info-base.",
"records": "Observation records",
"openJob": "Open Job #{id}",
"sameTrace": "Filter this Trace",
"samePeer": "Filter this Peer",
"sameOperation": "Filter this operation",
"attributes": "Record attributes",
"absoluteWindow": "Optional absolute window (local time). Fill both fields to override the time shortcut; clear them to return to the shortcut.",
"absoluteWindow": "Query any historical period, up to 31 days per request. Times use your local timezone. Enter both times to override the default range, or clear them to restore it. Record retention follows the selected backend configuration.",
"since": "From",
"until": "Until",
"invalidWindow": "Enter both dates, with the end after the start.",
"configuration": "Configuration"
"invalidWindow": "Enter both times, with the end after the start and a range of at most 31 days.",
"configuration": "Configuration",
"timeRange": "Time range",
"jobWindow": "The default range adds one hour before Job creation and after closure. Active Jobs use the refresh time for the end. Choose an absolute range to look earlier or later.",
"windowLimited": "The default range covers the 31 days nearest the end. Use Time range to query earlier stages in separate windows.",
"partialJobWindow": "This query covers part of the Job lifecycle. Adjust the time range to inspect earlier or later stages."
}
}
18 changes: 12 additions & 6 deletions apps/web/src/locales/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,9 @@
"notAvailable": "不可用",
"notFound": "未找到任务",
"sourceNotFound": "未找到数据源",
"openDiagnostics": "打开诊断入口(使用 Job ID 查询)"
"openDiagnostics": "打开诊断入口(使用 Job ID 查询)",
"submissionSaved": "提交已保存。下列状态和时间来自信息库中的 Job 记录。",
"currentStatus": "当前状态"
},
"logs": {
"loading": "正在读取日志…",
Expand Down Expand Up @@ -503,22 +505,26 @@
"errors": "错误记录",
"meanDuration": "已观测操作平均耗时",
"notObserved": "未观测到",
"sampleNote": "仅统计当前返回记录,不代表整个部署的错误率、总量或完整性能指标。",
"sampleNote": "数量与平均耗时依据当前返回记录计算。",
"truncated": "结果已达到数量上限。请缩窄时间窗口或增加关联筛选;当前时间线可能不完整。",
"loadFailed": "观测查询失败。请检查筛选条件、当前连接及后端配置后重试;失败不是空结果。",
"keptResults": "下方保留上次成功返回的记录,尚未刷新。",
"empty": "当前范围内没有观测记录。",
"emptyExplanation": "这不能证明业务没有执行。旧任务、关闭采集或已过保留期的数据可能没有记录。",
"emptyExplanation": "可调整时间范围或筛选条件,并在 Job 详情查看信息库中的提交、状态和起止时间。",
"records": "观测记录",
"openJob": "打开 Job #{id}",
"sameTrace": "筛选同一 Trace",
"samePeer": "筛选同一 Peer",
"sameOperation": "筛选同一操作",
"attributes": "记录属性",
"absoluteWindow": "可选的绝对时间范围(本地时间)。填写两个时间会覆盖快捷窗口;清空后恢复快捷窗口。",
"absoluteWindow": "可查询任意历史时间段,每次最长 31 天。时间采用本地时区。填写起止时间后覆盖默认范围,清空可恢复默认范围。记录保留由当前后端配置决定。",
"since": "开始时间",
"until": "结束时间",
"invalidWindow": "请填写完整时间范围,结束时间必须晚于开始时间。",
"configuration": "配置"
"invalidWindow": "请填写完整时间范围,结束时间须晚于开始时间,每次最多查询 31 天。",
"configuration": "配置",
"timeRange": "时间范围",
"jobWindow": "默认范围在 Job 创建至结束的时间段前后各留 1 小时余量。进行中的 Job 使用刷新时间作为结束端,可选择更早或更晚的绝对范围。",
"windowLimited": "默认范围已收窄为靠近结束端的 31 天。更早的阶段可通过“时间范围”分段查询。",
"partialJobWindow": "当前查询只覆盖 Job 生命周期的一部分,可调整时间范围查看更早或更晚的阶段。"
}
}
6 changes: 6 additions & 0 deletions apps/web/src/views/jobs/job/job.scss
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
}

.metadata {
&__notice {
@include apply-font(body-sm);
color: sys-var(color, text, subtle);
margin: 0;
}

&__header {
display: flex;
justify-content: space-between;
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/views/jobs/job/job.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ watch(
<h1 class="metadata__title">{{ t('navigation.job') }} #{{ job.id }}</h1>
</div>

<p class="metadata__notice">{{ t('job.submissionSaved') }}</p>

<InkField :label="t('job.jobId')">
<span class="metadata__value">{{ job.id }}</span>
</InkField>
Expand All @@ -161,7 +163,7 @@ watch(
>{{ t('job.openDiagnostics') }}</a
>

<InkField :label="t('job.status')">
<InkField :label="t('job.currentStatus')">
<div class="flex items-center gap-2" :aria-busy="jobLoading">
<span class="metadata__value" :class="statusColor">
{{ job.status }}
Expand Down Expand Up @@ -223,7 +225,8 @@ watch(
v-if="job"
class="flex-1 w-full"
:job-id="job.id"
:since="job.created_at.toISOString()"
:created-at="job.created_at.toISOString()"
:closed-at="job.closed_at?.toISOString()"
:enable-polling="enableLogsPolling"
:polling-interval="pollingInterval"
/>
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/views/sources/source/source.vue
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,11 @@ async function onDeleteSource() {
<p v-if="deleteError" role="alert" class="text-feedback-error">{{ deleteError }}</p>
</InkDialog>

<InkPopup v-model:open="newJobOpen" :title="t('source.newJobTitle')" @close="onCloseJob">
<InkPopup
:open="newJobOpen"
:title="t('source.newJobTitle')"
@update:open="!$event && onCloseJob()"
>
<InkForm @submit="onCreateJob">
<p class="source-view__popup-description">{{ t('source.newJobDescription') }}</p>
<InkDropdown
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/views/sources/source/use-job-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function useJobCreator(options: {
}

function onCloseJob(): void {
if (jobSaving.value) return
if (!newJobOpen.value || jobSaving.value) return
if (jobConfig.value !== '{}' && !window.confirm(t('source.discardChanges'))) return
newJobOpen.value = false
}
Expand All @@ -51,6 +51,7 @@ export function useJobCreator(options: {
{ source: options.sourceId.value, config: JSON.parse(jobConfig.value) }
)
options.jobs.value = [created, ...options.jobs.value]
jobConfig.value = '{}'
newJobOpen.value = false
} catch {
jobError.value = t('source.runFailed')
Expand Down
Loading
Loading