Files
openobserve/web/src/components/TenstackTable.vue
T
dcfbbce906 fix: removed spans limit from trace details (#11333)
#11280

---------

Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Omkar Kesarkhane <undefined@users.noreply.github.com>
2026-04-21 16:30:02 +00:00

2319 lines
81 KiB
Vue
Raw 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.
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<!-- Outer wrapper: full panel height, flex-column so pagination sits below the scroll area -->
<div
class="my-sticky-virtscroll-table tw:h-full tw:flex tw:flex-col tw:rounded-none!"
:data-sticky-id="tableId"
:class="{ 'pivot-sticky-totals': stickyRowTotals, 'wrap-enabled': wrap }"
:style="store.state.printMode ? { position: 'static' } : {}"
>
<!-- Scroll container: grows to fill available height -->
<div
ref="parentRef"
:class="['container', 'table-container', 'tw:flex-1', 'tw:min-h-0', 'tw:overflow-auto', 'tw:relative', { 'virtual-scroll-active': useVirtualScroll }]"
>
<table
v-if="table"
data-test="o2-table"
:class="['tw:w-full', 'tw:table-auto']"
:style="{
minWidth: '100%',
...columnSizeVars,
minHeight: showPagination ? undefined : totalSize + 'px',
width: !useVirtualScroll
? '100%'
: !defaultColumns
? table.getCenterTotalSize() + 'px'
: wrap
? width
? width - 12 + 'px'
: '100%'
: '100%',
// Use border-collapse:separate for pivot tables AND tables with
// sticky columns so borders stick with position:sticky headers
// (collapse mode paints adjacent cells on top of sticky shadows).
borderCollapse: usesSeparateBorders ? 'separate' : undefined,
borderSpacing: usesSeparateBorders ? '0' : undefined,
}"
>
<!-- ── Pivot multi-level headers (dashboard only) ───────────────────── -->
<thead
v-if="pivotHeaderLevels.length > 0"
class="pivot-thead tw:sticky tw:top-0 tw:z-10"
>
<tr
v-for="(level, levelIdx) in pivotHeaderLevels as any[]"
:key="'hl_' + levelIdx"
style="max-height: 28px; height: 28px"
>
<!-- Row-field headers: first <tr> only, rowspan all levels -->
<th
v-if="levelIdx === 0"
v-for="col in pivotRowColumns"
:key="'rh_' + col.name"
:rowspan="pivotHeaderLevels.length"
class="pivot-group-header tw:cursor-pointer tw:px-2 tw:text-left"
:style="getStickyColumnStyle(col) as any"
@click="handlePivotSort(col.name)"
>
{{ col.label }}
<q-icon
:name="
pivotSortState.descending ? 'arrow_downward' : 'arrow_upward'
"
size="12px"
class="q-ml-xs pivot-sort-icon"
:class="{
'pivot-sort-active': pivotSortState.sortBy === col.name,
}"
/>
</th>
<!-- Pivot group / value headers -->
<th
v-for="(cell, cellIdx) in level.cells"
:key="'c_' + levelIdx + '_' + cellIdx"
:colspan="cell.colspan"
:rowspan="cell.rowspan || 1"
class="tw:px-2"
:class="[
level.isLeaf
? 'pivot-value-header'
: 'pivot-group-header tw:text-center',
{ 'pivot-section-border': cell.hasBorder && !(stickyColTotals && cell._isTotalHeader) },
{ 'pivot-total-col': stickyColTotals && cell._isTotalHeader },
{ 'tw:cursor-pointer': cell._sortColumn },
]"
:style="
(stickyColTotals && cell._isTotalHeader
? getStickyTotalHeaderForPivot(cell)
: {}) as any
"
@click="cell._sortColumn && handlePivotSort(cell._sortColumn)"
>
{{ cell.label }}
<q-icon
v-if="level.isLeaf && cell._sortColumn"
:name="
pivotSortState.descending ? 'arrow_downward' : 'arrow_upward'
"
size="12px"
class="q-ml-xs pivot-sort-icon"
:class="{
'pivot-sort-active':
pivotSortState.sortBy === cell._sortColumn,
}"
/>
</th>
</tr>
</thead>
<!-- ── Standard TanStack headers (logs / non-pivot) ─────────────────── -->
<thead
v-else
class="tw:sticky tw:top-0 tw:z-10"
style="max-height: 44px; height: 22px"
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<vue-draggable
v-model="columnOrder"
:element="'table'"
:animation="200"
:sort="
enableColumnReorder && (!isResizingHeader || !defaultColumns)
"
handle=".table-head"
:class="[
// Flex only for virtual-scroll mode (logs/traces) for drag-reorder + alignment
// Non-virtual (dashboard) uses table-layout:auto — no flex so browser auto-sizes columns
useVirtualScroll ? 'tw:flex items-center' : '',
enableColumnReorder && table.getState().columnOrder.length
? 'tw:cursor-move!'
: '',
]"
:style="{
width:
defaultColumns && wrap
? width - 12 + 'px'
: defaultColumns
? tableRowSize + 'px'
: table.getTotalSize() + 'px',
minWidth: '100%',
background: store.state.theme === 'dark' ? '#565656' : '#E0E0E0',
}"
tag="tr"
@start="(event) => handleDragStart(event)"
@end="() => handleDragEnd()"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:id="header.id"
class="tw:px-2 tw:relative table-head tw:text-ellipsis!"
:class="[
(header.column.columnDef.meta as any)?.align === 'center'
? 'tw:text-center!'
: '',
(header.column.columnDef.meta as any)?.align === 'right'
? 'tw:text-right!'
: '',
(header.column.columnDef.meta as any)?.headerClass ?? '',
{
'pivot-total-col':
stickyColTotals &&
(header.column.columnDef.meta as any)?._isTotalColumn,
},
]"
:style="{
width: `calc(var(--header-${sanitizeCssId(header?.id)}-size) * 1px)`,
height: rowHeight != null ? rowHeight + 'px' : undefined,
}"
:data-test="`o2-table-th-${header.id}`"
>
<div
class="tw:h-full tw:w-full tw:flex tw:items-center"
:class="[
(header.column.columnDef.meta as any)?.align === 'center'
? 'tw:justify-center! tw:text-center!'
: '',
(header.column.columnDef.meta as any)?.align === 'right'
? 'tw:justify-end! tw:text-right!'
: '',
]"
>
<div
v-if="header.column.getCanResize()"
@dblclick="header.column.resetSize()"
@mousedown.self.prevent.stop="
header.getResizeHandler()?.($event)
"
@touchstart.self.prevent.stop="
header.getResizeHandler()?.($event)
"
:class="[
'resizer',
'tw:hover:bg-[var(--o2-border-color)]',
header.column.getIsResizing() ? 'isResizing' : '',
]"
class="tw:right-0 tw:bg-transparent"
/>
<div
v-if="!header.isPlaceholder"
:data-test="`o2-table-th-sort-${header.id}`"
:class="['text-left', 'cursor-pointer tw:gap-1']"
@click="
handleHeaderSortClick(
$event,
header.column,
header.column.getToggleSortingHandler(),
)
"
class="tw:overflow-hidden tw:whitespace-nowrap tw:text-ellipsis! tw:tracking-[0.06rem] tw:text-[var(--o2-text-2)] tw:text-[0.85rem]"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<!-- Server-side sort icons (shown when sortBy prop is provided) -->
<template
v-if="
sortBy !== undefined &&
(header.column.columnDef.meta as any)?.sortable
"
>
<q-icon
v-if="
(sortFieldMap?.[header.column.id] ??
header.column.id) === sortBy
"
:name="
sortOrder === 'asc' ? 'arrow_upward' : 'arrow_downward'
"
data-test="tenstack-table-sort-icon-active"
size="0.85rem"
class="tw:text-[var(--o2-primary-color)]"
/>
<q-icon
v-else
name="unfold_more"
data-test="tenstack-table-sort-icon-inactive"
size="0.85rem"
class="tw:opacity-40"
/>
</template>
</div>
<div
:data-test="`o2-table-add-data-from-column-${header.column.columnDef.header}`"
class="tw:invisible tw:items-center tw:absolute tw:right-2 tw:top-0 tw:px-2 column-actions tw:h-full tw:flex"
:class="
store.state.theme === 'dark' ? 'field_overlay_dark' : ''
"
v-if="
(header.column.columnDef.meta as any)?.closable ||
(header.column.columnDef.meta as any)?.showWrap
"
>
<q-icon
v-if="(header.column.columnDef.meta as any).closable"
:data-test="`o2-table-th-remove-${header.column.columnDef.header}-btn`"
name="cancel"
class="q-ma-none close-icon cursor-pointer"
:class="
store.state.theme === 'dark'
? 'text-white'
: 'text-grey-7'
"
:title="t('common.close')"
size="18px"
@click="closeColumn(header.column.columnDef)"
>
</q-icon>
</div>
</div>
</th>
</vue-draggable>
<!-- Loading row: shown when there are no rows yet (initial fetch) -->
<tr v-if="loading && tableRows.length === 0" class="tw:w-full">
<td
:colspan="columnOrder.length"
class="text-bold"
:style="{
background:
store.state.theme === 'dark' ? '#565656' : '#E0E0E0',
opacity: 0.7,
}"
>
<slot name="loading">
<div
class="text-subtitle2 text-weight-bold tw:flex tw:items-center"
>
<q-spinner-hourglass size="20px" />
{{ t("confirmDialog.loading") }}
</div>
</slot>
</td>
</tr>
<!-- Loading banner: shown above rows while a new page is fetching -->
<tr v-if="loading && tableRows.length > 0" class="tw:w-full">
<td :colspan="columnOrder.length">
<slot name="loading-banner" />
</td>
</tr>
<tr v-if="!loading && errMsg != ''" class="tw:w-full">
<td
:colspan="columnOrder.length"
class="text-bold"
style="opacity: 0.7"
>
<div class="text-subtitle2 text-weight-bold bg-warning">
<q-icon size="xs" name="warning" class="q-mr-xs" />
{{ errMsg }}
</div>
</td>
</tr>
<tr data-test="o2-table-function-error" v-if="functionErrorMsg != ''">
<td
:colspan="columnOrder.length"
class="text-bold"
style="opacity: 0.6"
>
<div
class="text-subtitle2 text-weight-bold q-pl-sm"
:class="
store.state.theme === 'dark'
? 'tw:bg-yellow-600'
: 'tw:bg-amber-300'
"
>
<q-btn
:icon="isFunctionErrorOpen ? 'expand_more' : 'chevron_right'"
dense
size="xs"
flat
class="q-mr-xs"
data-test="table-row-expand-menu"
@click.capture.stop="expandFunctionError"
></q-btn
><b>
<q-icon name="warning" size="15px"></q-icon>
{{ t("search.functionErrorLabel") }}</b
>
</div>
</td>
</tr>
<tr v-if="functionErrorMsg != '' && isFunctionErrorOpen">
<td
:colspan="columnOrder.length"
style="opacity: 0.7"
class="q-px-sm"
:class="
store.state.theme === 'dark'
? 'tw:bg-yellow-600'
: 'tw:bg-amber-300'
"
>
<pre>{{ functionErrorMsg }}</pre>
</td>
</tr>
</thead>
<!-- tw:relative is only needed for virtual-scroll absolute rows (logs/traces).
In dashboard mode (regular DOM rows) it must be absent so that
position:sticky on <thead> works correctly. -->
<tbody
data-test="o2-table-body"
ref="tableBodyRef"
:class="{ 'tw:relative': useVirtualScroll && !showPagination }"
>
<!-- ── Dashboard: regular DOM rows (no virtual scroll) ──────────────── -->
<!-- Used when `data` prop is present (dashboard mode) OR pagination is
enabled. Virtual scroll (below) is only for logs/traces. -->
<template v-if="showPagination || !useVirtualScroll">
<!-- Top spacer for dashboard virtual scroll -->
<tr
v-if="dashVirtualEnabled && dashVirtualPaddingTop > 0"
aria-hidden="true"
>
<td
:colspan="columnOrder.length"
:style="{
height: dashVirtualPaddingTop + 'px',
padding: 0,
border: 'none',
}"
/>
</tr>
<tr
v-for="{ row, idx } in renderedDashboardRows"
:key="row.id"
:data-index="idx"
:ref="(node: any) => measureDashboardRow(node)"
class="dashboard-data-row tw:cursor-pointer hover:tw:bg-[var(--o2-hover-gray)]"
:class="{ 'tw:border-b': !usesSeparateBorders }"
@click="
handleDataRowClick(row.original, idx as number, $event)
"
>
<td
v-for="(cell, cellIndex) in row.getVisibleCells()"
:key="cell.id"
class="tw:py-1 tw:px-2 tw:overflow-hidden tw:relative table-cell copy-cell-td"
:class="[
(cell.column.columnDef.meta as any)?.align === 'center'
? 'tw:text-center!'
: '',
(cell.column.columnDef.meta as any)?.align === 'right'
? 'tw:text-right!'
: '',
(cell.column.columnDef.meta as any)?.cellClass ?? '',
{
'sticky-column': (cell.column.columnDef.meta as any)
?.sticky,
},
{
'pivot-total-col':
stickyColTotals &&
(cell.column.columnDef.meta as any)?._isTotalColumn,
},
// In separate-border mode (pivot or sticky columns),
// <tr> borders don't render — apply the row border on <td>.
{ 'tw:border-b': usesSeparateBorders },
isPivotMergeNoBorder(
row.original,
(cell.column.columnDef.meta as any)?._col,
)
? 'pivot-no-border'
: '',
]"
:style="[
{
width: `calc(var(--col-${sanitizeCssId(cell.column.columnDef.id)}-size) * 1px)`,
height: rowHeight ? rowHeight + 'px' : undefined,
},
props.getCellStyle?.(cell),
getStickyColumnStyle(
(cell.column.columnDef.meta as any)?._col,
) as any,
getStickyTotalColumnStyle(
(cell.column.columnDef.meta as any)?._col,
) as any,
]"
>
<template
v-if="
!isPivotMergeHidden(
row.original,
(cell.column.columnDef.meta as any)?._col,
)
"
>
<div
class="tw:h-full tw:w-full tw:flex tw:items-center"
:class="[
(cell.column.columnDef.meta as any)?.align === 'center'
? 'tw:justify-center!'
: '',
(cell.column.columnDef.meta as any)?.align === 'right'
? 'tw:justify-end!'
: '',
]"
>
<!-- Copy button LEFT (right-aligned) -->
<q-btn
v-if="
enableCellCopy &&
(cell.column.columnDef.meta as any)?.align ===
'right' &&
shouldShowCopyButton(cell.getValue())
"
:icon="
isCellCopied(idx as number, cell.column.id)
? 'check'
: 'content_copy'
"
dense
size="xs"
no-caps
flat
class="copy-btn q-mr-xs"
@click.stop="
copyCellContent(
getCellDisplayValue(cell),
idx as number,
cell.column.id,
)
"
/>
<!-- JSON field inline renderer -->
<JsonFieldRenderer
v-if="
(cell.column.columnDef.meta as any)?.showFieldAsJson
"
:value="cell.getValue()"
/>
<!-- Default value with format fn -->
<span
v-else
:class="[
!props.wrap
? 'tw:min-w-0 tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap'
: '',
props.wrap ? 'tw:break-words' : '',
]"
>
{{ getCellDisplayValue(cell) }}
</span>
<!-- Copy button RIGHT (left/center-aligned) -->
<q-btn
v-if="
enableCellCopy &&
(cell.column.columnDef.meta as any)?.align !==
'right' &&
shouldShowCopyButton(cell.getValue())
"
:icon="
isCellCopied(idx as number, cell.column.id)
? 'check'
: 'content_copy'
"
dense
size="xs"
no-caps
flat
class="copy-btn q-ml-xs"
@click.stop="
copyCellContent(
getCellDisplayValue(cell),
idx as number,
cell.column.id,
)
"
/>
</div>
</template>
</td>
</tr>
<!-- Bottom spacer for dashboard virtual scroll -->
<tr
v-if="dashVirtualEnabled && dashVirtualPaddingBottom > 0"
aria-hidden="true"
>
<td
:colspan="columnOrder.length"
:style="{
height: dashVirtualPaddingBottom + 'px',
padding: 0,
border: 'none',
}"
/>
</tr>
</template>
<!-- ── Virtual scroll rows (logs / traces only — no `data` prop) ────── -->
<template
v-if="!showPagination && useVirtualScroll"
v-for="virtualRow in virtualRows"
:key="virtualRow.index"
>
<tr
:data-test="`o2-table-detail-${
(tableRows[virtualRow.index] as any)[
store.state.zoConfig.timestamp_column || '_timestamp'
]
}`"
:style="{
transform: `translateY(${virtualRow.start + (isFirefox ? baseOffset : 0)}px)`,
minWidth: '100%',
}"
:data-index="virtualRow.index"
:data-expanded="
formattedRows?.[virtualRow.index]?.original?.isExpandedRow
"
:ref="(node: any) => node && rowVirtualizer.measureElement(node)"
class="tw:absolute tw:flex tw:w-max tw:items-center tw:justify-start tw:border-b tw:border-b-[var(--o2-tag-grey-1)] tw:cursor-pointer hover:tw:bg-[var(--o2-hover-gray)]"
:class="[
defaultColumns &&
!wrap &&
!(formattedRows[virtualRow.index]?.original as any)
?.isExpandedRow
? 'tw:table-row'
: 'tw:flex',
(tableRows[virtualRow.index] as any)[
store.state.zoConfig.timestamp_column
] === highlightTimestamp &&
!(formattedRows[virtualRow.index]?.original as any)
?.isExpandedRow
? store.state.theme === 'dark'
? 'tw:bg-zinc-700'
: 'tw:bg-zinc-300'
: '',
'table-row-hover',
!(formattedRows[virtualRow.index]?.original as any)
?.isExpandedRow
? rowClass?.(tableRows[virtualRow.index] as any)
: undefined,
]"
@click="
!(formattedRows[virtualRow.index]?.original as any)
?.isExpandedRow &&
handleDataRowClick(
tableRows[virtualRow.index],
virtualRow.index,
$event,
)
"
>
<!-- Status color line for entire row -->
<div
v-if="
enableStatusBar &&
!(formattedRows[virtualRow.index]?.original as any)
?.isExpandedRow
"
class="tw:absolute tw:left-0 tw:inset-y-0 tw:w-1 tw:z-10"
:style="{
backgroundColor: getRowStatusColor(
tableRows[virtualRow.index],
),
}"
/>
<td
v-if="
enableRowExpand &&
(formattedRows[virtualRow.index]?.original as any)
?.isExpandedRow
"
:colspan="columnOrder.length"
:data-test="`o2-table-expanded-row-${virtualRow.index}`"
class="tw:w-full tw:relative"
>
<slot
name="expanded-row"
:row="tableRows[virtualRow.index - 1]"
:index="calculateActualIndex(virtualRow.index - 1)"
:hide-field-options="hideExpandFieldOptions"
/>
</td>
<template v-else>
<td
v-for="(cell, cellIndex) in formattedRows[
virtualRow.index
].getVisibleCells()"
:key="cell.id"
:data-test="
'o2-table-column-' +
virtualRow.index +
'-' +
cell.column.columnDef.id
"
class="tw:py-none tw:px-2 tw:items-center tw:justify-start tw:relative table-cell copy-cell-td"
:class="[
...tableCellClass,
{ 'tw:pl-2': cellIndex === 0 },
(cell.column.columnDef.meta as any)?.align === 'center'
? 'tw:justify-center! tw:text-center!'
: '',
(cell.column.columnDef.meta as any)?.align === 'right'
? 'tw:justify-end! tw:text-right!'
: '',
(cell.column.columnDef.meta as any)?.cellClass ?? '',
{
'sticky-column': (cell.column.columnDef.meta as any)
?.sticky,
},
{
'pivot-total-col':
stickyColTotals &&
(cell.column.columnDef.meta as any)?._isTotalColumn,
},
isPivotMergeNoBorder(
cell.row.original,
(cell.column.columnDef.meta as any)?._col,
)
? 'pivot-no-border'
: '',
]"
:style="[
{
width:
cell.column.columnDef.id !== 'source' ||
cell.column.columnDef.enableResizing
? `calc(var(--col-${sanitizeCssId(cell.column.columnDef.id)}-size) * 1px)`
: wrap
? width - 260 - 12 + 'px'
: 'auto',
height: wrap
? 'stretch'
: rowHeight != null
? (rowHeight ?? 28) + 'px'
: undefined,
},
props.getCellStyle?.(cell),
getStickyColumnStyle(
(cell.column.columnDef.meta as any)?._col,
) as any,
getStickyTotalColumnStyle(
(cell.column.columnDef.meta as any)?._col,
) as any,
]"
@mouseover="handleCellMouseOver(cell)"
@mouseleave="handleCellMouseLeave()"
>
<div
class="tw:h-full tw:w-full tw:flex tw:items-center"
:class="[
(cell.column.columnDef.meta as any)?.align === 'center'
? 'tw:justify-center! tw:text-center!'
: '',
(cell.column.columnDef.meta as any)?.align === 'right'
? 'tw:justify-end! tw:text-right!'
: '',
]"
>
<q-btn
v-if="enableRowExpand && cellIndex == 0"
:icon="
expandedRowIndices.has(virtualRow.index)
? 'expand_more'
: 'chevron_right'
"
dense
size="xs"
flat
class="q-mr-xs"
data-test="table-row-expand-menu"
@click.capture.stop="handleExpandRow(virtualRow.index)"
></q-btn>
<slot
name="cell-actions"
:row="cell.row.original"
:column="cell.column"
:active="
activeCellActionId === `${cell.id}_${cell.column.id}`
"
/>
<!-- If column.meta.slot is set, delegate to the named cell slot -->
<slot
v-if="(cell.column.columnDef.meta as any)?.slot"
:name="`cell-${cell.column.id}`"
:item="cell.row.original"
:cell="cell"
/>
<!-- Otherwise render the default cell content inline -->
<template v-else>
<!-- Pivot merge: skip content for hidden (merged) cells -->
<template
v-if="
!isPivotMergeHidden(
cell.row.original,
(cell.column.columnDef.meta as any)?._col,
)
"
>
<!-- Dashboard: copy button LEFT (right-aligned columns) -->
<q-btn
v-if="
enableCellCopy &&
(cell.column.columnDef.meta as any)?.align ===
'right' &&
shouldShowCopyButton(cell.getValue())
"
:icon="
isCellCopied(virtualRow.index, cell.column.id)
? 'check'
: 'content_copy'
"
dense
size="xs"
no-caps
flat
class="copy-btn q-mr-xs"
@click.stop="
copyCellContent(
getCellDisplayValue(cell),
virtualRow.index,
cell.column.id,
)
"
/>
<!-- Dashboard: JSON field inline renderer -->
<JsonFieldRenderer
v-if="
(cell.column.columnDef.meta as any)?.showFieldAsJson
"
:value="cell.getValue()"
/>
<!-- Logs: FTS-highlighted text -->
<span
v-else-if="
processedResults[
`${cell.column.id}_${calculateActualIndex(virtualRow.index)}`
]
"
:key="`${cell.column.id}_${calculateActualIndex(virtualRow.index)}`"
:class="store.state.theme === 'dark' ? 'dark' : ''"
v-html="
processedResults[
`${cell.column.id}_${calculateActualIndex(virtualRow.index)}`
]
"
/>
<!-- Default value (dashboard: format fn; logs: renderValue) -->
<span
:style="{
width:
cell.column.columnDef.id !== 'source' ||
cell.column.columnDef.enableResizing
? `calc((var(--col-${sanitizeCssId(cell.column.columnDef.id)}-size) * 1px) - 0.5rem)`
: wrap
? width - 260 - 12 + 'px'
: 'auto',
}"
:class="[
!props.wrap
? 'tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap'
: '',
props.wrap ? 'tw:break-words' : '',
]"
v-else
>
{{ getCellDisplayValue(cell) }}
</span>
<!-- Logs: AI context button -->
<O2AIContextAddBtn
v-if="
enableAiContextButton &&
cell.column.columnDef.id ===
store.state.zoConfig.timestamp_column
"
class="tw:absolute tw:top-[14px] tw:left-[18px] tw:transform tw:invisible tw:-translate-y-1/2 ai-btn"
@send-to-ai-chat="
sendToAiChat(
JSON.stringify(cell.row.original),
true,
)
"
/>
<!-- Dashboard: copy button RIGHT (left/center-aligned) -->
<q-btn
v-if="
enableCellCopy &&
(cell.column.columnDef.meta as any)?.align !==
'right' &&
shouldShowCopyButton(cell.getValue())
"
:icon="
isCellCopied(virtualRow.index, cell.column.id)
? 'check'
: 'content_copy'
"
dense
size="xs"
no-caps
flat
class="copy-btn q-ml-xs"
@click.stop="
copyCellContent(
getCellDisplayValue(cell),
virtualRow.index,
cell.column.id,
)
"
/>
</template>
</template>
</div>
</td>
</template>
</tr>
</template>
<!-- Empty slot: shown when rows is empty and not loading -->
<tr v-if="!loading && tableRows.length === 0" class="tw:w-full">
<td :colspan="columnOrder.length">
<slot name="empty" />
</td>
</tr>
</tbody>
<!-- ── Dashboard: sticky grand-total row ──────────────────────────────── -->
<tfoot
v-if="stickyTotalRow"
style="
position: sticky;
bottom: 0;
z-index: 10;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
"
>
<tr class="pivot-total-row pivot-sticky-total-row">
<td
v-for="col in (columns as any[]) || []"
:key="'ft_' + col.name"
class="tw:px-2"
:class="[
col.align === 'right'
? 'tw:text-right'
: col.align === 'center'
? 'tw:text-center'
: 'tw:text-left',
{ 'sticky-column': col.sticky },
{ 'pivot-total-col': stickyColTotals && col._isTotalColumn },
]"
:style="
[
getStickyTotalColumnStyle(col),
getStickyColumnStyle(col),
] as any
"
>
{{
stickyTotalRow[col.field] === undefined ||
stickyTotalRow[col.field] === null
? ""
: col.format
? col.format(stickyTotalRow[col.field], stickyTotalRow)
: stickyTotalRow[col.field]
}}
</td>
</tr>
</tfoot>
</table>
</div>
<!-- end scroll container -->
<!-- ── Bottom slot: pagination footer or custom footer ──── -->
<!-- Lives OUTSIDE the scroll container so it's always visible -->
<template v-if="$slots.bottom">
<slot
name="bottom"
:setRowsPerPage="
(val: number) => {
localRowsPerPage = val;
currentPage = 1;
}
"
:paginationOptions="paginationOptions"
:totalRows="tableRows.length"
:pagination="{ rowsPerPage: localRowsPerPage, page: currentPage }"
:pagesNumber="pagesNumber"
:isFirstPage="currentPage === 1"
:isLastPage="currentPage >= pagesNumber"
:firstPage="() => (currentPage = 1)"
:prevPage="() => (currentPage = Math.max(1, currentPage - 1))"
:nextPage="
() => {
currentPage = Math.min(pagesNumber, currentPage + 1);
}
"
:lastPage="
() => {
currentPage = pagesNumber;
}
"
/>
</template>
</div>
</template>
<script setup lang="ts">
import {
ref,
shallowRef,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
ComputedRef,
} from "vue";
import type { PropType } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";
import {
FlexRender,
type ColumnDef,
type SortingState,
useVueTable,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/vue-table";
import { useStore } from "vuex";
import { useI18n } from "vue-i18n";
import { VueDraggableNext as VueDraggable } from "vue-draggable-next";
import { debounce, copyToClipboard } from "quasar";
import O2AIContextAddBtn from "@/components/common/O2AIContextAddBtn.vue";
import { extractStatusFromLog } from "@/utils/logs/statusParser";
import { useTextHighlighter } from "@/composables/useTextHighlighter";
import { useLogsHighlighter } from "@/composables/useLogsHighlighter";
// ── Dashboard additions ──────────────────────────────────────────────────────
import { useStickyColumns } from "@/composables/useStickyColumns";
import {
TABLE_ROWS_PER_PAGE_DEFAULT_VALUE,
PIVOT_TABLE_TOTAL_COLUMN_WIDTH,
PIVOT_TABLE_ROW_KEY_SEPARATOR,
} from "@/utils/dashboard/constants";
import JsonFieldRenderer from "@/components/dashboards/panels/JsonFieldRenderer.vue";
export interface StreamField {
name: string;
isSchemaField: boolean;
}
const props = defineProps({
rows: {
type: Array,
default: () => [],
},
columns: {
type: Array,
default: () => [],
},
wrap: {
type: Boolean,
default: false,
},
width: {
type: Number,
default: 56,
},
loading: {
type: Boolean,
default: false,
},
errMsg: {
type: String,
default: "",
},
functionErrorMsg: {
type: String,
default: "",
},
expandedRows: {
type: Array,
default: () => [],
},
highlightTimestamp: {
type: Number,
default: -1,
},
defaultColumns: {
type: Boolean,
default: () => true,
},
highlightQuery: {
type: String,
default: "",
required: false,
},
selectedStreamFtsKeys: {
type: Array as PropType<StreamField[]>,
default: () => [],
},
hideExpandFieldOptions: {
type: Boolean,
default: false,
},
// ── Generic / traces props ───────────────────────────────────────────────
/** Active sort backend field name (server-side sort mode). When provided,
* sortable column headers emit 'sort-change' instead of sorting client-side. */
sortBy: {
type: String as PropType<string | undefined>,
default: undefined,
},
/** Active sort direction for server-side sort mode. */
sortOrder: {
type: String as PropType<"asc" | "desc" | undefined>,
default: undefined,
},
/** Maps column id → backend field name for server-side sort.
* E.g. { timestamp: 'start_time', duration: 'duration' } */
sortFieldMap: {
type: Object as PropType<Record<string, string> | undefined>,
default: undefined,
},
/** Returns Quasar/Tailwind class(es) for a given row.
* Use 'oz-table__row--error' to trigger the red left-border error variant. */
rowClass: {
type: Function as PropType<
(row: any) => string | string[] | Record<string, boolean> | undefined
>,
default: undefined,
},
/** Fixed row height in px used by the virtualizer.
* When set, all rows use this fixed height and dynamic measurement is disabled. */
rowHeight: {
type: Number as PropType<number>,
default: 22,
},
// ── Feature flags (all default true → current logs behavior unchanged) ──
/** Show VueDraggable column reorder in header. Default: true */
enableColumnReorder: {
type: Boolean,
default: true,
},
/** Show expand chevron and inline JSON row expansion. Default: true */
enableRowExpand: {
type: Boolean,
default: true,
},
/** Run FTS text highlighting (processHitsInChunks). Default: true */
enableTextHighlight: {
type: Boolean,
default: true,
},
/** Show left-side status color bar on each row. Default: true */
enableStatusBar: {
type: Boolean,
default: true,
},
enableAiContextButton: {
type: Boolean,
default: true,
},
// ── Dashboard / TableRenderer parity props ───────────────────────────────
/** When false, uses regular DOM rows (no virtual scroll). Set to false for dashboard/small-data tables.
* Default: true (virtual scroll mode for logs/traces). */
useVirtualScroll: {
type: Boolean,
default: true,
},
/** Pivot multi-level header config produced by the dashboard pivot transformer. */
pivotHeaderLevels: {
type: Array,
default: () => [],
},
/** Grand-total row pinned to the bottom of the table (dashboard pivot). */
stickyTotalRow: {
type: Object as PropType<any | null>,
default: null,
},
/** Pin the grand-total row to the bottom of the scroll area. */
stickyRowTotals: {
type: Boolean,
default: false,
},
/** Pin total columns to the right edge of the table. */
stickyColTotals: {
type: Boolean,
default: false,
},
/** Optional function to compute inline style string for a cell.
* Use this to add cell background coloring from the parent.
* Returns empty string by default (no coloring). */
getCellStyle: {
type: Function as PropType<(cell: any) => string>,
default: undefined,
},
/** Show per-cell copy-to-clipboard button on hover. Default: false */
enableCellCopy: {
type: Boolean,
default: false,
},
/** Enable paginated mode (turns off virtual scroll). Default: false */
showPagination: {
type: Boolean,
default: false,
},
/** Rows per page when `showPagination` is true. */
rowsPerPage: {
type: Number,
default: TABLE_ROWS_PER_PAGE_DEFAULT_VALUE,
},
});
const { t } = useI18n();
const emits = defineEmits([
"closeColumn",
"click:dataRow",
"update:columnSizes",
"update:columnOrder",
"expandRow",
"sendToAiChat",
"sort-change",
]);
const sorting = ref<SortingState>([]);
/** Replace characters invalid in CSS custom property names (e.g. dots) with underscores. */
const sanitizeCssId = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_");
const store = useStore();
const { isFTSColumn } = useTextHighlighter();
const { processedResults, processHitsInChunks } = useLogsHighlighter();
// ── Dashboard: sticky columns composable ─────────────────────────────────────
// useStickyColumns reads props.columns (Quasar-format when useVirtualScroll=false).
const { getStickyColumnStyle, tableId } = useStickyColumns(props, store);
const getSortingHandler = (e: Event, fn: any) => {
return fn(e);
};
/** Handles header click. In server-side sort mode (sortBy prop provided),
* emits 'sort-change'. Otherwise falls back to TanStack client-side sort. */
const handleHeaderSortClick = (e: Event, column: any, toggleFn: any) => {
if (props.sortBy !== undefined && column.columnDef.meta?.sortable) {
const field = props.sortFieldMap?.[column.id] ?? column.id;
const newOrder =
field === props.sortBy
? props.sortOrder === "desc"
? "asc"
: "desc"
: "desc";
emits("sort-change", field, newOrder);
} else {
getSortingHandler(e, toggleFn);
}
};
const setSorting = (sortingUpdater: any) => {
const newSortVal = sortingUpdater(sorting.value);
sorting.value = newSortVal;
};
const tableBodyRef = ref<any>(null);
const columnResizeMode = "onChange";
const tableRowSize = ref(0);
const columnOrder = ref<any>([]);
const tableRows = shallowRef<any[]>([...(props.rows ?? [])]);
// ── Dashboard: convert Quasar column defs → TanStack ColumnDef[] ─────────────
const dashboardColumns = computed<ColumnDef<unknown, any>[] | null>(() => {
if (props.useVirtualScroll || !props.columns) return null;
return (props.columns as any[])
.filter((col: any) => col.name != null && String(col.name) !== "")
.map((col: any) => ({
// Use col.field as the unique TanStack ID instead of col.name (display label).
// Multiple dashboard columns can share the same label (e.g. both named "Timestamp"),
// but each has a distinct field key (e.g. "x_axis_1" vs "y_axis_1").
// TanStack stores columns in a Map keyed by id; duplicate ids cause the last column
// to overwrite earlier ones, so row.getValue(id) returns the wrong accessor for every
// cell sharing that id — the format function then receives the wrong raw value.
id: String(col.field ?? col.name),
header: String(col.label ?? col.name),
...(typeof col.field === "function"
? { accessorFn: col.field }
: { accessorKey: String(col.field ?? col.name) }),
meta: {
align: col.align,
format: col.format,
sticky: col.sticky,
colorMode: col.colorMode,
showFieldAsJson: col.showFieldAsJson,
_isRowField: col._isRowField,
_isTotalColumn: col._isTotalColumn,
_totalColRightIndex: col._totalColRightIndex,
_col: col, // reference to original Quasar column for style helpers
sortable: col.sortable, // mirrors Quasar column flag — drives sort icon visibility
},
}));
});
// ── Dashboard: pivot helpers ─────────────────────────────────────────────────
const pivotHeaderLevels = computed(() => props.pivotHeaderLevels || []);
const pivotRowColumns = computed(() =>
((props.columns as any[]) || []).filter((c: any) => c._isRowField),
);
const stickyRowTotals = computed(() => !!props.stickyRowTotals);
const stickyColTotals = computed(() => !!props.stickyColTotals);
const stickyTotalRow = computed(() => props.stickyTotalRow || null);
const isPivotMode = computed(() => pivotHeaderLevels.value.length > 0);
// Any table with left-sticky columns needs border-collapse:separate so that
// adjacent cells don't paint over the sticky cell's box-shadow.
const hasStickyColumns = computed(() =>
((props.columns as any[]) || []).some((c: any) => c.sticky),
);
const usesSeparateBorders = computed(
() => isPivotMode.value || hasStickyColumns.value,
);
// Dashboard virtual scroll: enabled when not using logs virtual scroll and not paginated.
// Uses spacer rows + @tanstack/vue-virtual to render only visible rows (matching
// the old Quasar QTable :virtual-scroll behaviour).
// Disabled when `wrap` is true — wrapped rows have highly variable heights
// (29-81px) that cause large total-height jumps during scroll, which manifests
// as visible flicker. Rendering all rows in DOM is acceptable for dashboard
// panels (typically ≤1000 rows).
const dashVirtualEnabled = computed(
() => !props.useVirtualScroll && !props.showPagination && !props.wrap,
);
// Pivot sort state (managed manually — TanStack sort is disabled for pivot).
const pivotSortState = ref<{ sortBy: string; descending: boolean }>({
sortBy: "",
descending: false,
});
const handlePivotSort = (field: string) => {
if (pivotSortState.value.sortBy === field) {
pivotSortState.value = {
...pivotSortState.value,
descending: !pivotSortState.value.descending,
};
} else {
pivotSortState.value = { sortBy: field, descending: false };
}
};
// Re-sort pivot rows whenever sort state changes.
// Use serialized watch source to avoid deep comparison overhead.
watch(
() => `${pivotSortState.value.sortBy}_${pivotSortState.value.descending}`,
() => {
if (!isPivotMode.value) return;
const rows = ((props.rows as any[]) || []).filter(
(r: any) => !r.__isTotalRow,
);
const { sortBy, descending } = pivotSortState.value;
if (!sortBy) {
tableRows.value = [...rows];
return;
}
const col = ((props.columns as any[]) || []).find(
(c: any) => c.name === sortBy,
);
tableRows.value = [...rows].sort((a: any, b: any) => {
const va = a[sortBy];
const vb = b[sortBy];
let result: number;
if (col?.sort) result = col.sort(va, vb, a, b);
else if (typeof va === "number" && typeof vb === "number")
result = va - vb;
else result = String(va ?? "").localeCompare(String(vb ?? ""));
return descending ? -result : result;
});
},
);
// ── Dashboard: pivot merge map ───────────────────────────────────────────────
const pivotMergeMap = computed(() => {
const map = new Map<
string,
Record<string, { hideContent: boolean; hideBorder: boolean }>
>();
const rowCols = pivotRowColumns.value;
if (rowCols.length === 0) return map;
const rows = tableRows.value.filter((r: any) => !r.__isTotalRow);
if (rows.length === 0) return map;
const getRowKey = (row: any) =>
rowCols
.map((c: any) => String(row[c.name] ?? ""))
.join(PIVOT_TABLE_ROW_KEY_SEPARATOR);
for (let colIdx = 0; colIdx < rowCols.length; colIdx++) {
const col = rowCols[colIdx];
let groupStart = 0;
for (let i = 0; i <= rows.length; i++) {
let sameGroup = i < rows.length;
if (sameGroup) {
for (let p = 0; p <= colIdx; p++) {
if (rows[i][rowCols[p].name] !== rows[groupStart][rowCols[p].name]) {
sameGroup = false;
break;
}
}
}
if (!sameGroup) {
const size = i - groupStart;
if (size > 1) {
for (let r = groupStart; r < i; r++) {
const key = getRowKey(rows[r]);
if (!map.has(key)) map.set(key, {});
map.get(key)![col.name] = {
hideContent: r !== groupStart,
hideBorder: r < i - 1,
};
}
}
groupStart = i;
}
}
}
return map;
});
const pivotRowKey = (row: any) =>
pivotRowColumns.value
.map((c: any) => String(row[c.name] ?? ""))
.join(PIVOT_TABLE_ROW_KEY_SEPARATOR);
const isPivotMergeHidden = (row: any, col: any): boolean => {
if (!col?._isRowField) return false;
return (
pivotMergeMap.value.get(pivotRowKey(row))?.[col.name]?.hideContent === true
);
};
const isPivotMergeNoBorder = (row: any, col: any): boolean => {
if (!col?._isRowField) return false;
return (
pivotMergeMap.value.get(pivotRowKey(row))?.[col.name]?.hideBorder === true
);
};
// ── Dashboard: sticky total columns ─────────────────────────────────────────
const getStickyTotalColumnStyle = (col: any) => {
if (!stickyColTotals.value || !col?._isTotalColumn) return {};
const rightOffset =
(col._totalColRightIndex ?? 0) * PIVOT_TABLE_TOTAL_COLUMN_WIDTH;
const style = {
position: "sticky",
right: `${rightOffset}px`,
"z-index": 2,
width: `${PIVOT_TABLE_TOTAL_COLUMN_WIDTH}px`,
"min-width": `${PIVOT_TABLE_TOTAL_COLUMN_WIDTH}px`,
"max-width": `${PIVOT_TABLE_TOTAL_COLUMN_WIDTH}px`,
"background-color": store.state.theme === "dark" ? "#565656" : "#E0E0E0",
"box-shadow": "-4px 0 8px rgba(0, 0, 0, 0.15)",
"white-space": "normal",
"word-break": "break-word",
};
return style;
};
const getStickyTotalHeaderForPivot = (cell: any) => {
if (!stickyColTotals.value) return {};
const rightOffset =
(cell._totalColRightIndex ?? 0) * PIVOT_TABLE_TOTAL_COLUMN_WIDTH;
const width = cell.colspan
? cell.colspan * PIVOT_TABLE_TOTAL_COLUMN_WIDTH
: PIVOT_TABLE_TOTAL_COLUMN_WIDTH;
const style = {
position: "sticky",
right: `${rightOffset}px`,
top: 0,
"z-index": 11,
width: `${width}px`,
"min-width": `${width}px`,
"max-width": `${width}px`,
// Opaque background so scrolled body content doesn't bleed through
"background-color": "inherit",
"box-shadow": "-4px 0 8px rgba(0, 0, 0, 0.15)",
"white-space": "normal",
"word-break": "break-word",
// Remove border so shadow aligns with body total column
"border": "none",
};
return style;
};
const getCellDisplayValue = (cell: any): any => {
const value = cell.getValue();
const format = (cell.column.columnDef.meta as any)?.format;
// If a format function is defined (dashboard mode), always call it — this allows
// no_value_replacement to be applied even when the raw cell value is null/undefined.
if (value === "undefined" || value === null || value === undefined) {
return format ? format(null, cell.row.original) : "";
}
return format ? format(value, cell.row.original) : value;
};
// ── Dashboard: cell copy ─────────────────────────────────────────────────────
const copiedCells = ref(new Map<string, boolean>());
const isCellCopied = (rowIndex: number, colName: string) =>
copiedCells.value.has(`${rowIndex}_${colName}`);
const shouldShowCopyButton = (value: any) => {
if (value === null || value === undefined || value === "undefined")
return false;
return String(value).trim() !== "";
};
const copyCellContent = (value: any, rowIndex: number, colName: string) => {
if (value === null || value === undefined) return;
copyToClipboard(String(value))
.then(() => {
const key = `${rowIndex}_${colName}`;
copiedCells.value.set(key, true);
setTimeout(() => copiedCells.value.delete(key), 3000);
})
.catch(() => {});
};
// ── Dashboard: pagination ────────────────────────────────────────────────────
const currentPage = ref(1);
const localRowsPerPage = ref(
props.showPagination
? props.rowsPerPage || TABLE_ROWS_PER_PAGE_DEFAULT_VALUE
: 0,
);
watch(
() => [props.showPagination, props.rowsPerPage] as const,
([newShow, newRpp]) => {
localRowsPerPage.value = newShow
? newRpp || TABLE_ROWS_PER_PAGE_DEFAULT_VALUE
: 0;
currentPage.value = 1;
},
);
const paginationOptions = computed(() => {
if (!props.showPagination) return [0];
const base = [10, 20, 50, 100, 250, 500, 1000];
const configured = props.rowsPerPage || TABLE_ROWS_PER_PAGE_DEFAULT_VALUE;
const set = new Set(base);
if (configured > 0) set.add(configured);
const sorted = Array.from(set).sort((a, b) => a - b);
sorted.push(0);
return sorted;
});
const pagesNumber = computed(() =>
localRowsPerPage.value === 0
? 1
: Math.ceil(tableRows.value.length / localRowsPerPage.value) || 1,
);
// pagedRows is defined after formattedRows below — see declaration near virtualizer.
const selectedStreamFtsKeys: ComputedRef<StreamField[]> = computed(() => {
return props.selectedStreamFtsKeys || [];
});
const isFunctionErrorOpen = ref(false);
const activeCellActionId = ref("");
const highlightQuery = computed(() => {
return props.highlightQuery;
});
const getRowStatusColor = (rowData: any) => {
const statusInfo = extractStatusFromLog(rowData);
return statusInfo.color;
};
watch(
() => props.columns,
async (newVal) => {
columnOrder.value = newVal.map((column: any) => column.id ?? column.name);
await nextTick();
if (
props.enableTextHighlight &&
props.columns?.length &&
props.rows?.length
) {
processHitsInChunks(
props.rows,
props.columns,
true,
props.highlightQuery,
100,
selectedStreamFtsKeys.value as unknown as string[],
);
}
// updateTableWidth is only needed for logs/traces auto-sizing
if (props.defaultColumns && props.useVirtualScroll) updateTableWidth();
},
{
immediate: true,
},
);
watch(
() => props.rows,
async (newVal) => {
if (!newVal) return;
// Filter out total rows (dashboard pivot); no-op for logs/traces
const rows = (newVal as any[]).filter((r: any) => !r.__isTotalRow);
// Apply pivot sort when active, otherwise just assign.
// For pivot mode we need a copy to sort in-place; for dashboard mode
// the parent (TableRenderer) already provides a fresh sorted array,
// so we can assign directly to avoid an unnecessary O(n) copy.
const { sortBy, descending } = pivotSortState.value;
if (isPivotMode.value && sortBy) {
const col = ((props.columns as any[]) || []).find(
(c: any) => c.name === sortBy || c.id === sortBy,
);
tableRows.value = [...rows].sort((a: any, b: any) => {
const va = a[sortBy];
const vb = b[sortBy];
let result: number;
if (col?.sort) result = col.sort(va, vb, a, b);
else if (typeof va === "number" && typeof vb === "number")
result = va - vb;
else result = String(va ?? "").localeCompare(String(vb ?? ""));
return descending ? -result : result;
});
} else if (props.useVirtualScroll) {
// Logs/traces: copy so expand-row splices don't mutate parent data
tableRows.value = [...rows];
} else {
// Dashboard: parent already provides a fresh reference
tableRows.value = rows as any[];
}
await nextTick();
if (
props.enableTextHighlight &&
props.columns?.length &&
props.rows?.length
) {
processHitsInChunks(
props.rows,
props.columns,
false,
props.highlightQuery,
100,
selectedStreamFtsKeys.value as unknown as string[],
);
}
expandedRowIndices.value.clear();
// Clear height cache when rows change
expandedRowHeights.value = {};
// Clear actual index cache when rows change
actualIndexCache.value.clear();
setExpandedRows();
await nextTick();
// updateTableWidth is only needed for logs/traces auto-sizing (defaultColumns + virtual scroll)
if (props.defaultColumns && props.useVirtualScroll) updateTableWidth();
},
);
let table: any = useVueTable({
get data() {
return tableRows.value || [];
},
get columns() {
// Dashboard: use converted TanStack columns; Logs: use columns prop as-is.
return (dashboardColumns.value ?? props.columns) as ColumnDef<
unknown,
any
>[];
},
state: {
get sorting() {
return sorting.value;
},
get columnOrder() {
return columnOrder.value;
},
},
onSortingChange: setSorting,
// Disable TanStack client sort for pivot (rows are pre-sorted manually)
// and for dashboard mode (TableRenderer handles sorting externally).
get enableSorting() {
return !isPivotMode.value && props.useVirtualScroll;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
defaultColumn: {
minSize: 60,
maxSize: 800,
},
columnResizeMode,
enableColumnResizing: true,
});
const columnSizeVars = computed(() => {
const headers = table?.getFlatHeaders();
const colSizes: { [key: string]: number } = {};
for (let i = 0; i < headers.length; i++) {
const header = headers[i]!;
colSizes[`--header-${sanitizeCssId(header.id)}-size`] = header.getSize();
colSizes[`--col-${sanitizeCssId(header.column.id)}-size`] =
header.column.getSize();
}
return colSizes;
});
/** Maps sanitized column id → original column id for all current columns. */
const columnIdMap = computed(() => {
const map: Record<string, string> = {};
const headers = table?.getFlatHeaders() ?? [];
for (const header of headers) {
map[sanitizeCssId(header.column.id)] = header.column.id;
}
return map;
});
watch(columnSizeVars, (newColSizes) => {
debouncedUpdate(newColSizes, columnIdMap.value);
});
onMounted(() => {
setExpandedRows();
});
onBeforeUnmount(() => {
tableRows.value.length = 0;
tableRows.value = [];
tableBodyRef.value = null;
parentRef.value = null;
table = null;
});
const hasDefaultSourceColumn = computed(
() => props.defaultColumns && columnOrder.value.includes("source"),
);
const tableCellClass = ref<string[]>([]);
watch(
() => [hasDefaultSourceColumn.value, props.wrap],
() => {
tableCellClass.value = [
hasDefaultSourceColumn.value && !props.wrap
? "tw:table-cell"
: "tw:block height-stretch",
!props.wrap
? "tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap"
: "",
props.wrap ? "tw:break-words" : "",
];
},
{
immediate: true,
},
);
const updateTableWidth = async () => {
tableRowSize.value = tableBodyRef?.value?.children[0]?.scrollWidth;
setTimeout(() => {
let max = 0;
let width = max;
for (let i = 0; i < tableRows.value.length; i++) {
width = tableBodyRef?.value?.children[i]?.scrollWidth;
if (width > max) max = width;
}
tableRowSize.value = max;
}, 0);
};
const debouncedUpdate = debounce((newColSizes, idMap) => {
emits("update:columnSizes", newColSizes, idMap);
}, 500);
const formattedRows = computed(() => {
return table?.getRowModel().rows;
});
/** Stable primitive count for the virtualizer.
* Vue skips downstream notifications when a computed returns the same
* primitive value — so `rowVirtualizerOptions` won't re-evaluate (and
* `_willUpdate` / ResizeObserver won't re-fire) when `formattedRows`
* gets a new array reference with the same length. */
const rowCount = computed(() => formattedRows.value?.length ?? 0);
/** Rows for current page (dashboard).
* When pagination is enabled, slices by page. Otherwise returns all rows —
* dashboard virtual scroll handles limiting rendered DOM nodes. */
const pagedRows = computed(() => {
const all = formattedRows.value ?? [];
if (props.showPagination && localRowsPerPage.value > 0) {
const start = (currentPage.value - 1) * localRowsPerPage.value;
return all.slice(start, start + localRowsPerPage.value);
}
return all;
});
const isResizingHeader = ref(false);
const headerGroups = computed(() => table?.getHeaderGroups()[0]);
const headers = computed(() => headerGroups.value.headers);
watch(
() => headers.value,
(newVal) => {
isResizingHeader.value = newVal.some((header: any) =>
header.column.getIsResizing(),
);
},
{
deep: true,
},
);
const parentRef = ref<HTMLElement | null>(null);
const isFirefox = computed(() => {
return (
typeof document !== "undefined" &&
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("-moz-appearance", "none")
);
});
const baseOffset = isFirefox.value ? 20 : 0;
// Cache for expanded row heights
const expandedRowHeights = ref<{ [key: number]: number }>({});
/** Custom observeElementRect that avoids layout-thrashing during Vue's synchronous flush.
* The default TanStack Virtual implementation calls `offsetWidth`/`offsetHeight`
* synchronously during init, which forces a full layout recalculation for each of
* the 21 dashboard table virtualizers (792ms forced reflow). Using ResizeObserver
* only batches all measurements into a single async callback after mount. */
const deferredObserveElementRect = (
instance: any,
cb: (rect: { width: number; height: number }) => void,
) => {
const element = instance.scrollElement;
if (!element) return;
// Use the actual element dimensions for the initial estimate so the
// virtualizer renders the correct number of rows on first paint.
// Falling back to 800×300 only if layout hasn't been resolved yet.
const initialRect = element.getBoundingClientRect();
cb({
width: Math.round(initialRect.width) || 800,
height: Math.round(initialRect.height) || 300,
});
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry?.borderBoxSize?.[0]) {
const box = entry.borderBoxSize[0];
cb({
width: Math.round(box.inlineSize),
height: Math.round(box.blockSize),
});
} else if (entry) {
cb({
width: Math.round(entry.contentRect.width),
height: Math.round(entry.contentRect.height),
});
}
});
observer.observe(element, { box: "border-box" });
return () => observer.unobserve(element);
};
const rowVirtualizerOptions = computed(() => {
const isDashVirtual = dashVirtualEnabled.value;
// All non-logs tables (paginated + non-paginated dashboard panels)
const isNonLogs = !props.useVirtualScroll;
return {
count: rowCount.value,
getScrollElement: () => parentRef.value,
estimateSize: (index: number) => {
// Dashboard virtual scroll: always use 28px regardless of rowHeight prop.
// The rowHeight prop (default 22) is the *cell content* height and does not
// account for padding (py-1 = 8px) + border (1px), so using it here would
// cause a ~6px-per-row underestimate and a visible layout jump after measurement.
if (isDashVirtual) return 28;
if (props.rowHeight) return props.rowHeight;
// Logs/traces: check for expanded rows
const isExpandedRow = formattedRows.value[index]?.original?.isExpandedRow;
return isExpandedRow
? expandedRowHeights.value[index] || 300
: 28;
},
// Dashboard: moderate overscan keeps rows buffered above/below viewport.
// Logs/traces: high overscan for smooth fast-scroll.
overscan: isDashVirtual ? 20 : 100,
measureElement:
// When rowHeight is provided, skip dynamic measurement entirely
props.rowHeight && !isDashVirtual
? undefined
: typeof window !== "undefined" && !isFirefox.value
? (element: any) => {
const index = parseInt(element.dataset.index);
if (isNaN(index)) return props.rowHeight ?? 28;
// Dashboard: always measure so variable-height rows
// (wrapping text, etc.) are accounted for.
if (isDashVirtual) {
return element.getBoundingClientRect().height;
}
// Logs/traces: only measure expanded or wrapped rows
const isExpandedRow =
formattedRows.value[index]?.original?.isExpandedRow;
if (isExpandedRow || props.wrap) {
const height = element.getBoundingClientRect().height;
expandedRowHeights.value[index] = height;
return height;
}
return props.rowHeight ?? 28; // Default height for collapsed rows
}
: undefined,
// All dashboard tables: avoid layout-thrashing from sync getRect calls during Vue flush
...(isNonLogs ? { observeElementRect: deferredObserveElementRect } : {}),
// All dashboard tables: skip unnecessary scrollTo(0) during init
...(isNonLogs
? {
scrollToFn: (
offset: number,
opts: { adjustments?: number; behavior?: ScrollBehavior },
instance: any,
) => {
// Only scroll when the target offset is non-zero (user-initiated).
// The virtualizer calls scrollTo(0) during _willUpdate init, which
// forces a synchronous layout reflow — skip it entirely for freshly
// mounted panels where scrollTop is already 0.
const toOffset = offset + (opts.adjustments ?? 0);
if (toOffset === 0) return;
instance.scrollElement?.scrollTo({
[instance.options.horizontal ? "left" : "top"]: toOffset,
behavior: opts.behavior,
});
},
}
: {}),
};
});
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions);
/** Defers measureElement outside Vue's synchronous render flush to prevent
* recursive reactive update warnings. Called via :ref on dashboard rows. */
const measureDashboardRow = (node: any) => {
if (node && dashVirtualEnabled.value) {
queueMicrotask(() => rowVirtualizer.value.measureElement(node));
}
};
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(
() => rowVirtualizer.value.getTotalSize() + (props.rowHeight ?? 0),
);
// ── Dashboard virtual scroll: spacer row heights ─────────────────────────────
const dashVirtualPaddingTop = computed(() => {
if (!dashVirtualEnabled.value) return 0;
const items = virtualRows.value;
return items.length > 0 ? items[0].start : 0;
});
const dashVirtualPaddingBottom = computed(() => {
if (!dashVirtualEnabled.value) return 0;
const items = virtualRows.value;
if (items.length === 0) return 0;
return rowVirtualizer.value.getTotalSize() - items[items.length - 1].end;
});
/** Unified row iterator for dashboard mode (both paginated and virtual scroll).
* Each item has `row` (TanStack Row) and `idx` (row index in the full dataset). */
const renderedDashboardRows = computed(() => {
if (dashVirtualEnabled.value) {
const items = virtualRows.value;
const rows = formattedRows.value ?? [];
return items
.map((vi: any) => ({ row: rows[vi.index], idx: vi.index }))
.filter((r: any) => r.row);
}
// Paginated mode: use TanStack's global row index so page 2+ emits correct position
return (pagedRows.value ?? []).map((row: any) => ({
row,
idx: row.index,
}));
});
const setExpandedRows = () => {
props.expandedRows.forEach((index: any) => {
const virtualIndex = calculateVirtualIndex(index);
if (index < props.rows.length) {
expandRow(virtualIndex as number);
}
});
// Clear the actual index cache since expanded rows are changing
actualIndexCache.value.clear();
};
const closeColumn = (data: any) => {
emits("closeColumn", data);
};
const handleDragStart = (event: any) => {
if (
columnOrder.value[event.oldIndex] === store.state.zoConfig.timestamp_column
) {
isResizingHeader.value = true;
} else {
isResizingHeader.value = false;
}
};
const handleDragEnd = () => {
emits("update:columnOrder", columnOrder.value, props.columns);
};
const expandedRowIndices = ref<Set<number>>(new Set());
const handleExpandRow = (index: number) => {
// Calculate actual index BEFORE expanding (using current state)
const actualIndex = calculateActualIndex(index);
// Emit the event with the calculated actual index
emits("expandRow", actualIndex);
// Now expand the row (this modifies expandedRowIndices)
expandRow(index);
// Clear the actual index cache since expanded rows have changed
actualIndexCache.value.clear();
};
const expandRow = async (index: number) => {
let isCollapseOperation = false;
if (expandedRowIndices.value.has(index)) {
// COLLAPSE OPERATION
expandedRowIndices.value.delete(index);
// Clear cached height for collapsed row
delete expandedRowHeights.value[index + 1];
// Remove the expanded row from tableRows
tableRows.value.splice(index + 1, 1);
isCollapseOperation = true;
// Update all expanded indices that come after this collapsed row
const updatedIndices = new Set<number>();
expandedRowIndices.value.forEach((i) => {
updatedIndices.add(i > index ? i - 1 : i);
});
expandedRowIndices.value = updatedIndices;
} else {
// EXPAND OPERATION
// First, update all expanded indices that come at or after this position
const updatedIndices = new Set<number>();
expandedRowIndices.value.forEach((i) => {
updatedIndices.add(i >= index ? i + 1 : i);
});
// Add the new expanded index
updatedIndices.add(index);
expandedRowIndices.value = updatedIndices;
// Insert the expanded row
tableRows.value.splice(index + 1, 0, {
isExpandedRow: true,
...(props.rows[index] as {}),
});
}
tableRows.value = [...tableRows.value];
await nextTick();
if (isCollapseOperation) {
expandedRowIndices.value.forEach((expandedIndex) => {
if (expandedIndex !== -1) {
formattedRows.value[expandedIndex].toggleExpanded();
}
});
} else {
// For expand operation, measure height after DOM update
await nextTick();
// Force the virtualizer to recalculate all sizes
if (rowVirtualizer.value) {
// Find the actual expanded row element
const expandedElement = document.querySelector(
`[data-index="${index + 1}"]`,
);
if (expandedElement && rowVirtualizer.value.measureElement) {
rowVirtualizer.value.measureElement(expandedElement);
}
// Trigger a full recalculation
// rowVirtualizer.value.measure();
}
}
};
// Cache for calculated actual indices - maps virtual index to actual index
// Cache is cleared whenever expandedRowIndices changes (expand/collapse operations)
const actualIndexCache = ref<Map<number, number>>(new Map());
/**
* Converts a virtual index (including expanded rows) to an actual index (in original data).
* Uses caching to avoid redundant calculations, especially during render and hover events.
*
* @param virtualIndex - Index in the displayed table (includes expanded rows)
* @returns Actual index in the original data array (without expanded rows)
*/
const calculateActualIndex = (virtualIndex: number): number => {
// Check cache first for O(1) lookup
if (actualIndexCache.value.has(virtualIndex)) {
return actualIndexCache.value.get(virtualIndex)!;
}
// Calculate actual index from virtual index
// For each expanded row before this virtual index, subtract 1
let actualIndex = virtualIndex;
expandedRowIndices.value.forEach((expandedIndex) => {
if (expandedIndex !== -1 && expandedIndex < virtualIndex) {
actualIndex -= 1;
}
});
// Store in cache for future lookups
actualIndexCache.value.set(virtualIndex, actualIndex);
return actualIndex;
};
const calculateVirtualIndex = (index: number): number => {
let virtualIndex = index;
expandedRowIndices.value.forEach((expandedIndex) => {
if (expandedIndex !== -1 && expandedIndex < virtualIndex) {
virtualIndex += 1;
}
});
return virtualIndex;
};
const handleDataRowClick = (row: any, index: number, event?: MouseEvent) => {
const actualIndex = calculateActualIndex(index);
if (actualIndex !== -1) {
emits("click:dataRow", row, actualIndex, event);
}
};
const expandFunctionError = () => {
isFunctionErrorOpen.value = !isFunctionErrorOpen.value;
};
// Specific function that updates the active cell action ID
const updateActiveCell = (cell?: { id: string; column: { id: string } }) => {
if (!cell) {
activeCellActionId.value = "";
} else {
activeCellActionId.value = `${cell.id}_${cell.column.id}`;
}
};
// Debounced version of the function
const debounceCellAction = debounce(updateActiveCell, 1500);
// Event handlers for mouse over and mouse leave
const handleCellMouseOver = (cell: { id: string; column: { id: string } }) => {
debounceCellAction(cell);
};
const handleCellMouseLeave = () => {
activeCellActionId.value = "";
};
const sendToAiChat = (
value: any,
isEntireRow: boolean = false,
append: boolean = true,
) => {
if (isEntireRow) {
//here we will get the original value of the row
//and we need to filter the row if props.columns have any filtered cols that user applied
//the format of the props.columns is like this:
//if user have not applied any filter then the props.columns will be like this:
//it contains _timestamp column and source column
//else we get _timestamp column and other filter columns so if user have applied any filter then we need to filter the row based on the filter columns
const row = JSON.parse(value);
//lets filter based on props.columns so lets ignore _timestamp column as it is always present and now we want to check if source is present we can directly send the row
//otherwise we need to filter the row based on the columns that user have applied
if (checkIfSourceColumnPresent(props.columns)) {
emits("sendToAiChat", JSON.stringify(row), append);
} else {
//we need to filter the row based on the columns that user have applied
const filteredRow = filterRowBasedOnColumns(row, props.columns);
emits("sendToAiChat", JSON.stringify(filteredRow), append);
}
} else {
emits("sendToAiChat", value, append);
}
};
const checkIfSourceColumnPresent = (columns: any) => {
//we need to check if source column is present in the columns
//if present then we need to return true else false
return columns.some((column: any) => column.id === "source");
};
const filterRowBasedOnColumns = (row: any, columns: any) => {
//we need to filter the row based on the columns that user have applied
//here we need to filter row not columns based on the columns that user have applied
const columnsToFilter = columns.filter(
(column: any) => column.id !== "source",
);
return columnsToFilter.reduce((acc: any, column: any) => {
acc[column.id] = row[column.id];
return acc;
}, {});
};
defineExpose({
parentRef,
virtualRows,
sendToAiChat,
store,
selectedStreamFtsKeys,
processedResults,
/** Returns the current sorted/filtered rows as plain objects (for export, etc.) */
getRows: () => table?.getRowModel().rows.map((r: any) => r.original) ?? [],
});
</script>
<style>
@import "@/assets/styles/log-highlighting.css";
</style>
<style scoped lang="scss">
@import "@/styles/logs/tenstack-table.scss";
// Outer wrapper for the table (used for sticky-column CSS scoping via data-sticky-id)
.my-sticky-virtscroll-table {
overflow: hidden;
}
// Add explicit hover styles for log rows
.table-row-hover {
transition: background-color 0.15s ease-in-out;
&:hover {
background-color: var(--o2-hover-gray) !important;
}
}
// ── Dashboard / pivot table styles ──────────────────────────────────────────
// Pivot multi-level header cells - sticky with border and shadow
.pivot-group-header {
text-align: center;
font-weight: 600;
vertical-align: middle;
white-space: nowrap;
padding-top: 5px;
padding-bottom: 5px;
border-right: 1px solid rgba(0, 0, 0, 0.15);
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
}
.pivot-value-header {
text-align: center;
font-weight: 500;
font-size: 0.85em;
vertical-align: middle;
padding-top: 5px;
padding-bottom: 5px;
border-right: 1px solid rgba(0, 0, 0, 0.15);
}
// Column separator between pivot sections
.pivot-section-border {
border-left: 2px solid rgba(0, 0, 0, 0.2) !important;
}
// Right-sticky total column body cells
.pivot-total-col {
font-weight: 600;
}
// Cells hidden by pivot row merging (duplicates suppressed)
.pivot-no-border {
border-bottom: none !important;
}
// Left-sticky columns
.sticky-column {
background-color: inherit;
}
// Sticky total row at bottom
.pivot-sticky-total-row {
font-weight: bold;
position: sticky;
bottom: 0;
z-index: 9;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
td {
background-color: var(--q-color-grey-2, #f5f5f5);
position: sticky;
bottom: 0;
}
}
// Sort icon shown in pivot header clicks
.pivot-sort-icon {
vertical-align: middle;
opacity: 0;
transition: opacity 0.2s;
&.pivot-sort-active {
opacity: 1 !important;
color: var(--q-primary);
}
}
// Copy button — only visible on cell hover
.copy-cell-td {
.copy-btn {
opacity: 0;
transition: opacity 0.15s;
}
&:hover .copy-btn {
opacity: 1;
}
}
</style>