Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

216 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) }
}