Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
import { CanvasRenderer } from 'echarts/renderers'
import type { ECMouseEvent } from 'svelte-echarts'
import { Chart } from 'svelte-echarts'
import { ServerDate } from '$lib/compositions/serverTime'
import { getThemeColor } from '$lib/functions/common/color'
import { humanSize } from '$lib/functions/common/string'
import { tuple } from '$lib/functions/common/tuple'
import type { PipelineMetrics } from '$lib/functions/pipelineMetrics'
import { timeSeriesAxisMax } from '$lib/functions/pipelineMetrics'
import type { Pipeline } from '$lib/services/pipelineManager'
import type { TimeSeriesEntry } from '$lib/types/pipelineManager'

Expand All @@ -40,6 +41,10 @@

const pipelineName = $derived(pipeline.current.name)

// Anchor the time axis to the newest sample's timestamp rather than to the
// client clock.
const xAxisMax = $derived(timeSeriesAxisMax(metrics))

const valueMax = $derived(metrics.length ? Math.max(...metrics.map((v) => v.m.toNumber())) : 0)
const yMaxStep = $derived(2 ** Math.ceil(Math.log2(valueMax * 1.25)))
const yMax = $derived(valueMax !== 0 ? yMaxStep : 1024 * 2048)
Expand Down Expand Up @@ -67,8 +72,8 @@
}
],
xAxis: {
min: Date.now() - keepMs,
max: Date.now()
min: xAxisMax - keepMs,
max: xAxisMax
},
yAxis: {
interval: (yMax - yMin) / 2,
Expand Down Expand Up @@ -111,8 +116,10 @@
animationDuration: 0,
animationDurationUpdate: refetchMs,
type: 'time' as const,
min: Date.now() - keepMs - refetchMs,
max: Date.now() - refetchMs,
// svelte-ignore state_referenced_locally
min: ServerDate.now() - keepMs - refetchMs,
// svelte-ignore state_referenced_locally
max: ServerDate.now() - refetchMs,
minInterval: 25000,
maxInterval: 25000,
axisLabel: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
import { CanvasRenderer } from 'echarts/renderers'
import type { ECMouseEvent } from 'svelte-echarts'
import { Chart } from 'svelte-echarts'
import { ServerDate } from '$lib/compositions/serverTime'
import { getThemeColor } from '$lib/functions/common/color'
import { humanSize } from '$lib/functions/common/string'
import { tuple } from '$lib/functions/common/tuple'
import type { PipelineMetrics } from '$lib/functions/pipelineMetrics'
import { timeSeriesAxisMax } from '$lib/functions/pipelineMetrics'
import type { Pipeline } from '$lib/services/pipelineManager'
import type { TimeSeriesEntry } from '$lib/types/pipelineManager'
import type { Snippet } from '$lib/types/svelte'
Expand Down Expand Up @@ -43,6 +44,10 @@

const pipelineName = $derived(pipeline.current.name)

// Anchor the time axis to the newest sample's timestamp rather than to the
// client clock.
const xAxisMax = $derived(timeSeriesAxisMax(metrics))

const valueMax = $derived(metrics.length ? Math.max(...metrics.map((v) => v.s.toNumber())) : 0)
const yMaxStep = $derived(2 ** Math.ceil(Math.log2(valueMax * 1.25)))
const yMax = $derived(valueMax !== 0 ? yMaxStep : 1024 * 2048)
Expand Down Expand Up @@ -70,8 +75,8 @@
}
],
xAxis: {
min: Date.now() - keepMs,
max: Date.now()
min: xAxisMax - keepMs,
max: xAxisMax
},
yAxis: {
interval: (yMax - yMin) / 2,
Expand Down Expand Up @@ -114,8 +119,10 @@
animationDuration: 0,
animationDurationUpdate: refetchMs,
type: 'time' as const,
min: Date.now() - keepMs - refetchMs,
max: Date.now() - refetchMs,
// svelte-ignore state_referenced_locally
min: ServerDate.now() - keepMs - refetchMs,
// svelte-ignore state_referenced_locally
max: ServerDate.now() - refetchMs,
minInterval: 25000,
maxInterval: 25000,
axisLabel: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
import { type EChartsType, init, use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { Chart } from 'svelte-echarts'
import { ServerDate } from '$lib/compositions/serverTime'
import { getThemeColor } from '$lib/functions/common/color'
import { formatQty } from '$lib/functions/format'
import { calcPipelineThroughput, type PipelineMetrics } from '$lib/functions/pipelineMetrics'
import {
calcPipelineThroughput,
timeSeriesAxisMax,
type PipelineMetrics
} from '$lib/functions/pipelineMetrics'
import type { Pipeline } from '$lib/services/pipelineManager'
import type { TimeSeriesEntry } from '$lib/types/pipelineManager'

Expand All @@ -27,6 +32,10 @@
const pipelineName = $derived(pipeline.current.name)
const throughput = $derived(calcPipelineThroughput(metrics))

// Anchor the time axis to the newest sample's timestamp rather than to the
// client clock.
const xAxisMax = $derived(timeSeriesAxisMax(metrics))

const primaryColor = getThemeColor('--color-primary-500').format('hex')

let ref: EChartsType | undefined = $state()
Expand All @@ -43,8 +52,8 @@
}
],
xAxis: {
min: Date.now() - keepMs,
max: Date.now()
min: xAxisMax - keepMs,
max: xAxisMax
},
yAxis: {
interval: (throughput.yMax - throughput.yMin) / 2,
Expand All @@ -69,8 +78,10 @@
animationDuration: 0,
animationDurationUpdate: refetchMs,
type: 'time' as const,
min: Date.now() - keepMs - refetchMs,
max: Date.now() - refetchMs,
// svelte-ignore state_referenced_locally
min: ServerDate.now() - keepMs - refetchMs,
// svelte-ignore state_referenced_locally
max: ServerDate.now() - refetchMs,
minInterval: 25000,
maxInterval: 25000,
axisLabel: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
} from '$lib/components/health/StatusTimeline.svelte'
import Drawer from '$lib/components/layout/Drawer.svelte'
import { useInterval } from '$lib/compositions/common/useInterval.svelte'
import { newDate } from '$lib/compositions/serverTime'
import { ServerDate } from '$lib/compositions/serverTime'
import { usePipelineManager } from '$lib/compositions/usePipelineManager.svelte'
import { partition } from '$lib/functions/common/array'
import { ceilToHour, dateMax } from '$lib/functions/common/date'
Expand Down Expand Up @@ -121,7 +121,7 @@
const healthWindowHours = 72

