110 lines
5.1 KiB
Vue
110 lines
5.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from "vue";
|
|
import { ChevronRight, ChevronDown } from "@lucide/vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Button } from "@/components/ui/button";
|
|
import { formatProfileNanos } from "@/lib/elasticsearch/elasticsearchProfile";
|
|
import type { ElasticsearchProfileNode, ElasticsearchProfileHeatLevel } from "@/lib/elasticsearch/elasticsearchProfile";
|
|
|
|
const HEAT_COLORS: Record<ElasticsearchProfileHeatLevel, string> = {
|
|
none: "var(--muted-foreground)",
|
|
cool: "#22c55e",
|
|
warm: "#f59e0b",
|
|
hot: "#ef4444",
|
|
};
|
|
|
|
const HEAT_BADGE_CLASSES: Record<ElasticsearchProfileHeatLevel, string> = {
|
|
none: "bg-muted text-muted-foreground",
|
|
cool: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
|
warm: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
|
hot: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
|
};
|
|
|
|
/** Nodes at or below this depth start collapsed to keep large trees responsive. */
|
|
const COLLAPSE_DEFAULT_DEPTH = 4;
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
node: ElasticsearchProfileNode;
|
|
depth?: number;
|
|
/** null = manual per-node state; "expanded"/"collapsed" = a global override. */
|
|
globalCollapse?: "expanded" | "collapsed" | null;
|
|
}>(),
|
|
{ depth: 0, globalCollapse: null },
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
/** The user toggled a node while a global expand/collapse was active. */
|
|
manual: [];
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const hasChildren = computed(() => props.node.children.length > 0);
|
|
const localCollapsed = ref((props.depth ?? 0) >= COLLAPSE_DEFAULT_DEPTH);
|
|
const collapsed = computed(() => {
|
|
if (props.globalCollapse === "collapsed") return true;
|
|
if (props.globalCollapse === "expanded") return false;
|
|
return localCollapsed.value;
|
|
});
|
|
|
|
function toggle() {
|
|
if (props.globalCollapse !== null) {
|
|
localCollapsed.value = !collapsed.value;
|
|
emit("manual");
|
|
} else {
|
|
localCollapsed.value = !localCollapsed.value;
|
|
}
|
|
}
|
|
|
|
const breakdownEntries = computed(() =>
|
|
Object.entries(props.node.breakdown ?? {})
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([name, value]) => ({ name, value })),
|
|
);
|
|
const barPercent = computed(() => `${Math.min(100, props.node.costShare * 100)}%`);
|
|
const costPercent = computed(() => {
|
|
const percent = props.node.costShare * 100;
|
|
return percent >= 10 ? `${Math.round(percent)}%` : `${percent.toFixed(1)}%`;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="flex min-w-0 items-center gap-1 rounded border bg-background px-2 py-1 text-xs" :class="node.isCriticalPath ? 'border-orange-500/40 bg-orange-500/[0.04]' : 'border-border'">
|
|
<Button v-if="hasChildren" variant="ghost" size="icon-xs" class="-ml-1 h-5 w-5 shrink-0 text-foreground/80" :aria-label="node.type" :aria-expanded="!collapsed" @click="toggle">
|
|
<ChevronRight v-if="collapsed" class="h-3 w-3" aria-hidden="true" />
|
|
<ChevronDown v-else class="h-3 w-3" aria-hidden="true" />
|
|
</Button>
|
|
<span v-else class="h-5 w-5 shrink-0" aria-hidden="true" />
|
|
|
|
<span class="shrink-0 rounded bg-muted px-1 py-0.5 font-mono font-medium">{{ node.type }}</span>
|
|
<span v-if="node.description" class="min-w-0 flex-1 truncate text-foreground/70" :title="node.description">{{ node.description }}</span>
|
|
<span v-if="!node.description && !hasChildren" class="min-w-0 flex-1" aria-hidden="true" />
|
|
|
|
<span class="shrink-0 tabular-nums text-foreground/80" :title="t('profile.selfTime')">self {{ formatProfileNanos(node.selfTimeInNanos) }}</span>
|
|
<span class="shrink-0 tabular-nums text-muted-foreground" :title="t('profile.totalTime')">total {{ formatProfileNanos(node.timeInNanos) }}</span>
|
|
|
|
<div class="flex w-24 shrink-0 items-center gap-1">
|
|
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
|
<div class="h-full rounded-full" :style="{ width: barPercent, background: HEAT_COLORS[node.heatLevel] }" />
|
|
</div>
|
|
<span class="shrink-0 rounded px-1 font-mono text-[10px] font-semibold tabular-nums" :class="HEAT_BADGE_CLASSES[node.heatLevel]" :title="t('profile.legendSelfTime')">{{ costPercent }}</span>
|
|
</div>
|
|
|
|
<span v-if="node.isCriticalPath" class="shrink-0 rounded border border-orange-500/30 bg-orange-500/10 px-1 py-0.5 text-[10px] font-semibold text-orange-700 dark:text-orange-300" :title="t('profile.criticalPath')">
|
|
{{ t("profile.criticalPathTag") }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="!collapsed">
|
|
<div v-if="breakdownEntries.length" class="ml-6 mt-px flex flex-wrap items-center gap-1 pl-2">
|
|
<span class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("profile.breakdown") }}</span>
|
|
<span v-for="entry in breakdownEntries" :key="entry.name" class="rounded bg-muted/60 px-1 py-0.5 font-mono text-[10px] tabular-nums text-foreground/80">{{ entry.name }}:{{ formatProfileNanos(entry.value) }}</span>
|
|
</div>
|
|
<div v-if="node.children.length" class="ml-3 mt-px space-y-px border-l pl-2">
|
|
<ElasticsearchProfileNodeRow v-for="(child, index) in node.children" :key="index" :node="child" :depth="depth + 1" :global-collapse="globalCollapse" @manual="emit('manual')" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|