Files
openobserve/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue
T
Omkar Kesarkhane 76bd5dd822 fix: added skeleton loading for traces table (#12593)
## Why

The custom spinner-based loading states were inconsistent across traces
views anddid not align with the logs TenstackTable skeleton pattern
already established inthe codebase. The new skeleton provides a unified,
low-friction loading experience — shimmer pills at column-aligned
positions that mirror real cell widths — and removes the need for each
consumer to write its own loading template.
2026-06-12 07:45:30 +00:00

3095 lines
99 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>
<ODrawer
:open="visible"
:width="60"
:seamless="true"
:portal-target="containerEl"
:title="selectedNode?.name || selectedNode?.label || selectedNode?.id"
data-test="service-graph-side-panel"
@update:open="(v) => { if (!v) handleClose() }"
>
<template #header-right>
<div class="tw:flex tw:items-center tw:gap-2">
<span class="health-badge" :class="serviceHealth.status">
{{ serviceHealth.text }}
</span>
<ODropdown side="bottom" align="start">
<template #trigger>
<OButton
variant="outline"
size="sm"
data-test="service-graph-node-panel-view-related-btn"
>
View Related
<OIcon name="arrow-drop-down" size="sm" />
</OButton>
</template>
<ODropdownItem
@select="viewRelatedLogs"
data-test="service-graph-node-panel-view-related-logs-btn"
>Logs</ODropdownItem
>
<ODropdownItem
@select="viewRelatedTraces"
data-test="service-graph-node-panel-view-related-traces-btn"
>Traces</ODropdownItem
>
</ODropdown>
</div>
</template>
<!-- Content Scrollable Area -->
<div class="panel-content">
<!-- RED Charts Section -->
<div
v-if="streamFilter !== 'all' && dashboardData"
class="panel-section red-charts-section tw:flex tw:flex-col tw:mb-0!"
data-test="service-graph-side-panel-red-charts"
>
<!-- DataZoom filter chips + View in Traces button -->
<div
v-if="filterChips.length"
class="tw:flex tw:items-center tw:gap-2 tw:px-2 tw:py-[0.3rem] tw:flex-wrap"
data-test="service-graph-side-panel-filter-chips"
>
<!-- Filter chip pills -->
<div
v-for="chip in filterChips"
:key="chip.key"
class="tw:inline-flex tw:items-center tw:gap-1 tw:rounded tw:border tw:border-[var(--o2-border)] tw:px-2 tw:py-[0.325rem] tw:text-[0.7rem] tw:leading-none tw:text-[var(--o2-text-primary)]"
:data-test="`service-graph-filter-chip-${chip.key}`"
:class="
chip.type === 'duration'
? 'tw:text-[var(--o2-latency-p95)]'
: 'tw:text-[var(--o2-status-error-text)]'
"
>
<!-- Duration chip icon -->
<OIcon
v-if="chip.type === 'duration'"
name="schedule"
size="xs"
class="tw:text-[var(--o2-latency-p95)]"
/>
<!-- Error chip icon -->
<OIcon
v-else-if="chip.type === 'error'"
name="warning"
size="xs"
class="tw:text-[var(--o2-status-error-text)]"
/>
<span
:class="
chip.type === 'duration'
? 'tw:text-[var(--o2-latency-p95)]'
: 'tw:text-[var(--o2-status-error-text)]'
"
>{{ chip.label }}</span
>
<OButton
variant="ghost"
size="icon-chip"
class="tw:ml-0.5"
:data-test="`service-graph-filter-chip-remove-${chip.key}`"
@click="removeLocalRangeFilter(chip.key)"
>
<OIcon name="close" size="xs" />
</OButton>
</div>
<!-- Spacer -->
<div class="tw:flex-1" />
<!-- View in Traces button -->
<OButton
variant="ghost-primary"
size="sm"
data-test="service-graph-side-panel-view-in-traces-btn"
@click="viewInTraces"
>
<template #icon-left>
<OIcon name="search" size="xs" />
</template>
View Traces
</OButton>
</div>
<div class="charts-wrapper tw:py-0! tw:min-h-[10.875rem] tw:w-full">
<div class="charts-container tw:w-full">
<RenderDashboardCharts
ref="dashboardChartsRef"
:viewOnly="true"
:dashboardData="dashboardData || {}"
:currentTimeObj="currentTimeObj"
:allowAlertCreation="false"
searchType="dashboards"
@updated:dataZoom="onDataZoom"
/>
</div>
</div>
</div>
<OSeparator class="tw:my-[1rem]!" />
<!-- Tabs: Operations / Nodes / Pods -->
<template v-if="streamFilter !== 'all'">
<div
class="tw:flex tw:items-end tw:border-b tw:border-b-[var(--o2-border-color)] tw:mx-[0.5rem] tw:mb-[0.375rem]"
data-test="service-graph-node-panel-tabs-row"
>
<OTabs
v-model="activeTab"
dense
align="left"
class="tw:font-bold tw:flex-1 tw:w-[calc(100%-2rem)]!"
data-test="service-graph-node-panel-tabs"
>
<OTab
name="operations"
label="Operations"
style="text-transform: capitalize"
data-test="service-graph-node-panel-tab-operations"
/>
<OTab
v-for="cfg in activeResourceTabConfigs"
:key="cfg.id"
:name="cfg.id"
:label="cfg.label"
style="text-transform: capitalize"
:data-test="`service-graph-node-panel-tab-${cfg.id}`"
/>
<OTab
v-if="!isInferred"
name="metrics"
label="Metrics"
style="text-transform: capitalize"
data-test="service-graph-node-panel-tab-metrics"
/>
</OTabs>
<!-- Resource tabs dropdown — shows/hides individual OTEL resource tabs -->
<!-- Hidden for inferred services (they have fixed tabs from the registry) -->
<ODropdown
v-if="!isInferred && availableResourceTabConfigs.length > 0"
side="bottom"
align="end"
>
<template #trigger>
<OButton
variant="ghost"
size="icon-sm"
data-test="service-graph-node-panel-workload-fields-btn"
>
<OIcon name="tune" size="sm" />
<OTooltip :content="t('common.resources')" />
</OButton>
</template>
<div
class="tw:min-w-[12rem]!"
data-test="service-graph-node-panel-workload-fields-menu"
>
<template v-for="env in detectedEnvironments" :key="env.key">
<div
class="tw:text-xs tw:px-3 tw:pb-0 tw:py-[0.375rem]! tw:uppercase tw:tracking-wide tw:text-muted-foreground"
>
{{ env.label }}
</div>
<ODropdownItem
v-for="cfg in availableResourceTabConfigs.filter(
(c) => c.environment === env.key,
)"
:key="cfg.id"
:data-test="`service-graph-node-panel-workload-field-${cfg.id}`"
class="tw:px-[0.325rem]! tw:h-[30px]! tw:min-h-[30px]!"
@select="(e) => { e.preventDefault(); toggleWorkloadField(cfg.id); }"
>
<template #icon-left>
<span @click.stop>
<OCheckbox
v-model="selectedWorkloadFields"
:value="cfg.id"
size="xs"
/>
</span>
</template>
<span class="tw:text-xs">
{{ cfg.label }}
<OTooltip :content="cfg.groupField" />
</span>
</ODropdownItem>
</template>
</div>
</ODropdown>
</div>
<OTabPanels v-model="activeTab" animated>
<!-- Operations Tab -->
<OTabPanel
name="operations"
class="tw:p-0! panel-section tw:mb-0!"
data-test="service-graph-side-panel-recent-operations"
>
<div
v-if="recentOperations.length === 0 && !loadingOperations"
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
No operations found
</div>
<div
v-else-if="recentOperations.length > 0 || loadingOperations"
class="tw:overflow-hidden tw:rounded"
data-test="service-graph-side-panel-operations-table"
>
<TenstackTable
:columns="operationsTableColumns"
:rows="sortedOperationsTableRows"
:sort-by="sortBy"
:sort-order="sortOrder"
:loading="loadingOperations"
:default-columns="false"
:enable-column-reorder="false"
:enable-row-expand="false"
:enable-text-highlight="false"
:enable-status-bar="false"
:enable-ai-context-button="false"
:row-height="28"
@sort-change="handleSortChange"
@click:data-row="
(row: any) =>
navigateToTraces({
operationName: row.operation,
callerService: isInferred ? row.caller : undefined,
})
"
>
<template #cell-errors="{ item }">
<span
:class="
item.errors > 0
? 'tw:text-[var(--q-negative)] tw:font-semibold'
: ''
"
>{{ item.errors }}</span
>
</template>
<template #cell-p99="{ item }">
<span
:class="
item.p99 > 0
? 'tw:text-[var(--o2-latency-p99)]'
: ''
"
>{{ formatOperationLatency(item.p99) }}</span
>
</template>
<template #cell-p95="{ item }">
<span
:class="
item.p95 > 0
? 'tw:text-[var(--o2-latency-p95)]'
: ''
"
>{{ formatOperationLatency(item.p95) }}</span
>
</template>
<template #cell-p75="{ item }">
<span
:class="
item.p75 > 0
? 'tw:text-[var(--o2-latency-p75)]'
: ''
"
>{{ formatOperationLatency(item.p75) }}</span
>
</template>
<template #cell-actions="{ row, column, active }">
<OButton
v-if="active"
variant="ghost"
size="icon"
class="tw:ml-1 tw:absolute! tw:right-2!"
data-test="service-graph-side-panel-view-traces-btn"
@click.stop="
navigateToTraces({
operationName: row.operation,
callerService: isInferred ? row.caller : undefined,
errorsOnly: column.id === 'errors',
minDurationMicros: isDurationColumn(column.id) ? row[column.id] : undefined
})
"
>
<OIcon name="search" size="xs" />
<OTooltip content="View in Traces" />
</OButton>
</template>
<template #empty>
<div
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
No operations found
</div>
</template>
</TenstackTable>
</div>
</OTabPanel>
<!-- Nodes Tab -->
<!-- Dynamic OTEL resource tabs (Pods, Nodes, Hosts, Containers, Functions, ECS Tasks…) -->
<OTabPanel
v-for="cfg in activeResourceTabConfigs"
:key="cfg.id"
:name="cfg.id"
class="tw:p-0! panel-section"
:data-test="`service-graph-side-panel-${cfg.id}`"
>
<div
v-if="!resourceTabData[cfg.id]?.length && !resourceTabLoading[cfg.id]"
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
No {{ cfg.label.toLowerCase() }} data found
</div>
<div
v-else-if="resourceTabData[cfg.id]?.length > 0 || resourceTabLoading[cfg.id]"
class="tw:overflow-hidden tw:rounded"
:data-test="`service-graph-side-panel-${cfg.id}-table`"
>
<TenstackTable
:columns="buildEntityTableColumns(cfg.colId, cfg.colLabel)"
:rows="sortResourceRows(buildResourceTableRows(cfg))"
:sort-by="sortBy"
:sort-order="sortOrder"
:loading="resourceTabLoading[cfg.id]"
:default-columns="false"
:enable-column-reorder="false"
:enable-row-expand="false"
:enable-text-highlight="false"
:enable-status-bar="false"
:enable-ai-context-button="false"
:row-height="28"
@sort-change="handleSortChange"
@click:data-row="
(row: any) =>
navigateToTraces({
resourceFilter: cfg.fields
? { fields: cfg.fields, value: row[cfg.colId] }
: { field: cfg.groupField, value: row[cfg.colId] },
})
"
>
<template #cell-actions="{ row, column, active }">
<OButton
v-if="active"
variant="ghost"
size="icon"
class="tw:ml-1 tw:absolute! tw:right-2!"
:data-test="`service-graph-side-panel-${cfg.id}-view-traces-btn`"
@click.stop="
navigateToTraces({
resourceFilter: cfg.fields
? { fields: cfg.fields, value: row[cfg.colId] }
: { field: cfg.groupField, value: row[cfg.colId] },
errorsOnly: column.id === 'errors',
minDurationMicros: isDurationColumn(column.id) ? row[column.id] : undefined
})
"
>
<OIcon name="search" size="xs" />
<OTooltip content="View in Traces" />
</OButton>
</template>
<template #cell-errors="{ item }">
<span
:class="
item.errors > 0
? 'tw:text-[var(--q-negative)] tw:font-semibold'
: ''
"
>{{ item.errors }}</span
>
</template>
<template #cell-p99="{ item }">
<span
:class="
item.p99 > 0
? 'tw:text-[var(--o2-latency-p99)]'
: ''
"
>{{ formatOperationLatency(item.p99) }}</span
>
</template>
<template #cell-p95="{ item }">
<span
:class="
item.p95 > 0
? 'tw:text-[var(--o2-latency-p95)]'
: ''
"
>{{ formatOperationLatency(item.p95) }}</span
>
</template>
<template #cell-p75="{ item }">
<span
:class="
item.p75 > 0
? 'tw:text-[var(--o2-latency-p75)]'
: ''
"
>{{ formatOperationLatency(item.p75) }}</span
>
</template>
<template #empty>
<div
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
No {{ cfg.label.toLowerCase() }} data found
</div>
</template>
</TenstackTable>
</div>
</OTabPanel>
<!-- Metrics Tab -->
<OTabPanel
v-if="!isInferred"
name="metrics"
class="tw:p-0! panel-section tw:mb-0! tw:h-full!"
data-test="service-graph-side-panel-metrics"
>
<!-- Loading state -->
<div
v-if="metricsCorrelationLoading"
class="tw:flex tw:items-center tw:gap-2 tw:py-3 tw:text-sm"
style="color: var(--o2-text-secondary)"
data-test="service-graph-side-panel-metrics-loading"
>
<OSpinner size="xs" />
<span>Loading metrics...</span>
</div>
<!-- Error state -->
<div
v-else-if="metricsCorrelationError"
class="tw:flex tw:flex-col tw:items-center tw:gap-3 tw:py-6 tw:text-center tw:text-sm"
style="color: var(--o2-text-secondary)"
data-test="service-graph-side-panel-metrics-error"
>
<span>{{ metricsCorrelationError }}</span>
<OButton
variant="ghost-primary"
size="sm"
data-test="service-graph-side-panel-metrics-retry-btn"
@click="fetchMetricsCorrelation(true)"
>Retry</OButton
>
</div>
<!-- Metrics dashboard -->
<TelemetryCorrelationDashboard
v-else-if="metricsCorrelationData"
mode="embedded-tabs"
external-active-tab="metrics"
:service-name="metricsCorrelationData.serviceName"
:matched-dimensions="metricsCorrelationData.matchedDimensions"
:additional-dimensions="
metricsCorrelationData.additionalDimensions
"
:matched-set-id="metricsCorrelationData.matchedSetId"
:chip-dimensions="metricsCorrelationData.chipDimensions"
:source-event="metricsCorrelationData.sourceEvent"
:metric-streams="metricsCorrelationData.metricStreams"
:log-streams="metricsCorrelationData.logStreams"
:trace-streams="metricsCorrelationData.traceStreams"
:source-stream="streamFilter"
source-type="traces"
:time-range="telemetryTimeRange"
:hide-dimension-filters="true"
:metric-group-definitions="metricGroupResources"
:panelHeight="12"
:panelWidth="96"
data-test="service-graph-side-panel-metrics-dashboard"
/>
<!-- Empty state -->
<div
v-else-if="metricsCorrelationLoaded"
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
data-test="service-graph-side-panel-metrics-empty"
>
No metrics available for this service.
</div>
</OTabPanel>
</OTabPanels>
</template>
</div>
</ODrawer>
<!-- Telemetry Correlation Dialog (reuses the same component as "show related" on logs page) -->
<TelemetryCorrelationDashboard
v-if="showTelemetryDialog && correlationData"
mode="dialog"
:service-name="correlationData.serviceName"
:matched-dimensions="correlationData.matchedDimensions"
:additional-dimensions="correlationData.additionalDimensions"
:matched-set-id="correlationData.matchedSetId"
:chip-dimensions="correlationData.chipDimensions"
:log-streams="correlationData.logStreams"
:metric-streams="correlationData.metricStreams"
:trace-streams="correlationData.traceStreams"
:time-range="telemetryTimeRange"
:metric-group-definitions="metricGroupResources"
@close="showTelemetryDialog = false"
/>
</template>
<script lang="ts">
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import OTabPanels from "@/lib/navigation/Tabs/OTabPanels.vue";
import OTabPanel from "@/lib/navigation/Tabs/OTabPanel.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import {
defineComponent,
computed,
ref,
watch,
defineAsyncComponent,
type PropType,
} from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import searchService from "@/services/search";
import useStreams from "@/composables/useStreams";
import {
correlate as correlateStreams,
getSemanticGroups,
getDimensionAnalytics,
type FieldAlias,
type FoundGroup,
} from "@/services/service_streams";
import { ENV_SEGMENTS, groupEnvKey } from "@/utils/serviceStreamEnvs";
import {
escapeSingleQuotes,
deepCopy,
formatTimeWithSuffix,
b64EncodeUnicode,
} from "@/utils/zincutils";
import { convertDashboardSchemaVersion } from "@/utils/dashboard/convertDashboardSchemaVersion";
import metrics from "./metrics/metrics.json";
import {
type MetricGroupDefinition,
K8S_METRIC_GROUP_DEFINITIONS,
} from "@/utils/metrics/metricGrouping";
import { buildWorkloadChipDimensions } from "@/composables/useMetricSubjectButtons";
import { normalizeSeverity } from "@/utils/sourceEventSeverity";
import DeployedCode from "@/components/icons/DeployedCode.vue";
import { useI18n } from "vue-i18n";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import { toast } from "@/lib/feedback/Toast/useToast";
const TelemetryCorrelationDashboard = defineAsyncComponent(
() => import("@/plugins/correlation/TelemetryCorrelationDashboard.vue"),
);
const RenderDashboardCharts = defineAsyncComponent(
() => import("@/views/Dashboards/RenderDashboardCharts.vue"),
);
const TenstackTable = defineAsyncComponent(
() => import("@/components/TenstackTable.vue"),
);
// ---------------------------------------------------------------------------
// OTEL Resource Tab Configuration
// ---------------------------------------------------------------------------
// Resource tabs are derived dynamically from getDimensionAnalytics (org-wide).
//
// Priority logic:
// - If any ENV_SEGMENTS groups (k8s / aws / azure / gcp) are present in the
// current stream schema → show ONLY those platform-specific tabs.
// - Otherwise → show generic fallbacks (host, container, faas, process, cloud).
//
// Within the shown set, DEFAULT_GROUP_IDS determines which tabs are pre-selected.
// ---------------------------------------------------------------------------
export interface ResourceTabConfig {
id: string; // unique tab name (= FoundGroup.group_id)
label: string; // display label
groupField: string; // SQL GROUP BY field (= FoundGroup.aliases["traces"])
colLabel: string; // header of the first column (entity name)
colId: string; // row property key for the entity name column
environment: string; // env key derived from group_id first segment
isDefault: boolean; // pre-selected when the environment is first detected
/** Fallback field chain for inferred service tabs. When set, the GROUP BY
* expression is COALESCE(NULLIF(fields[0], ''), NULLIF(fields[1], ''), …).
* When absent, groupField is used directly (existing OTEL resource tabs). */
fields?: string[];
}
// ---------------------------------------------------------------------------
// Inferred Service Tabs — one entry per dependency type, with field fallback
// chains that generate COALESCE expressions for both SELECT/GROUP BY and
// the downstream filter clause.
// ---------------------------------------------------------------------------
/**
* Describes a resource tab rendered for an inferred service in the node detail panel.
*
* Each tab exposes a group of related span attributes (e.g. host info, database names)
* organised as a table. Because OTel semantic conventions have evolved across
* generations, a single conceptual attribute may map to several field names
* (e.g. host → `server_address`, `net_peer_name`, `net_peer_ip`, `network_peer_address`).
*
* The `fields` array defines a **fallback chain** — the first field found in the
* stream schema is used as both the GROUP BY column and the link target for View
* Traces drill-down. If multiple fields exist, they are assembled into a
* `COALESCE(NULLIF(f1, ''), NULLIF(f2, ''), ...)` expression so that rows with
* different generations of the same attribute still aggregate together.
*/
export interface InferredServiceTab {
/** Unique tab identifier, e.g. "hosts", "databases", "queries" */
id: string;
/** User-facing tab label, e.g. "Hosts", "Databases", "Queries" */
label: string;
/** Column header shown in the resource table, e.g. "Host", "Database", "DB Operation" */
colLabel: string;
/**
* Fallback-ordered field names for the attribute column, evaluated against the
* stream schema at runtime. The first field present in the schema wins as the
* primary GROUP BY column; additional matching fields contribute to a COALESCE
* expression for unified aggregation.
*/
fields: string[];
}
/**
* Registry of inferred-service resource tabs, keyed by `service_type`.
*
* Each entry defines the resource breakdowns available for that service type.
* At runtime, tabs are filtered against the target stream's schema so that
* only tabs whose `fields` intersect the schema are shown to the user.
*
* OTel semantic convention coverage:
* | Service Type | Tabs | Attribute Generations |
* |-------------|---------------|-----------------------------------------------------------|
* | database | Hosts | server_address, net_peer_*, network_peer_address |
* | | Databases | db_namespace, db_name (stabilised OTel DB) |
* | | Queries | db_operations (OTel DB operations) |
* | queue | Hosts | server_address, net_peer_*, network_peer_address |
* | | Destinations | messaging_destination_name, messaging_destination |
* | rpc | Hosts | server_address, net_peer_*, network_peer_address |
* | | RPC Services | rpc_service, rpc_method |
* | http | Hosts | server_address, net_peer_*, network_peer_address |
*
* When adding a new dependency type, add its entry here — the panel will
* automatically render the appropriate resource tabs.
*/
const INFERRED_SERVICE_TABS: Record<string, InferredServiceTab[]> = {
database: [
{
id: "hosts",
label: "Hosts",
colLabel: "Host",
fields: ["server_address", "net_peer_name", "net_peer_ip", "network_peer_address"],
},
{
id: "databases",
label: "Databases",
colLabel: "Database",
fields: ["db_namespace", "db_name"],
},
{
id: "queries",
label: "Queries",
colLabel: "DB Operation",
fields: ["db_operations"],
},
],
queue: [
{
id: "hosts",
label: "Hosts",
colLabel: "Host",
fields: ["server_address", "net_peer_name", "net_peer_ip", "network_peer_address"],
},
{
id: "destinations",
label: "Destinations",
colLabel: "Destination",
fields: ["messaging_destination_name", "messaging_destination"],
},
],
rpc: [
{
id: "hosts",
label: "Hosts",
colLabel: "Host",
fields: ["server_address", "net_peer_name", "net_peer_ip", "network_peer_address"],
},
{
id: "rpc_services",
label: "RPC Services",
colLabel: "RPC Service",
fields: ["rpc_service"],
},
],
external: [
{
id: "hosts",
label: "Hosts",
colLabel: "Host",
fields: ["server_address", "net_peer_name", "net_peer_ip", "network_peer_address"],
},
{
id: "urls",
label: "URLs",
colLabel: "URL",
fields: ["http_url", "url_full"],
},
],
};
/** Build a COALESCE expression from a fallback field chain.
* e.g. ["server_address", "net_peer_name"]
* → "COALESCE(NULLIF(server_address, ''), NULLIF(net_peer_name, ''))" */
const buildCoalesceExpr = (fields: string[]): string =>
fields.map((f) => `NULLIF(${f}, '')`).join(", ");
/** Build a WHERE clause fragment to exclude rows where all fallback fields are null.
* e.g. ["server_address", "net_peer_name"]
* → "(server_address IS NOT NULL OR net_peer_name IS NOT NULL)" */
const buildNullCheck = (fields: string[]): string =>
fields.map((f) => `${f} IS NOT NULL`).join(" OR ");
export interface ResourceRow {
name: string;
requestCount: number;
errorCount: number;
p50Latency: number;
p75Latency: number;
p95Latency: number;
p99Latency: number;
}
// OTEL semantic field names (OpenObserve format: service_ prefix, dots → underscores)
// that are pre-selected by default when their environment is detected.
// Keyed on the actual stream field, not on the internal group_id, so this stays
// in sync with OTEL semantic conventions regardless of backend group_id naming.
const DEFAULT_GROUP_FIELDS = new Set([
// Kubernetes
"service_k8s_pod_name",
"service_k8s_node_name",
// Host
"service_host_name",
// Container
"service_container_name",
// Serverless (FaaS)
"service_faas_name",
// AWS ECS
"service_aws_ecs_task_arn",
"service_aws_ecs_container_name",
// Cloud
"service_cloud_region",
// Process / runtime
"service_process_runtime_name",
"service_process_runtime_version",
// Service identity
"service_namespace",
"service_version",
]);
// Generic environment display labels for non-primary-platform segments.
// Primary platform labels (k8s/aws/azure/gcp) come from the shared ENV_SEGMENTS.
const GENERIC_ENV_LABELS: Record<string, string> = {
host: "Host",
container: "Container",
faas: "Serverless",
process: "Runtime",
cloud: "Cloud",
};
/** Gets a display label for any environment key, platform or generic. */
const envLabel = (envKey: string): string =>
Object.values(ENV_SEGMENTS).find((e) => e.key === envKey)?.label ??
GENERIC_ENV_LABELS[envKey] ??
envKey;
export default defineComponent({
name: "ServiceGraphNodeSidePanel",
components: {
OSeparator,
OTabs,
OTab,
OTabPanels,
OTabPanel,
OButton,
ODropdown,
ODropdownItem,
ODrawer,
TelemetryCorrelationDashboard,
TenstackTable,
RenderDashboardCharts,
OSpinner,
OTooltip,
OCheckbox,
OIcon,
},
props: {
selectedNode: {
type: Object as PropType<any>,
required: true,
},
graphData: {
type: Object as PropType<{ nodes: any[]; edges: any[] }>,
required: true,
},
timeRange: {
type: Object as PropType<{ startTime: number; endTime: number }>,
required: true,
},
visible: {
type: Boolean,
required: true,
},
streamFilter: {
type: String,
required: true,
},
containerEl: {
type: Object as PropType<HTMLElement | null>,
default: null,
},
},
emits: ["close", "view-traces"],
setup(props, { emit }) {
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const { getStream } = useStreams();
// RED Charts State
const dashboardData = ref<any>({});
// Local datazoom filter map (keyed by panelId)
const localRangeFilters = ref<
Map<
string,
{
panelTitle: string;
start: number | null;
end: number | null;
timeStart: number | null;
timeEnd: number | null;
}
>
>(new Map());
// Reactivity trigger for Map changes (Vue 3 doesn't track Map.set() automatically)
const rangeFiltersVersion = ref(0);
const selectedTimeObj = ref({
start_time: new Date(props.timeRange.startTime),
end_time: new Date(props.timeRange.endTime),
});
const currentTimeObj = ref({
__global: {
start_time: new Date(props.timeRange.startTime),
end_time: new Date(props.timeRange.endTime),
},
});
// Returns the escaped service name value for use in SQL WHERE clauses
const buildServiceName = (): string => {
if (!props.selectedNode) return "";
const name =
props.selectedNode.name ||
props.selectedNode.label ||
props.selectedNode.id;
return escapeSingleQuotes(name);
};
/**
* Returns the correct SQL field name for the service name column in WHERE clauses.
*
* Inferred services (those discovered from span attributes rather than instrumented
* SDKs) use `infer_service_name`, while regular instrumented services use `service_name`.
* The distinction is driven by `props.selectedNode.service_type`:
* - When `service_type` is set (e.g. "database", "queue", "rpc", "http"), the node
* represents an inferred service → `infer_service_name`.
* - When `service_type` is absent, the node represents an instrumented service → `service_name`.
*/
const serviceNameField = computed(() =>
props.selectedNode?.service_type ? "infer_service_name" : "service_name",
);
const loadDashboard = () => {
if (!props.selectedNode || props.streamFilter === "all") {
dashboardData.value = {};
return;
}
const serviceName = buildServiceName();
const streamName = props.streamFilter;
selectedTimeObj.value = {
start_time: new Date(props.timeRange.startTime),
end_time: new Date(props.timeRange.endTime),
};
currentTimeObj.value = {
__global: {
start_time: new Date(props.timeRange.startTime),
end_time: new Date(props.timeRange.endTime),
},
};
const convertedDashboard = convertDashboardSchemaVersion(
deepCopy(metrics),
);
const serviceFilter = `${serviceNameField.value} = '${serviceName}'`;
convertedDashboard.tabs[0].panels.forEach((panel: any, index) => {
let whereClause: string;
if (panel.title === "Errors") {
whereClause = `WHERE span_status = 'ERROR' AND ${serviceFilter}`;
} else {
whereClause = `WHERE ${serviceFilter}`;
}
panel.layout.h = 10;
let query = panel.queries[0].query
.replace("[STREAM_NAME]", () => `"${streamName}"`)
.replace("[WHERE_CLAUSE]", () => whereClause);
// Use count(*) instead of approx_distinct(trace_id)
query = query
.replace(
/approx_distinct\(trace_id\)\s+filter\s*\(where\s+span_status\s*=\s*'ERROR'\)/gi,
"count(*) FILTER (WHERE span_status = 'ERROR')",
)
.replace(/approx_distinct\(trace_id\)/gi, "count(*)");
convertedDashboard.tabs[0].panels[index]["queries"][0].query = query;
});
dashboardData.value = convertedDashboard;
};
const createRangeFilter = (
data: any,
start: number | null = null,
end: number | null = null,
timeStart: number | null = null,
timeEnd: number | null = null,
) => {
const panelId = data?.id;
const panelTitle = data?.title || "Chart";
if (
panelId &&
(panelTitle === "Duration" ||
panelTitle === "Rate" ||
panelTitle === "Errors")
) {
localRangeFilters.value.set(panelId, {
panelTitle,
start: start != null ? Math.floor(start) : null,
end: end != null ? Math.floor(end) : null,
timeStart: timeStart != null ? Math.floor(timeStart) : null,
timeEnd: timeEnd != null ? Math.floor(timeEnd) : null,
});
rangeFiltersVersion.value++;
}
};
const onDataZoom = ({
start,
end,
start1,
end1,
data,
}: {
start: number;
end: number;
start1: number;
end1: number;
data: any;
}) => {
if (start && end) {
const panelTitle = data?.title;
if (panelTitle === "Rate" || panelTitle === "Errors") {
createRangeFilter(data, -1, -1, start * 1000, end * 1000);
} else {
createRangeFilter(data, start1, end1, start * 1000, end * 1000);
}
rangeFiltersVersion.value++;
}
};
const removeLocalRangeFilter = (panelId: string) => {
localRangeFilters.value.delete(panelId);
rangeFiltersVersion.value++;
};
const filterChips = computed(() => {
// Access rangeFiltersVersion to force reactivity when Map changes
rangeFiltersVersion.value;
const chips: {
key: string;
label: string;
type: "duration" | "error";
}[] = [];
localRangeFilters.value.forEach((f, key) => {
if (
f.panelTitle === "Duration" &&
f.start !== null &&
f.end !== null &&
f.start > 0 &&
f.end > 0
) {
chips.push({
key,
type: "duration",
label: `Duration: ${formatTimeWithSuffix(f.start)} – ${formatTimeWithSuffix(f.end)}`,
});
} else if (f.panelTitle === "Errors") {
chips.push({ key, type: "error", label: "Status: Error" });
}
});
return chips;
});
const viewInTraces = () => {
let errorsOnly = false;
let minDurationMicros: number | undefined;
let maxDurationMicros: number | undefined;
localRangeFilters.value.forEach((f) => {
if (f.panelTitle === "Errors") errorsOnly = true;
if (f.panelTitle === "Duration" && f.start !== null && f.start > 0) {
minDurationMicros = f.start;
maxDurationMicros = f.end;
}
});
navigateToTraces({ errorsOnly, minDurationMicros, maxDurationMicros });
};
// Metric group definitions — controls which category tabs and default selections
// appear in the metrics dashboard. Uses K8S_METRIC_GROUP_DEFINITIONS for OTel
// semantic defaults; overrides the pods icon with the project-specific component.
const metricGroupResources = ref<MetricGroupDefinition[]>(
K8S_METRIC_GROUP_DEFINITIONS.map((g) =>
g.id === "pods" ? { ...g, icon: DeployedCode } : g,
),
);
// Metrics Correlation State
const showTelemetryDialog = ref(false);
const correlationLoading = ref(false);
const correlationError = ref<string | null>(null);
const correlationData = ref<{
serviceName: string;
matchedDimensions: Record<string, string>;
additionalDimensions: Record<string, string>;
matchedSetId?: string;
chipDimensions?: Record<string, string>;
logStreams: any[];
metricStreams: any[];
traceStreams: any[];
} | null>(null);
const telemetryTimeRange = computed(() => ({
startTime: props.timeRange.startTime,
endTime: props.timeRange.endTime,
}));
// Metrics Tab State (independent from "Show Telemetry" dialog)
const metricsCorrelationLoading = ref(false);
const metricsCorrelationError = ref<string | null>(null);
const metricsCorrelationData = ref<{
serviceName: string;
matchedDimensions: Record<string, string>;
additionalDimensions: Record<string, string>;
matchedSetId?: string;
chipDimensions?: Record<string, string>;
sourceEvent?: {
timestamp?: number | string;
severity?: string;
message?: string;
};
logStreams: any[];
metricStreams: any[];
traceStreams: any[];
} | null>(null);
const metricsCorrelationLoaded = ref(false);
const fetchCorrelatedStreams = async (force = false) => {
if (!props.selectedNode) return;
if (!force && correlationData.value) return;
correlationLoading.value = true;
correlationError.value = null;
try {
const org = store.state.selectedOrganization.identifier;
const serviceName =
props.selectedNode.name ||
props.selectedNode.label ||
props.selectedNode.id;
// Send service name directly to _correlate
const correlateResponse = await correlateStreams(org, {
source_stream: props.streamFilter || "default",
source_type: "traces",
available_dimensions: { service: serviceName },
});
const data = correlateResponse.data;
if (!data) {
correlationError.value = "No correlated streams found.";
correlationData.value = null;
return;
}
correlationData.value = {
serviceName: data.service_name,
matchedDimensions: data.matched_dimensions || {},
additionalDimensions: data.additional_dimensions || {},
matchedSetId: data.matched_set_id,
chipDimensions: {
...(data.matched_dimensions || {}),
...(data.additional_dimensions || {}),
},
logStreams: data.related_streams?.logs || [],
metricStreams: data.related_streams?.metrics || [],
traceStreams: data.related_streams?.traces || [],
};
} catch (err: any) {
if (err.response?.status === 403) {
correlationError.value =
"Service Discovery is an enterprise feature.";
} else {
correlationError.value =
err.message || "Failed to load service streams.";
}
correlationData.value = null;
} finally {
correlationLoading.value = false;
}
};
// Reset correlation data when node changes
watch(
() => props.selectedNode?.id,
() => {
correlationData.value = null;
correlationError.value = null;
},
);
// Extract semantic dimensions from a span for richer metric correlation
const extractSpanDimensions = (
span: Record<string, any>,
): Record<string, string> => {
const dimensions: Record<string, string> = {};
if (span.service_name) dimensions["service"] = span.service_name;
const attributeMappings: Record<string, string> = {
k8s_namespace_name: "k8s-namespace",
"k8s.namespace.name": "k8s-namespace",
k8s_deployment_name: "k8s-deployment",
"k8s.deployment.name": "k8s-deployment",
k8s_pod_name: "k8s-pod",
"k8s.pod.name": "k8s-pod",
k8s_container_name: "k8s-container",
"k8s.container.name": "k8s-container",
k8s_node_name: "k8s-node",
"k8s.node.name": "k8s-node",
k8s_cluster_name: "k8s-cluster",
"k8s.cluster.name": "k8s-cluster",
host_name: "host-name",
"host.name": "host-name",
cloud_region: "cloud-region",
"cloud.region": "cloud-region",
cloud_availability_zone: "cloud-availability-zone",
"cloud.availability_zone": "cloud-availability-zone",
container_name: "container-name",
"container.name": "container-name",
};
for (const [attr, dim] of Object.entries(attributeMappings)) {
let service_attr = "service_" + attr;
if (span[service_attr] && !dimensions[dim]) {
dimensions[dim] = String(span[service_attr]);
}
}
return dimensions;
};
// Fetch the most recent span for this service to extract rich semantic dimensions
const fetchLatestSpan = async (): Promise<Record<string, any> | null> => {
const serviceName = buildServiceName();
const streamName = props.streamFilter || "default";
const org = store.state.selectedOrganization.identifier;
try {
const response = await searchService.search({
org_identifier: org,
query: {
query: {
sql: `SELECT * FROM "${streamName}" WHERE ${serviceNameField.value} = '${serviceName}' ORDER BY _timestamp DESC`,
start_time: props.timeRange.startTime,
end_time: props.timeRange.endTime,
from: 0,
size: 1,
},
},
page_type: "traces",
});
return response.data?.hits?.[0] ?? null;
} catch {
return null;
}
};
// Fetch correlated metric streams for the Metrics tab
const fetchMetricsCorrelation = async (force = false) => {
if (!props.selectedNode) return;
if (!force && metricsCorrelationLoaded.value) return;
metricsCorrelationLoading.value = true;
metricsCorrelationError.value = null;
metricsCorrelationData.value = null;
try {
const org = store.state.selectedOrganization.identifier;
const serviceName = buildServiceName();
// Fetch latest span to extract richer semantic dimensions
const latestSpan = await fetchLatestSpan();
const spanDimensions = {
service: serviceName,
};
// Resolve org semantic groups so we can layer Pod/Node/… subject chips
// into chipDimensions consistently with the logs/trace-detail sidebars.
let orgSemanticGroups: FieldAlias[] = [];
try {
const sgResp = await getSemanticGroups(org);
orgSemanticGroups = sgResp.data || [];
} catch {
orgSemanticGroups = [];
}
const correlateResponse = await correlateStreams(org, {
source_stream: props.streamFilter || "default",
source_type: "traces",
available_dimensions: spanDimensions,
});
const data = correlateResponse.data;
if (!data) {
metricsCorrelationData.value = null;
return;
}
const spanSeverity = (() => {
if (!latestSpan) return undefined;
const ss = typeof latestSpan.span_status === "string"
? latestSpan.span_status.toUpperCase()
: null;
if (ss === "ERROR") return "ERROR";
return normalizeSeverity(
latestSpan.severity_text ?? latestSpan.severity,
) ?? undefined;
})();
metricsCorrelationData.value = {
serviceName: data.service_name,
matchedDimensions: data.matched_dimensions || {},
additionalDimensions: data.additional_dimensions || {},
matchedSetId: data.matched_set_id,
chipDimensions: {
...(data.matched_dimensions || {}),
...(data.additional_dimensions || {}),
...buildWorkloadChipDimensions(
data.matched_set_id,
orgSemanticGroups,
latestSpan ?? undefined,
),
},
sourceEvent: latestSpan
? {
timestamp: latestSpan.start_time ?? latestSpan._timestamp,
severity: spanSeverity,
message: latestSpan.operation_name,
}
: undefined,
logStreams: data.related_streams?.logs || [],
metricStreams: data.related_streams?.metrics || [],
traceStreams: data.related_streams?.traces || [],
};
} catch (err: any) {
if (err.response?.status === 403) {
metricsCorrelationError.value =
"Service Discovery is an enterprise feature.";
} else {
metricsCorrelationError.value =
err.message || "Failed to load metrics.";
}
metricsCorrelationData.value = null;
} finally {
metricsCorrelationLoading.value = false;
metricsCorrelationLoaded.value = true;
}
};
// Reload RED charts when node, time range, stream, or visibility changes
watch(
() =>
[
props.selectedNode?.id,
props.timeRange,
props.streamFilter,
props.visible,
] as const,
([, , , visible]) => {
if (visible && props.selectedNode) {
loadDashboard();
} else if (!visible) {
dashboardData.value = {};
}
},
{ immediate: true, deep: true },
);
// Active tab state
const activeTab = ref<string>("operations");
// Recent Operations State
const operationsViewMode = ref<"aggregated" | "spans">("aggregated");
const recentOperations = ref<any[]>([]);
const recentSpanData = ref<{ errorSpans: any[]; slowSpans: any[] }>({
errorSpans: [],
slowSpans: [],
});
const loadingOperations = ref(false);
// Dynamic resource tabs state (replaces per-resource recentNodes/recentPods/loadingNodes/loadingPods)
const resourceTabData = ref<Record<string, ResourceRow[]>>({});
const resourceTabLoading = ref<Record<string, boolean>>({});
// Resource groups from getDimensionAnalytics that match the current stream schema
// (after platform-priority filtering — see resolveWorkloadFields)
const availableResourceTabConfigs = ref<ResourceTabConfig[]>([]);
// IDs of alias entries the user has checked in the dropdown
const selectedWorkloadFields = ref<string[]>([]);
// Shared cache of stream schema field names — populated by resolveStreamSchema,
// consumed by both resolveWorkloadFields and inferredTabConfigs.
const streamFieldSet = ref<Set<string>>(new Set());
const schemaResolved = ref(false);
/** Fetch the trace stream schema and populate streamFieldSet.
* Idempotent — skips if already resolved for the current stream.
* Uses useStreams().getStream() which caches the schema in the Vuex store
* so other components benefit from the cached data. */
const resolveStreamSchema = async () => {
if (
!props.visible ||
props.streamFilter === "all" ||
!props.streamFilter ||
schemaResolved.value
)
return;
try {
const stream = await getStream(props.streamFilter, "traces", true);
const schemaFields: { name: string; type: string }[] =
stream?.schema || [];
streamFieldSet.value = new Set(schemaFields.map((f) => f.name));
schemaResolved.value = true;
} catch (err) {
console.error(
"[ServiceGraphNodeSidePanel] Failed to resolve stream schema:",
err,
);
streamFieldSet.value = new Set();
}
};
// Inferred service tabs — built from INFERRED_SERVICE_TABS registry when
// the selected node has a service_type (database/queue/rpc/external).
// Each tab's fallback field chain is filtered to only include fields that
// actually exist in the stream schema. Tabs with zero surviving fields are
// dropped entirely.
const inferredTabConfigs = computed<ResourceTabConfig[]>(() => {
const st = props.selectedNode?.service_type as string | undefined;
if (!st) return [];
const tabs = INFERRED_SERVICE_TABS[st];
if (!tabs?.length) return [];
const fieldSet = streamFieldSet.value;
// If schema hasn't resolved yet, return empty — tabs appear once the
// schema fetch completes (reactive via streamFieldSet).
if (!schemaResolved.value) return [];
const resolved: ResourceTabConfig[] = [];
for (const t of tabs) {
const present = t.fields.filter((f) => fieldSet.has(f));
if (present.length === 0) continue;
resolved.push({
id: t.id,
label: t.label,
groupField: `COALESCE(${buildCoalesceExpr(present)})`,
colLabel: t.colLabel,
colId: t.id,
environment: "",
isDefault: true,
fields: present,
});
}
return resolved;
});
/** Whether the selected node is an inferred service (uninstrumented dependency). */
const isInferred = computed(() => !!props.selectedNode?.service_type);
// Tabs actually shown. For inferred services use the registry tabs;
// for instrumented services use the user-selected OTEL resource tabs.
const activeResourceTabConfigs = computed(() =>
isInferred.value
? inferredTabConfigs.value
: availableResourceTabConfigs.value.filter((c) =>
selectedWorkloadFields.value.includes(c.id),
),
);
// Deduped list of environments detected from visible resource groups
const detectedEnvironments = computed<{ key: string; label: string }[]>(
() => {
const seen = new Set<string>();
const envs: { key: string; label: string }[] = [];
for (const cfg of availableResourceTabConfigs.value) {
if (!seen.has(cfg.environment)) {
seen.add(cfg.environment);
envs.push({
key: cfg.environment,
label: envLabel(cfg.environment),
});
}
}
return envs;
},
);
// Workload Field Discovery State
const resolvedWorkloadFields = ref<{ field: string; alias: FieldAlias }[]>(
[],
);
// Toggle a workload field id in the selected set — invoked when the user
// selects the surrounding ODropdownItem (in addition to clicking the
// OCheckbox directly).
const toggleWorkloadField = (id: string) => {
const current = selectedWorkloadFields.value;
selectedWorkloadFields.value = current.includes(id)
? current.filter((entry) => entry !== id)
: [...current, id];
};
// Computed: Service Metrics
const serviceMetrics = computed(() => {
if (!props.selectedNode || !props.graphData) {
return {
requestRate: "N/A",
requestRateValue: "N/A",
totalRequests: 0,
incomingRequests: 0,
outgoingRequests: 0,
errorRate: "N/A",
p50Latency: "N/A",
p95Latency: "N/A",
p99Latency: "N/A",
};
}
// Get total request count - handle both graph view (uses 'value') and tree view (uses 'requests')
const totalRequests =
props.selectedNode.value || props.selectedNode.requests || 0;
// Calculate incoming requests (sum of all edges TO this node)
const incomingEdges = props.graphData.edges.filter(
(edge: any) => edge.to === props.selectedNode.id,
);
const incomingRequests = incomingEdges.reduce(
(sum: number, edge: any) => sum + (edge.total_requests || 0),
0,
);
// Calculate outgoing requests (sum of all edges FROM this node)
const outgoingEdges = props.graphData.edges.filter(
(edge: any) => edge.from === props.selectedNode.id,
);
const outgoingRequests = outgoingEdges.reduce(
(sum: number, edge: any) => sum + (edge.total_requests || 0),
0,
);
// Get errors and calculate error rate
const errors = props.selectedNode.errors || 0;
const errorRate = totalRequests > 0 ? (errors / totalRequests) * 100 : 0;
// Calculate latency percentiles from incoming edges
let p50Latency = 0;
let p95Latency = 0;
let p99Latency = 0;
if (incomingEdges.length > 0) {
p50Latency = Math.max(
...incomingEdges.map((edge: any) => edge.p50_latency_ns || 0),
);
p95Latency = Math.max(
...incomingEdges.map((edge: any) => edge.p95_latency_ns || 0),
);
p99Latency = Math.max(
...incomingEdges.map((edge: any) => edge.p99_latency_ns || 0),
);
}
// Format request rate value without unit
let requestRateValue = "";
if (totalRequests >= 1000000) {
requestRateValue = (totalRequests / 1000000).toFixed(1) + "M";
} else if (totalRequests >= 1000) {
requestRateValue = (totalRequests / 1000).toFixed(1) + "K";
} else {
requestRateValue = totalRequests.toString();
}
return {
requestRate: formatRequestRate(totalRequests),
requestRateValue: requestRateValue,
totalRequests: totalRequests,
incomingRequests: incomingRequests,
outgoingRequests: outgoingRequests,
errorRate: errorRate.toFixed(2) + "%",
p50Latency:
incomingEdges.length > 0 ? formatLatency(p50Latency) : "N/A",
p95Latency:
incomingEdges.length > 0 ? formatLatency(p95Latency) : "N/A",
p99Latency:
incomingEdges.length > 0 ? formatLatency(p99Latency) : "N/A",
};
});
// Computed: Service Health
const serviceHealth = computed(() => {
if (!props.selectedNode) {
return {
status: "unknown",
text: "Unknown",
};
}
// Use node's own error_rate directly
const errorRate = props.selectedNode.error_rate || 0;
if (errorRate > 10) {
return {
status: "critical",
text: "Critical",
};
} else if (errorRate > 5) {
return {
status: "degraded",
text: "Degraded",
};
} else {
return {
status: "healthy",
text: "Healthy",
};
}
});
// Computed: Check if "All Streams" is selected
const isAllStreamsSelected = computed(() => {
return props.streamFilter === "all";
});
// Helper: Format Request Rate (with unit)
const formatRequestRate = (requests: number): string => {
if (requests >= 1000000) {
return (requests / 1000000).toFixed(1) + "M req/min";
}
if (requests >= 1000) {
return (requests / 1000).toFixed(1) + "K req/min";
}
return requests + " req/min";
};
// Helper: Format Number (without unit)
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + "K";
}
return num.toString();
};
// Helper: Format Latency
const formatLatency = (nanoseconds: number): string => {
if (!nanoseconds || nanoseconds === 0) return "N/A";
const milliseconds = nanoseconds / 1000000;
if (milliseconds >= 1000) {
return (milliseconds / 1000).toFixed(2) + "s";
}
return milliseconds.toFixed(0) + "ms";
};
// Helper: Format Operation Latency (from microseconds)
const formatOperationLatency = (microseconds: number): string => {
if (!microseconds || microseconds === 0) return "N/A";
if (microseconds < 1000) return `${microseconds.toFixed(0)}μs`;
const milliseconds = microseconds / 1000;
if (milliseconds < 1000) return `${milliseconds.toFixed(1)}ms`;
return `${(milliseconds / 1000).toFixed(2)}s`;
};
// Helper: Format Span Timestamp (from nanoseconds)
const formatSpanTimestamp = (nanoseconds: number): string => {
const date = new Date(nanoseconds / 1000000);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
// ── Sorting state ──────────────────────────────────────────────────────
const sortBy = ref<string>("");
const sortOrder = ref<"asc" | "desc">("");
function handleSortChange(field: string, order: "asc" | "desc") {
sortBy.value = field;
sortOrder.value = order;
}
const compareRows = (a: any, b: any, field: string): number => {
if (field === "p99" || field === "p95" || field === "p75") {
return (a[field] ?? 0) - (b[field] ?? 0);
}
// For requests, sort by raw _requests value
if (field === "requests") {
return (a.requests ?? 0) - (b.requests ?? 0);
}
const va = a[field];
const vb = b[field];
if (typeof va === "number" && typeof vb === "number") {
return va - vb;
}
return String(va ?? "").localeCompare(String(vb ?? ""));
};
const sortedOperationsTableRows = computed(() => {
const rows = operationsTableRows.value;
if (!sortBy.value) return rows;
const order = sortOrder.value;
return [...rows].sort((a, b) => {
const result = compareRows(a, b, sortBy.value);
return order === "desc" ? -result : result;
});
});
const sortResourceRows = (rows: any[]) => {
if (!sortBy.value) return rows;
const order = sortOrder.value;
return [...rows].sort((a, b) => {
const result = compareRows(a, b, sortBy.value);
return order === "desc" ? -result : result;
});
};
// Generic helper: builds table columns with a dynamic first (entity) column
const buildEntityTableColumns = (
entityId: string,
entityHeader: string,
) => [
{
id: entityId,
accessorKey: entityId,
header: entityHeader,
size: 220,
enableSorting: true,
meta: { slot: false, sortable: true },
},
{
id: "requests",
accessorFn: (row: any) => formatNumber(row.requests),
header: "Requests",
size: 100,
enableSorting: true,
meta: { sortable: true },
},
{
id: "errors",
accessorKey: "errors",
header: "Errors",
size: 80,
enableSorting: true,
meta: { slot: true, sortable: true },
},
{
id: "p99",
accessorKey: "p99",
header: "P99",
size: 80,
enableSorting: true,
meta: { slot: true, sortable: true },
},
{
id: "p95",
accessorKey: "p95",
header: "P95",
size: 80,
enableSorting: true,
meta: { slot: true, sortable: true },
},
{
id: "p75",
accessorKey: "p75",
header: "P75",
size: 80,
enableSorting: true,
meta: { slot: true, sortable: true },
},
];
// Computed: Operations table columns
const operationsTableColumns = computed(() => {
const cols = buildEntityTableColumns("operation", "Operation");
if (isInferred.value) {
cols.unshift({
id: "caller",
accessorKey: "caller",
header: "Caller",
size: 180,
enableSorting: true,
meta: { slot: false, sortable: true },
});
}
return cols;
});
// Computed: Operations table rows
const operationsTableRows = computed(() =>
recentOperations.value.map((op) => ({
operation: op.name,
caller: op.callerService || "",
requests: op.requestCount,
errors: op.errorCount,
p99: op.p99Latency,
p95: op.p95Latency,
p75: op.p75Latency,
p50: op.p50Latency,
})),
);
// Fetch aggregated operations (grouped by operation_name with percentiles).
// For inferred services we also GROUP BY service_name so the caller is visible.
const fetchAggregatedOperations = async () => {
if (!props.selectedNode || !props.visible || props.streamFilter === "all")
return;
loadingOperations.value = true;
recentOperations.value = [];
try {
const serviceName = buildServiceName();
const streamName = props.streamFilter || "default";
const isInf = isInferred.value;
const selectCols = isInf
? "service_name as caller_service, operation_name"
: "operation_name";
const groupCols = isInf
? "service_name, operation_name"
: "operation_name";
const sql = `SELECT ${selectCols}, count(*) as request_count, count(*) FILTER (WHERE span_status = 'ERROR') as error_count, approx_percentile_cont(duration, 0.50) as p50_latency, approx_percentile_cont(duration, 0.75) as p75_latency, approx_percentile_cont(duration, 0.95) as p95_latency, approx_percentile_cont(duration, 0.99) as p99_latency FROM "${streamName}" WHERE ${serviceNameField.value} = '${serviceName}' GROUP BY ${groupCols}`;
const response = await searchService.search({
org_identifier: store.state.selectedOrganization.identifier,
query: {
query: {
sql,
start_time: props.timeRange.startTime,
end_time: props.timeRange.endTime,
from: 0,
size: 100,
},
},
page_type: "traces",
});
if (response.data && response.data.hits) {
recentOperations.value = response.data.hits.map((op: any) => ({
name: op.operation_name || "unknown",
callerService: op.caller_service || undefined,
requestCount: op.request_count || 0,
errorCount: op.error_count || 0,
p50Latency: op.p50_latency || 0,
p75Latency: op.p75_latency || 0,
p95Latency: op.p95_latency || 0,
p99Latency: op.p99_latency || 0,
}));
}
} catch (error) {
console.error("Failed to fetch aggregated operations:", error);
recentOperations.value = [];
} finally {
loadingOperations.value = false;
}
};
// Fetch recent spans (error spans + slowest spans)
const fetchRecentSpans = async () => {
if (!props.selectedNode || !props.visible || props.streamFilter === "all")
return;
loadingOperations.value = true;
recentSpanData.value = { errorSpans: [], slowSpans: [] };
try {
const serviceName = buildServiceName();
const streamName = props.streamFilter || "default";
const org = store.state.selectedOrganization.identifier;
const timeParams = {
start_time: props.timeRange.startTime,
end_time: props.timeRange.endTime,
from: 0,
};
const [errorRes, slowRes] = await Promise.all([
searchService.search({
org_identifier: org,
query: {
query: {
sql: `SELECT operation_name, duration, start_time FROM "${streamName}" WHERE ${serviceNameField.value} = '${serviceName}' AND span_status = 'ERROR' ORDER BY start_time DESC`,
...timeParams,
size: 5,
},
},
page_type: "traces",
}),
searchService.search({
org_identifier: org,
query: {
query: {
sql: `SELECT operation_name, duration FROM "${streamName}" WHERE ${serviceNameField.value} = '${serviceName}' ORDER BY duration DESC`,
...timeParams,
size: 20,
},
},
page_type: "traces",
}),
]);
recentSpanData.value = {
errorSpans: (errorRes.data?.hits || []).map((s: any) => ({
name: s.operation_name || "unknown",
duration: s.duration || 0,
timestampDisplay: s.start_time
? formatSpanTimestamp(s.start_time)
: "",
})),
slowSpans: (slowRes.data?.hits || []).map((s: any) => ({
name: s.operation_name || "unknown",
duration: s.duration || 0,
})),
};
} catch (error) {
console.error("Failed to fetch recent spans:", error);
recentSpanData.value = { errorSpans: [], slowSpans: [] };
} finally {
loadingOperations.value = false;
}
};
// Dispatch fetch based on current view mode
const fetchOperations = () => {
if (operationsViewMode.value === "aggregated") {
fetchAggregatedOperations();
} else {
fetchRecentSpans();
}
};
// Watch for panel visibility, node, stream filter, and view mode changes
watch(
() => [
props.visible,
props.selectedNode?.id,
props.streamFilter,
operationsViewMode.value,
],
() => {
if (
props.visible &&
props.selectedNode &&
props.streamFilter !== "all"
) {
fetchOperations();
}
},
{ immediate: true },
);
// Generic fetch for any OTEL resource tab config (supports both instrumented
// and inferred service tabs; inferred tabs use a COALESCE field chain).
const fetchResourceData = async (config: ResourceTabConfig) => {
if (!props.selectedNode || !props.visible || props.streamFilter === "all")
return;
resourceTabLoading.value = {
...resourceTabLoading.value,
[config.id]: true,
};
resourceTabData.value = { ...resourceTabData.value, [config.id]: [] };
try {
const serviceName = buildServiceName();
const streamName = props.streamFilter || "default";
const groupField = config.groupField;
const alias = config.colId + "_name";
// For inferred tabs with fallback fields, the null-check must span all
// fields in the chain; for instrumented tabs a simple IS NOT NULL works.
const nullClause = config.fields
? `(${buildNullCheck(config.fields)})`
: `${groupField} IS NOT NULL`;
const sql = `SELECT ${groupField} as ${alias}, count(*) as request_count, count(*) FILTER (WHERE span_status = 'ERROR') as error_count, approx_percentile_cont(duration, 0.50) as p50_latency, approx_percentile_cont(duration, 0.75) as p75_latency, approx_percentile_cont(duration, 0.95) as p95_latency, approx_percentile_cont(duration, 0.99) as p99_latency FROM "${streamName}" WHERE ${serviceNameField.value} = '${serviceName}' AND ${nullClause} GROUP BY ${alias} ORDER BY request_count DESC`;
const response = await searchService.search({
org_identifier: store.state.selectedOrganization.identifier,
query: {
query: {
sql,
start_time: props.timeRange.startTime,
end_time: props.timeRange.endTime,
from: 0,
size: 100,
},
},
page_type: "traces",
});
if (response.data?.hits) {
resourceTabData.value = {
...resourceTabData.value,
[config.id]: response.data.hits.map((row: any) => ({
name: row[alias] || "unknown",
requestCount: row.request_count || 0,
errorCount: row.error_count || 0,
p50Latency: row.p50_latency || 0,
p75Latency: row.p75_latency || 0,
p95Latency: row.p95_latency || 0,
p99Latency: row.p99_latency || 0,
})),
};
}
} catch (error) {
console.error(`Failed to fetch ${config.label} data:`, error);
resourceTabData.value = { ...resourceTabData.value, [config.id]: [] };
} finally {
resourceTabLoading.value = {
...resourceTabLoading.value,
[config.id]: false,
};
}
};
// Discover which workload fields exist in the trace stream and build resource tabs
// via getDimensionAnalytics. Uses ENV_SEGMENTS priority: if any primary-platform
// groups (k8s / aws / azure / gcp) match the schema, show only those. Otherwise
// fall back to generic groups (host, container, faas, process, cloud).
const resolveWorkloadFields = async () => {
if (!props.visible || props.streamFilter === "all" || !props.streamFilter)
return;
// Ensure the stream schema is resolved (shared with inferred tab resolution)
await resolveStreamSchema();
if (streamFieldSet.value.size === 0) return;
try {
const org = store.state.selectedOrganization.identifier;
// Fetch semantic groups and build a reverse lookup map (field → alias)
const semanticGroupsResponse = await getSemanticGroups(org);
const fieldToAliasMap = new Map<string, FieldAlias>();
for (const alias of semanticGroupsResponse.data) {
for (const field of alias.fields) {
fieldToAliasMap.set(field, alias);
}
}
// Filter for service_ fields, intersect with semantic groups, deduplicate by alias.id
const seen = new Set<string>();
const matched: { field: string; alias: FieldAlias }[] = [];
const schemaFields = [...streamFieldSet.value]; // already resolved above
for (const fieldName of schemaFields) {
if (!fieldName.startsWith("service_")) continue;
const alias = fieldToAliasMap.get(fieldName);
if (!alias || seen.has(alias.id)) continue;
seen.add(alias.id);
matched.push({ field: fieldName, alias });
}
resolvedWorkloadFields.value = matched;
// Fetch org-wide dimension analytics to discover all available resource groups
const analyticsResp = await getDimensionAnalytics(org);
const allGroups: FoundGroup[] =
analyticsResp.data?.available_groups ?? [];
// Filter to groups whose traces (or spans) alias exists in this stream's schema
const schemaMatchedGroups = allGroups.filter((g) => {
const field = g.aliases["traces"] ?? g.aliases["spans"];
return field && streamFieldSet.value.has(field);
});
// Apply ENV_SEGMENTS priority
// If any primary-platform groups (k8s / aws / azure / gcp) are present
// in this stream, show ONLY those. Otherwise fall back to generic groups.
const platformGroups = schemaMatchedGroups.filter(
(g) => groupEnvKey(g.group_id) !== null,
);
const visibleGroups =
platformGroups.length > 0 ? platformGroups : schemaMatchedGroups;
// Build ResourceTabConfig from FoundGroup data
availableResourceTabConfigs.value = visibleGroups.map((g) => {
const field = g.aliases["traces"] ?? g.aliases["spans"] ?? "";
const envKey = groupEnvKey(g.group_id) ?? g.group_id.split("-")[0];
return {
id: g.group_id,
label: g.display,
groupField: field,
colLabel: g.display,
colId: g.group_id.replace(/-/g, "_"),
environment: envKey,
isDefault: DEFAULT_GROUP_FIELDS.has(field),
};
});
// Smart defaults — pre-select only the important groups
selectedWorkloadFields.value = availableResourceTabConfigs.value
.filter((c) => c.isDefault)
.map((c) => c.id);
} catch (err) {
console.error(
"[ServiceGraphNodeSidePanel] Failed to resolve workload fields:",
err,
);
resolvedWorkloadFields.value = [];
availableResourceTabConfigs.value = [];
selectedWorkloadFields.value = [];
}
};
// Build table rows for any resource tab config
const buildResourceTableRows = (config: ResourceTabConfig) =>
(resourceTabData.value[config.id] || []).map((row) => ({
[config.colId]: row.name,
requests: row.requestCount,
errors: row.errorCount,
p99: row.p99Latency,
p95: row.p95Latency,
p75: row.p75Latency,
p50: row.p50Latency,
}));
// Lazy-fetch resource tab data / metrics when their tab is activated
watch(
() => [
props.visible,
props.selectedNode?.id,
props.streamFilter,
activeTab.value,
],
() => {
if (
!props.visible ||
!props.selectedNode ||
props.streamFilter === "all"
)
return;
const config = activeResourceTabConfigs.value.find(
(c) => c.id === activeTab.value,
);
if (config && !resourceTabData.value[config.id]?.length) {
fetchResourceData(config);
}
if (activeTab.value === "metrics") fetchMetricsCorrelation();
},
);
// Trigger workload field discovery when the panel becomes visible with a valid stream.
// For inferred services, resolve the stream schema so inferredTabConfigs can filter
// fallback field chains to only those present in the schema.
watch(
() => [props.visible, props.selectedNode?.id, props.streamFilter],
([visible]) => {
if (visible && props.selectedNode && props.streamFilter !== "all") {
if (isInferred.value) {
resolveStreamSchema();
} else {
resolveWorkloadFields();
}
}
},
{ immediate: true },
);
// Reset resource tab data, local filters, and go back to operations tab when node or stream changes
watch(
() => [props.selectedNode?.id, props.streamFilter],
() => {
resourceTabData.value = {};
resourceTabLoading.value = {};
availableResourceTabConfigs.value = [];
resolvedWorkloadFields.value = [];
selectedWorkloadFields.value = [];
schemaResolved.value = false;
streamFieldSet.value = new Set();
metricsCorrelationData.value = null;
metricsCorrelationError.value = null;
metricsCorrelationLoaded.value = false;
activeTab.value = "operations";
localRangeFilters.value.clear();
rangeFiltersVersion.value++;
},
);
// Navigate to traces explore page with contextual filters
const navigateToTraces = (params: {
operationName?: string;
/** When the service is inferred, this is the caller's service_name. */
callerService?: string;
/** @deprecated use resourceFilter instead */
nodeName?: string;
/** @deprecated use resourceFilter instead */
podName?: string;
/** Generic OTEL resource filter. Use `field` for a single field,
* or `fields` for a fallback chain (OR clause in the filter). */
resourceFilter?: { field?: string; fields?: string[]; value: string };
errorsOnly?: boolean;
minDurationMicros?: number;
maxDurationMicros?: number;
mode?: "spans" | "traces";
}) => {
emit("view-traces", {
stream: props.streamFilter,
serviceName:
props.selectedNode?.name ||
props.selectedNode?.label ||
props.selectedNode?.id,
serviceType: props.selectedNode?.service_type,
operationName: params.operationName,
callerService: params.callerService,
nodeName: params.nodeName,
podName: params.podName,
resourceFilter: params.resourceFilter,
errorsOnly: params.errorsOnly,
minDurationMicros: params.minDurationMicros,
maxDurationMicros: params.maxDurationMicros,
timeRange: props.timeRange,
mode: params.mode || "spans",
});
};
// Handlers
const handleClose = () => {
emit("close");
};
const viewRelatedTraces = () => {
navigateToTraces({ mode: "traces" });
};
const viewRelatedLogs = async () => {
const serviceName = buildServiceName();
if (!serviceName) return;
const org = store.state.selectedOrganization.identifier;
let streamName: string | undefined;
let filterQuery = `${serviceNameField.value}='${serviceName}'`;
try {
const correlateResponse = await correlateStreams(org, {
source_stream: props.streamFilter || "default",
source_type: "traces",
available_dimensions: { service: serviceName },
});
const logStreams = correlateResponse.data?.related_streams?.logs || [];
if (logStreams.length > 0) {
const firstLogStream = logStreams[0];
streamName = firstLogStream.stream_name;
// Build filter from the stream's resolved field names
const filters: Record<string, string> = firstLogStream.filters || {};
const filterParts = Object.entries(filters)
.map(([field, value]) => `${field}='${escapeSingleQuotes(value)}'`)
.join(" AND ");
if (filterParts) {
filterQuery = filterParts;
}
} else {
toast({
variant: "warning",
message: t("traces.noLogsAvailableForService"),
});
return;
}
} catch {
toast({
variant: "warning",
message: t("traces.noLogsAvailableForService"),
});
return;
}
const queryParams: any = {
stream_type: "logs",
from: props.timeRange.startTime,
to: props.timeRange.endTime,
sql_mode: "false",
query: b64EncodeUnicode(filterQuery),
org_identifier: org,
};
if (streamName) {
queryParams.stream_value = streamName;
}
router.push({
path: "/logs",
query: queryParams,
});
};
const handleShowTelemetry = async () => {
await fetchCorrelatedStreams();
if (correlationData.value) {
showTelemetryDialog.value = true;
} else if (correlationError.value) {
toast({
variant: "warning",
message: correlationError.value,
});
}
};
// Get color class for error rate card
const getErrorRateClass = (): string => {
const errorRateStr = serviceMetrics.value.errorRate;
if (errorRateStr === "N/A") return "status-unknown";
const errorRate = parseFloat(errorRateStr);
if (errorRate < 1) return "status-healthy";
if (errorRate < 5) return "status-warning";
return "status-critical";
};
// Get color class for latency card
const getLatencyClass = (latencyStr: string): string => {
if (latencyStr === "N/A") return "status-unknown";
// Parse latency value (could be "125ms" or "1.5s")
let latencyMs = 0;
if (latencyStr.endsWith("ms")) {
latencyMs = parseFloat(latencyStr);
} else if (latencyStr.endsWith("s")) {
latencyMs = parseFloat(latencyStr) * 1000;
}
if (latencyMs < 100) return "status-healthy";
if (latencyMs < 500) return "status-warning";
return "status-critical";
};
const isDurationColumn = (column: string) => {
return ['p99','p95','p75'].includes(column);
}
return {
t,
serviceMetrics,
serviceHealth,
isAllStreamsSelected,
isInferred,
formatNumber,
getErrorRateClass,
getLatencyClass,
handleClose,
viewRelatedTraces,
viewRelatedLogs,
handleShowTelemetry,
// RED Charts
dashboardData,
selectedTimeObj,
currentTimeObj,
loadDashboard,
// DataZoom filters
filterChips,
onDataZoom,
removeLocalRangeFilter,
viewInTraces,
// Telemetry Correlation
showTelemetryDialog,
correlationLoading,
correlationData,
telemetryTimeRange,
// Tabs
activeTab,
// Recent Operations
loadingOperations,
recentOperations,
operationsTableColumns,
operationsTableRows,
navigateToTraces,
// Dynamic resource tabs (OTEL platforms)
availableResourceTabConfigs,
activeResourceTabConfigs,
detectedEnvironments,
resourceTabData,
resourceTabLoading,
buildEntityTableColumns,
buildResourceTableRows,
// Workload Field Discovery
resolvedWorkloadFields,
selectedWorkloadFields,
toggleWorkloadField,
// Metrics Tab
metricsCorrelationLoading,
metricsCorrelationError,
metricsCorrelationData,
metricsCorrelationLoaded,
fetchMetricsCorrelation,
metricGroupResources,
sortedOperationsTableRows,
sortBy,
sortOrder,
handleSortChange,
sortResourceRows,
formatOperationLatency,
isDurationColumn
};
},
});
</script>
<style scoped lang="scss">
.health-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
line-height: 1;
&::before {
content: "●";
font-size: 10px;
}
&.healthy {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
&.degraded {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
&.critical {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
&.warning {
background: rgba(249, 115, 22, 0.15);
color: #f97316;
}
}
.body--light .health-badge {
&.healthy {
background: rgba(16, 185, 129, 0.08);
color: #059669;
}
&.degraded {
background: rgba(251, 191, 36, 0.08);
color: #d97706;
}
&.critical {
background: rgba(239, 68, 68, 0.08);
color: #dc2626;
}
&.warning {
background: rgba(249, 115, 22, 0.08);
color: #ea580c;
}
}
// Actions Section (moved to content area)
.actions-section {
display: flex;
gap: 8px;
padding: 0;
margin-bottom: 8px;
flex-wrap: wrap;
.action-btn {
width: 35%;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border: 1px solid #2d3548;
color: #e4e7eb;
background: #242938;
transition: all 0.2s;
&:hover {
background: #1a1f2e;
border-color: #3b82f6;
transform: translateY(-1px);
}
}
}
.view-traces-btn {
font-size: 13px;
font-weight: 500;
line-height: 16px;
border-radius: 4px;
padding: 0px 12px;
min-width: 90px;
transition:
box-shadow 0.3s ease,
opacity 0.2s ease;
background: color-mix(in srgb, var(--o2-primary-btn-bg) 20%, white 10%);
&:hover {
opacity: 0.8;
box-shadow: 0 0 7px
color-mix(in srgb, var(--o2-primary-btn-bg), transparent 10%);
}
}
.body--light .actions-section .action-btn {
border-color: #e0e0e0;
color: #333;
background: #f9f9f9;
&:hover {
background: #ffffff;
border-color: #3b82f6;
}
}
.panel-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: #0f1419;
padding: 0.625rem;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #1a1f2e;
}
&::-webkit-scrollbar-thumb {
background: #242938;
border-radius: 4px;
&:hover {
background: #2d3548;
}
}
}
.body--light .panel-content {
background: #ffffff;
&::-webkit-scrollbar-track {
background: #f8f9fa;
}
&::-webkit-scrollbar-thumb {
background: #e0e0e0;
&:hover {
background: #d0d0d0;
}
}
}
.operation-row {
transition: background 0.15s ease;
&:hover {
background: rgba(99, 102, 241, 0.1);
.operation-link-icon {
opacity: 1 !important;
}
}
.operation-link-icon {
opacity: 0;
color: var(--o2-text-secondary);
transition: opacity 0.15s ease;
}
}
.body--light .operation-row:hover {
background: rgba(99, 102, 241, 0.07);
}
.panel-section {
padding: 0;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.section-header-actions {
display: flex;
gap: 6px;
}
.section-title {
font-size: 14px;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
color: #e4e7eb;
}
}
.body--light .panel-section .section-title {
color: #202124;
}
.metrics-section {
// Full-width card for total requests (single line)
.metric-card-full {
padding: 10px 12px;
border-radius: 8px;
background: linear-gradient(135deg, #242938 0%, #1f2937 100%);
border: 1px solid #374151;
margin-bottom: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
&:hover {
transform: translateY(-2px);
box-shadow:
0 6px 12px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(99, 102, 241, 0.3);
border-color: rgba(99, 102, 241, 0.4);
}
.metric-single-line {
display: flex;
align-items: center;
gap: 12px;
.metric-total {
display: flex;
align-items: baseline;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.08),
rgba(99, 102, 241, 0.02)
);
border: 1px solid rgba(99, 102, 241, 0.15);
.total-label {
font-size: 12px;
font-weight: 600;
color: #a5b4fc;
}
.total-value {
font-size: 16px;
font-weight: 700;
color: #e0e7ff;
letter-spacing: -0.02em;
}
.total-unit {
font-size: 10px;
font-weight: 500;
color: #a5b4fc;
}
}
.metric-divider {
width: 1px;
height: 20px;
background: #374151;
}
.metric-inline {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
flex: 1;
&.incoming {
color: #a5b4fc;
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.1),
rgba(99, 102, 241, 0.02)
);
border: 1px solid rgba(99, 102, 241, 0.15);
&:hover {
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.15),
rgba(99, 102, 241, 0.05)
);
box-shadow: 0 0 8px rgba(99, 102, 241, 0.2);
}
}
&.outgoing {
color: #a5b4fc;
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.1),
rgba(99, 102, 241, 0.02)
);
border: 1px solid rgba(99, 102, 241, 0.15);
&:hover {
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.15),
rgba(99, 102, 241, 0.05)
);
box-shadow: 0 0 8px rgba(99, 102, 241, 0.2);
}
}
.inline-value {
font-size: 14px;
font-weight: 700;
letter-spacing: -0.01em;
}
}
}
// Horizontal divider between top and bottom rows
.metric-horizontal-divider {
width: 100%;
height: 1px;
background: #374151;
margin: 10px 0 8px 0;
}
// Bottom row for error rate and p95 latency
.metric-bottom-row {
display: flex;
align-items: center;
gap: 8px;
.metric-inline-item {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 4px;
transition: all 0.2s ease;
// Status-based styling
&.status-healthy {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.08),
rgba(16, 185, 129, 0.02)
);
border: 1px solid rgba(16, 185, 129, 0.15);
.metric-value {
color: #10b981;
}
&:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.12),
rgba(16, 185, 129, 0.04)
);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.15);
}
}
&.status-warning {
background: linear-gradient(
135deg,
rgba(251, 191, 36, 0.08),
rgba(251, 191, 36, 0.02)
);
border: 1px solid rgba(251, 191, 36, 0.15);
.metric-value {
color: #fbbf24;
}
&:hover {
background: linear-gradient(
135deg,
rgba(251, 191, 36, 0.12),
rgba(251, 191, 36, 0.04)
);
box-shadow: 0 0 6px rgba(251, 191, 36, 0.15);
}
}
&.status-critical {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.08),
rgba(239, 68, 68, 0.02)
);
border: 1px solid rgba(239, 68, 68, 0.15);
.metric-value {
color: #ef4444;
}
&:hover {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.12),
rgba(239, 68, 68, 0.04)
);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.15);
}
}
&.status-unknown {
background: linear-gradient(
135deg,
rgba(107, 114, 128, 0.08),
rgba(107, 114, 128, 0.02)
);
border: 1px solid rgba(107, 114, 128, 0.15);
.metric-value {
color: #9ca3af;
}
}
.metric-label {
font-size: 11px;
font-weight: 600;
color: #9ca3af;
white-space: nowrap;
}
.metric-value {
font-size: 13px;
font-weight: 700;
letter-spacing: -0.01em;
}
}
.metric-row-divider {
width: 1px;
height: 20px;
background: #374151;
}
}
}
}
.body--light .metrics-section {
.metric-card-full {
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
border-color: #d1d5db;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
&:hover {
box-shadow:
0 6px 12px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
}
.metric-single-line {
.metric-total {
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.08),
rgba(99, 102, 241, 0.03)
);
border-color: rgba(99, 102, 241, 0.2);
.total-label {
color: #6366f1;
}
.total-value {
color: #4338ca;
}
.total-unit {
color: #6366f1;
}
}
.metric-divider {
background: #d1d5db;
}
.metric-inline {
&.incoming {
color: #6366f1;
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.08),
rgba(99, 102, 241, 0.02)
);
border-color: rgba(99, 102, 241, 0.2);
&:hover {
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.12),
rgba(99, 102, 241, 0.04)
);
box-shadow: 0 0 8px rgba(99, 102, 241, 0.15);
}
}
&.outgoing {
color: #6366f1;
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.08),
rgba(99, 102, 241, 0.02)
);
border-color: rgba(99, 102, 241, 0.2);
&:hover {
background: linear-gradient(
135deg,
rgba(99, 102, 241, 0.12),
rgba(99, 102, 241, 0.04)
);
box-shadow: 0 0 8px rgba(99, 102, 241, 0.15);
}
}
}
}
.metric-horizontal-divider {
background: #d1d5db;
}
.metric-bottom-row {
.metric-inline-item {
&.status-healthy {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.06),
rgba(16, 185, 129, 0.01)
);
border-color: rgba(16, 185, 129, 0.2);
.metric-value {
color: #059669;
}
&:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.1),
rgba(16, 185, 129, 0.03)
);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.12);
}
}
&.status-warning {
background: linear-gradient(
135deg,
rgba(217, 119, 6, 0.06),
rgba(217, 119, 6, 0.01)
);
border-color: rgba(217, 119, 6, 0.2);
.metric-value {
color: #d97706;
}
&:hover {
background: linear-gradient(
135deg,
rgba(217, 119, 6, 0.1),
rgba(217, 119, 6, 0.03)
);
box-shadow: 0 0 6px rgba(217, 119, 6, 0.12);
}
}
&.status-critical {
background: linear-gradient(
135deg,
rgba(220, 38, 38, 0.06),
rgba(220, 38, 38, 0.01)
);
border-color: rgba(220, 38, 38, 0.2);
.metric-value {
color: #dc2626;
}
&:hover {
background: linear-gradient(
135deg,
rgba(220, 38, 38, 0.1),
rgba(220, 38, 38, 0.03)
);
box-shadow: 0 0 6px rgba(220, 38, 38, 0.12);
}
}
&.status-unknown {
background: linear-gradient(
135deg,
rgba(107, 114, 128, 0.06),
rgba(107, 114, 128, 0.01)
);
border-color: rgba(107, 114, 128, 0.2);
.metric-value {
color: #6b7280;
}
}
.metric-label {
color: rgba(0, 0, 0, 0.6);
}
}
.metric-row-divider {
background: #d1d5db;
}
}
}
}
// Empty/Loading/Error States
.empty-state,
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
.loading-text,
.error-text {
margin-top: 0.75rem;
font-weight: 400;
}
}
.body--light {
.empty-state,
.loading-state,
.error-state {
color: rgba(0, 0, 0, 0.5);
}
}
.error-state {
color: #f44336;
font-weight: 500;
}
.body--light .error-state {
color: #c62828;
}
.error-state {
color: var(--q-negative);
}
.red-charts-section {
:deep(.card-container) {
box-shadow: none;
:first-child {
padding: 0 0.0625rem !important;
}
}
}
:deep(
.metrics-correlation-dashboard .q-splitter--vertical > .q-splitter__separator
) {
height: 100% !important;
}
</style>