const lastTimestamp = (es: PipelineMonitorEventSelectedInfo[] | null) =>
ceilToHour(es?.length ? dateMax(new Date(es[0].recorded_at), newDate()) : newDate())
ceilToHour(es?.length ? dateMax(new Date(es[0].recorded_at), new ServerDate()) : new ServerDate())
const firstTimestamp = (es: PipelineMonitorEventSelectedInfo[] | null) =>
new Date(lastTimestamp(es).getTime() - healthWindowHours * 60 * 60 * 1000)

Expand Down
27 changes: 27 additions & 0 deletions js-packages/web-console/src/lib/compositions/serverTime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { ServerDate } from './serverTime'

describe('ServerDate', () => {
it('behaves like Date when constructed with an explicit value', () => {
expect(new ServerDate(0).getTime()).toBe(0)
expect(new ServerDate('2020-01-01T00:00:00Z').getTime()).toBe(
Date.parse('2020-01-01T00:00:00Z')
)
expect(new ServerDate() instanceof Date).toBe(true)
})

it('applies the synced server/client offset to now()', () => {
// Pretend the server clock is one minute ahead of this client.
ServerDate.sync(Date.now() + 60_000)
const skew = ServerDate.now() - Date.now()
expect(skew).toBeGreaterThanOrEqual(59_000)
expect(skew).toBeLessThanOrEqual(61_000)
})

it('reflects the synced offset in a zero-argument instance', () => {
ServerDate.sync(Date.now() + 60_000)
const skew = new ServerDate().getTime() - Date.now()
expect(skew).toBeGreaterThanOrEqual(59_000)
expect(skew).toBeLessThanOrEqual(61_000)
})
})
47 changes: 35 additions & 12 deletions js-packages/web-console/src/lib/compositions/serverTime.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
let offsetMs = 0

