mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
216 lines
7.7 KiB
TypeScript
216 lines
7.7 KiB
TypeScript
// Pure helpers for the duration-histogram sparkline. The list sorted by duration pairs
|
||
// with a histogram of trace counts per logarithmic duration bucket; everything here is pure so it
|
||
// can be unit-tested without the kea logic's import graph.
|
||
|
||
export interface DurationHistogramRow {
|
||
bucket_ns: number
|
||
service: string
|
||
count: number
|
||
}
|
||
|
||
export interface TracingDurationHistogramData {
|
||
data: { name: string; values: number[]; color: string }[]
|
||
/** The 1-2-5 series bucket for each bar, in ns — the duration-space sibling of `dates`. */
|
||
bucketsNs: number[]
|
||
labels: string[]
|
||
}
|
||
|
||
/** A `[minNs, maxNs)` duration range in nanoseconds — the shared currency of selection and filtering. */
|
||
export interface DurationRange {
|
||
minNs: number
|
||
maxNs: number
|
||
}
|
||
|
||
export type VisibleDurationRange = DurationRange
|
||
|
||
const BUCKET_MANTISSAS = [1, 2, 5]
|
||
|
||
/**
|
||
* Snap a duration (ns) down onto the 1-2-5 log series the histogram buckets use (1ms, 2ms, 5ms, ...).
|
||
*
|
||
* Mirrors the SQL bucketing in `backend/duration_histogram_query_runner.py` — the backend buckets
|
||
* authoritatively; the frontend only re-snaps to map the visible rows' durations (scroll position is
|
||
* client-side) onto the rendered axis. Change the series in BOTH places or the highlight drifts.
|
||
*/
|
||
export function snapDurationToBucket(ns: number): number {
|
||
const clamped = Math.max(ns, 1)
|
||
const decade = Math.pow(10, Math.floor(Math.log10(clamped)))
|
||
const mantissa = clamped / decade
|
||
return Math.round(decade * (mantissa < 2 ? 1 : mantissa < 5 ? 2 : 5))
|
||
}
|
||
|
||
/** Every 1-2-5 series bucket from minBucket to maxBucket inclusive (both already snapped). */
|
||
export function fillBucketSeries(minBucket: number, maxBucket: number): number[] {
|
||
const buckets: number[] = []
|
||
let decade = Math.pow(10, Math.floor(Math.log10(Math.max(minBucket, 1))))
|
||
for (;;) {
|
||
for (const mantissa of BUCKET_MANTISSAS) {
|
||
const bucket = Math.round(decade * mantissa)
|
||
if (bucket > maxBucket) {
|
||
return buckets
|
||
}
|
||
if (bucket >= minBucket) {
|
||
buckets.push(bucket)
|
||
}
|
||
}
|
||
decade *= 10
|
||
}
|
||
}
|
||
|
||
/** Buckets are exact 1-2-5 values, so labels stay clean integers ("2ms", "500ms", "1s"). */
|
||
export function formatBucketLabel(ns: number): string {
|
||
if (ns < 1_000) {
|
||
return `${ns}ns`
|
||
}
|
||
if (ns < 1_000_000) {
|
||
return `${ns / 1_000}µs`
|
||
}
|
||
if (ns < 1_000_000_000) {
|
||
return `${ns / 1_000_000}ms`
|
||
}
|
||
return `${ns / 1_000_000_000}s`
|
||
}
|
||
|
||
/**
|
||
* Pivot histogram rows into the Sparkline series shape, filling gaps along the 1-2-5 series
|
||
* between the smallest and largest non-empty bucket so the log axis is continuous.
|
||
*/
|
||
export function pivotDurationHistogram(
|
||
rows: DurationHistogramRow[],
|
||
colors: readonly string[]
|
||
): TracingDurationHistogramData {
|
||
if (!rows.length) {
|
||
return { data: [], bucketsNs: [], labels: [] }
|
||
}
|
||
|
||
const minBucket = Math.min(...rows.map((row) => row.bucket_ns))
|
||
const maxBucket = Math.max(...rows.map((row) => row.bucket_ns))
|
||
const bucketsNs = fillBucketSeries(minBucket, maxBucket)
|
||
const indexByBucket = new Map(bucketsNs.map((bucket, index) => [bucket, index]))
|
||
|
||
const accumulated: Record<string, number[]> = {}
|
||
for (const row of rows) {
|
||
if (!row.service) {
|
||
continue
|
||
}
|
||
const index = indexByBucket.get(row.bucket_ns)
|
||
if (index === undefined) {
|
||
continue
|
||
}
|
||
if (!accumulated[row.service]) {
|
||
accumulated[row.service] = bucketsNs.map(() => 0)
|
||
}
|
||
accumulated[row.service][index] += row.count
|
||
}
|
||
|
||
const data = Object.entries(accumulated)
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([name, values], index) => ({
|
||
name,
|
||
values,
|
||
color: colors[index % colors.length],
|
||
}))
|
||
.filter((series) => series.values.reduce((a, b) => a + b, 0) > 0)
|
||
|
||
return { data, bucketsNs, labels: bucketsNs.map(formatBucketLabel) }
|
||
}
|
||
|
||
export interface LatencyHeatmapRow {
|
||
time: string
|
||
bucket_ns: number
|
||
count: number
|
||
}
|
||
|
||
export interface TracingLatencyHeatmapData {
|
||
/** ISO start of every time bucket in the window, in order — the heatmap's x axis. */
|
||
timeBuckets: string[]
|
||
/** Filled 1-2-5 series, ascending — the heatmap's y axis, bottom row first. */
|
||
bucketsNs: number[]
|
||
labels: string[]
|
||
/** cells[rowIndex][colIndex] counts, aligned with bucketsNs × timeBuckets. */
|
||
cells: number[][]
|
||
}
|
||
|
||
/**
|
||
* Pivot latency-heatmap rows into the quill Heatmap grid shape. The backend enumerates every
|
||
* time bucket (empty ones as a {time, bucket_ns: 0, count: 0} sentinel), so the x axis comes
|
||
* straight from the response; the y axis fills gaps along the 1-2-5 series like the histogram.
|
||
*/
|
||
export function pivotLatencyHeatmap(rows: LatencyHeatmapRow[]): TracingLatencyHeatmapData {
|
||
const timeBuckets: string[] = []
|
||
const timeIndex = new Map<string, number>()
|
||
for (const row of rows) {
|
||
if (!timeIndex.has(row.time)) {
|
||
timeIndex.set(row.time, timeBuckets.length)
|
||
timeBuckets.push(row.time)
|
||
}
|
||
}
|
||
|
||
const dataRows = rows.filter((row) => row.count > 0)
|
||
if (!dataRows.length) {
|
||
return { timeBuckets, bucketsNs: [], labels: [], cells: [] }
|
||
}
|
||
|
||
const minBucket = Math.min(...dataRows.map((row) => row.bucket_ns))
|
||
const maxBucket = Math.max(...dataRows.map((row) => row.bucket_ns))
|
||
const bucketsNs = fillBucketSeries(minBucket, maxBucket)
|
||
const bucketIndex = new Map(bucketsNs.map((bucket, index) => [bucket, index]))
|
||
|
||
const cells = bucketsNs.map(() => timeBuckets.map(() => 0))
|
||
for (const row of dataRows) {
|
||
const rowIndex = bucketIndex.get(row.bucket_ns)
|
||
const colIndex = timeIndex.get(row.time)
|
||
if (rowIndex !== undefined && colIndex !== undefined) {
|
||
cells[rowIndex][colIndex] += row.count
|
||
}
|
||
}
|
||
|
||
return { timeBuckets, bucketsNs, labels: bucketsNs.map(formatBucketLabel), cells }
|
||
}
|
||
|
||
/** Exclusive upper edge of a 1-2-5 bucket — the next bucket on the series (1→2, 2→5, 5→10). */
|
||
export function bucketUpperBound(bucketNs: number): number {
|
||
return fillBucketSeries(bucketNs, bucketNs * 10)[1]
|
||
}
|
||
|
||
/**
|
||
* Duration range covered by an inclusive bar-index selection on the histogram axis.
|
||
* Bucket b covers [b, nextBucket(b)), so the range's max is the bucket after the last selected
|
||
* bar (extrapolated on the 1-2-5 series when the selection ends on the last bar).
|
||
*/
|
||
export function selectionToDurationRange(
|
||
bucketsNs: number[],
|
||
startIndex: number,
|
||
endIndex: number
|
||
): DurationRange | null {
|
||
if (bucketsNs.length === 0) {
|
||
return null
|
||
}
|
||
const start = Math.max(0, Math.min(startIndex, bucketsNs.length - 1))
|
||
const end = Math.max(start, Math.min(endIndex, bucketsNs.length - 1))
|
||
const minNs = bucketsNs[start]
|
||
const maxNs = bucketsNs[end + 1] ?? bucketUpperBound(bucketsNs[end])
|
||
return { minNs, maxNs }
|
||
}
|
||
|
||
/**
|
||
* Duration range spanned by the visible slice of a duration-sorted list, given the durations of
|
||
* its rows in display order. Returns min/max regardless of ASC/DESC sort direction.
|
||
*/
|
||
export function visibleDurationRange(
|
||
visibleRowRange: { startIndex: number; stopIndex: number } | null,
|
||
durationsNs: number[]
|
||
): VisibleDurationRange | null {
|
||
if (!visibleRowRange || durationsNs.length === 0) {
|
||
return null
|
||
}
|
||
const startIndex = Math.max(0, Math.min(visibleRowRange.startIndex, durationsNs.length - 1))
|
||
const stopIndex = Math.max(0, Math.min(visibleRowRange.stopIndex, durationsNs.length - 1))
|
||
const a = durationsNs[startIndex]
|
||
const b = durationsNs[stopIndex]
|
||
if (a === undefined || b === undefined) {
|
||
return null
|
||
}
|
||
return { minNs: Math.min(a, b), maxNs: Math.max(a, b) }
|
||
}
|