/**
* Sets the global time offset based on the provided server time.
* @param newTime - A server-provided time (ISO string or Date object)
* @returns Server time
* A drop-in replacement for `Date` that reads the current time from the
* server's clock rather than the client's.
*
* The pipeline manager reports its own wall-clock time (e.g. in the license
* payload). The browser clock may be skewed relative to the server, so any UI
* that compares server-provided timestamps against "now" must use the server's
* notion of now. `ServerDate` measures the offset once via {@link ServerDate.sync}
* and applies it to every `new ServerDate()` and {@link ServerDate.now}.
*
* Instances are ordinary `Date` objects, so a `ServerDate` can be used anywhere
* a `Date` is expected. Constructing one with an explicit argument behaves
* exactly like `Date`; only the zero-argument form (the current time) is
* shifted onto the server clock.
*/
export const setCurrentTime = (newTime: string | Date) => {
const serverMs = new Date(newTime).getTime()
const clientMs = Date.now()
offsetMs = serverMs - clientMs
}
export class ServerDate extends Date {
/** Server clock minus client clock, in milliseconds. */
private static offsetMs = 0

export const newDate = () => new Date(Date.now() + offsetMs)
/**
* Records the client/server clock offset from a known server timestamp.
* @param serverTime - The server's current time (ISO string, epoch ms, or `Date`).
*/
static sync(serverTime: string | number | Date) {
ServerDate.offsetMs = new Date(serverTime).getTime() - Date.now()
}

export const dateNow = () => Date.now() + offsetMs
/** Current server time, in milliseconds since the Unix epoch. */
static now(): number {
return Date.now() + ServerDate.offsetMs
}

/**
* @param value - As `Date`'s constructor; omit to use the current server time.
*/
constructor(value?: number | string | Date) {
super(value === undefined ? ServerDate.now() : value)
}
}
23 changes: 23 additions & 0 deletions js-packages/web-console/src/lib/functions/pipelineMetrics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { BigNumber } from 'bignumber.js'
import { describe, expect, it } from 'vitest'
import { timeSeriesAxisMax } from './pipelineMetrics'
import type { TimeSeriesEntry } from '$lib/types/pipelineManager'

const sampleAt = (timeMs: number): TimeSeriesEntry => ({
t: new BigNumber(timeMs),
r: new BigNumber(0),
m: new BigNumber(0),
s: new BigNumber(0)
})

describe('timeSeriesAxisMax', () => {
it('anchors to the newest sample regardless of the client clock', () => {
const metrics = [sampleAt(1000), sampleAt(2000), sampleAt(3000)]
// A client clock that disagrees with the server must not influence the result.
expect(timeSeriesAxisMax(metrics, () => 9999)).toBe(3000)
})

it('falls back to the supplied time source when there are no samples', () => {
expect(timeSeriesAxisMax([], () => 4242)).toBe(4242)
})
})
15 changes: 15 additions & 0 deletions js-packages/web-console/src/lib/functions/pipelineMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ServerDate } from '$lib/compositions/serverTime'
import { groupBy } from '$lib/functions/common/array'
import { nonNull } from '$lib/functions/common/function'
import { discreteDerivative } from '$lib/functions/common/math'
Expand Down Expand Up @@ -199,6 +200,20 @@ export const accumulatePipelineMetrics =
}
}

/**
* Right edge (newest time) of a performance graph's time axis.
*
* Samples carry server-side timestamps, so the axis must be anchored to the
* newest sample rather than to the client clock: any client/server clock skew
* would otherwise shift the plotted line relative to the axis and leave the
* graph under-filling its width. Before any sample has arrived, fall back to
* the server-time estimate so the empty window is still in the right time base.
*
* @param now - Source of the fallback time; injectable for testing.
*/
export const timeSeriesAxisMax = (metrics: TimeSeriesEntry[], now: () => number = ServerDate.now) =>
metrics.at(-1)?.t.toNumber() ?? now()

/**
* @returns Time series of throughput with smoothing window over 3 data intervals
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import BookADemo from '$lib/components/other/BookADemo.svelte'
import CreatePipelineButton from '$lib/components/pipelines/CreatePipelineButton.svelte'
import { useAdaptiveDrawer } from '$lib/compositions/layout/useAdaptiveDrawer.svelte'
import { newDate } from '$lib/compositions/serverTime'
import { ServerDate } from '$lib/compositions/serverTime'
import { usePipelineManager } from '$lib/compositions/usePipelineManager.svelte'
import { partition } from '$lib/functions/common/array'
import { ceilToHour, dateMax } from '$lib/functions/common/date'
Expand Down Expand Up @@ -59,7 +59,9 @@
const healthWindowHours = 72

const lastTimestamp = (events: ClusterMonitorEventSelectedInfo[] | null) =>
ceilToHour(events?.length ? dateMax(new Date(events.at(0)!.recorded_at), newDate()) : newDate())
ceilToHour(
events?.length ? dateMax(new Date(events.at(0)!.recorded_at), new ServerDate()) : new ServerDate()
)
const firstTimestamp = (events: ClusterMonitorEventSelectedInfo[] | null) =>
new Date(lastTimestamp(events).getTime() - healthWindowHours * 60 * 60 * 1000)

Expand Down
4 changes: 2 additions & 2 deletions js-packages/web-console/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { page } from '$app/state'
import { useInterval } from '$lib/compositions/common/useInterval.svelte'
import { newDate } from '$lib/compositions/serverTime'
import { ServerDate } from '$lib/compositions/serverTime'
import { useSystemMessages } from '$lib/compositions/useSystemMessages.svelte'
import { getLicenseMessage } from '$lib/functions/license'
import { _markRouterReady } from './+layout'
Expand Down Expand Up @@ -45,7 +45,7 @@
if (!page.data.feldera) {
return
}
upsert(/^license_/, getLicenseMessage(page.data.feldera.config, newDate()))
upsert(/^license_/, getLicenseMessage(page.data.feldera.config, new ServerDate()))
}, 60000)
</script>

Expand Down
8 changes: 4 additions & 4 deletions js-packages/web-console/src/routes/+layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
getSessionConfigFromCache
} from '$lib/compositions/configCache'
import { initSystemMessages, type SystemMessage } from '$lib/compositions/initSystemMessages'
import { newDate, setCurrentTime } from '$lib/compositions/serverTime'
import { ServerDate } from '$lib/compositions/serverTime'
import { displayScheduleToDismissable, getLicenseMessage } from '$lib/functions/license'
import { resolve } from '$lib/functions/svelte'
import {
Expand Down Expand Up @@ -168,7 +168,7 @@ const syncServerTimeFromConfig = (config: Configuration) => {
? config.license_validity.Exists
: undefined
if (license) {
setCurrentTime(license.current)
ServerDate.sync(license.current)
}
}

Expand Down Expand Up @@ -435,7 +435,7 @@ function buildLayoutData(
* It is expected to be idempotent - calling it the second time on lazy update
* when config hasn't changed should not break anything.
*
* Does NOT call `setCurrentTime` — that belongs to `syncServerTimeFromConfig`,
* Does NOT call `ServerDate.sync` — that belongs to `syncServerTimeFromConfig`,
* which only runs against freshly-fetched data.
*/
function initializeConfigDependencies(auth: AuthDetails, config: Configuration) {
Expand All @@ -454,7 +454,7 @@ function initializeConfigDependencies(auth: AuthDetails, config: Configuration)
}

{
const message = getLicenseMessage(config, newDate())
const message = getLicenseMessage(config, new ServerDate())
if (message) {
pushSystemMessageOnce(message)
}
Expand Down
Loading