节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
8.90 KiB
Stardust
<script setup lang="ts">
/**
 * TraceTree.vue — 调用链树通用组件(props: traceId)
 *
 * 复刻 MVC TraceController.Search 的深度搜索算法(纯前端 JS):
 *  - 拉 GET /Monitors/SampleData?traceId=&pageSize=10000 拿到 SampleData 列表
 *  - 按 ParentId/SpanId 构建有序调用链树:
 *      顶级 parent(其 span 不存在)→ 子级按 StartTime 降序入栈 → 深度优先弹出
 *  - 渲染为可折叠 el-tree(默认展开全部),节点显示:操作名 / 耗时 / 开始时间 / 应用@节点
 *  - 空数据 → el-empty
 *
 * 数据字段(见 Stardust.Data/Monitors/采样数据.cs):
 *  Id / DataId / ItemId / Success / StartTime / EndTime / Cost / Value /
 *  ClientId / TraceId / SpanId / ParentId / Tag / Error / CreateIP / CreateTime
 *  以及 TraceItem(嵌套,可能为空):Kind / Name / DisplayName
 */
import { ref, onMounted, watch, computed } from 'vue';
import request from '@newlifex/cube-vue/core/utils/request';

const props = defineProps<{ traceId?: string }>();

interface SampleRow {
  id?: number | string;
  dataId?: number | string;
  itemId?: number | string;
  success?: boolean;
  startTime?: number;
  endTime?: number;
  cost?: number;
  spanId?: string | null;
  parentId?: string | null;
  appName?: string | null;
  appId?: number | string;
  name?: string | null;
  tag?: string | null;
  error?: string | null;
  traceItem?: { kind?: string; name?: string; displayName?: string } | null;
  [k: string]: unknown;
}

interface TreeRow {
  key: string;
  id: number | string;
  label: string;
  name: string;
  cost: number;
  startTime: number;
  success: boolean;
  appName: string;
  nodeName: string;
  hasError: boolean;
  raw: SampleRow;
  children: TreeRow[];
}

const loading = ref(false);
const error = ref('');
const rows = ref<SampleRow[]>([]);
const treeData = ref<TreeRow[]>([]);
const defaultExpandedKeys = ref<string[]>([]);

function fmtTime(ms?: number): string {
  if (!ms || ms <= 0) return '-';
  const d = new Date(ms);
  const p = (n: number) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}

/**
 * 复刻 MVC Search 深度搜索算法(纯 JS):
 *  1. 反向排序(StartTime 降序、SpanId 降序)
 *  2. 找顶级 parent(其对应 span 不存在的 ParentId),其子级按时间降序入栈
 *  3. 依次弹出 parent,深度搜索其子级(按时间降序入栈),形成有序结果 rs
 *  4. 残留异常数据追加到末尾
 * 返回有序的扁平列表(保留了父先于子的访问顺序)。
 */
function depthSearchOrder(list: SampleRow[]): SampleRow[] {
  if (list.length === 0) return [];
  const work = [...list];
  // 反向顺序(startTime 降序,spanId 降序)
  work.sort((a, b) => {
    const sa = a.startTime ?? 0;
    const sb = b.startTime ?? 0;
    if (sb !== sa) return sb - sa;
    return String(b.spanId ?? '').localeCompare(String(a.spanId ?? ''));
  });

  const spanSet = new Set(work.map((e) => String(e.spanId ?? '')));
  const stack: SampleRow[] = [];

  // 所有 parentId(含空),首次出现的 parentId 优先
  const pids = work.map((e) => String(e.parentId ?? '')).filter((v, i, arr) => arr.indexOf(v) === i);
  // 顶级 parentId:其对应 span 不存在(含空字符串)
  for (const pid of pids) {
    if (!spanSet.has(pid)) {
      for (const elm of work.filter((e) => String(e.parentId ?? '') === pid)) {
        stack.push(elm);
      }
    }
  }
  // 从 work 移除已进入栈的
  const inStack = new Set(stack);
  for (let i = work.length - 1; i >= 0; i--) {
    if (inStack.has(work[i])) work.splice(i, 1);
  }

  const rs: SampleRow[] = [];
  if (stack.length === 0) return list; // 理论上至少有顶级

  while (stack.length > 0) {
    const parent = stack.pop()!;
    rs.push(parent);
    const pid = String(parent.spanId ?? '');
    // 当前 span 的下级,按时间降序入栈
    const children = work.filter((e) => String(e.parentId ?? '') === pid);
    for (const c of children) {
      stack.push(c);
      const idx = work.indexOf(c);
      if (idx >= 0) work.splice(idx, 1);
    }
  }
  // 残留异常数据
  rs.push(...work);
  return rs;
}

/** 将有序扁平列表(父先于子)转换为树 */
function buildTree(list: SampleRow[]): TreeRow[] {
  const ordered = depthSearchOrder(list);
  const map = new Map<string, TreeRow>();
  const roots: TreeRow[] = [];

  const makeRow = (item: SampleRow): TreeRow => {
    const nodeName = item.traceItem?.displayName || item.name || item.appName || String(item.id);
    const label = `${item.name || item.appName || '未知操作'} · ${item.cost ?? 0}ms · ${fmtTime(item.startTime)}`;
    return {
      key: String(item.id),
      id: item.id as number | string,
      label,
      name: item.name || '',
      cost: item.cost ?? 0,
      startTime: item.startTime ?? 0,
      success: item.success !== false,
      appName: item.appName || '',
      nodeName: nodeName || '',
      hasError: !!(item.error && item.error.length > 0),
      raw: item,
      children: [],
    };
  };

  for (const item of ordered) {
    const row = makeRow(item);
    map.set(String(item.id), row);
  }

  for (const item of ordered) {
    const row = map.get(String(item.id))!;
    const parentId = String(item.parentId ?? '');
    // 找父级:父级可能是某个 row 的 spanId
    let parentRow: TreeRow | undefined;
    if (parentId) {
      for (const r of map.values()) {
        if (String(r.raw.spanId ?? '') === parentId) {
          parentRow = r;
          break;
        }
      }
    }
    if (parentRow) parentRow.children.push(row);
    else roots.push(row);
  }

  // 收集展开 keys(默认全展开)
  const keys: string[] = [];
  const walk = (arr: TreeRow[]) => {
    for (const r of arr) {
      keys.push(r.key);
      if (r.children.length) walk(r.children);
    }
  };
  walk(roots);
  defaultExpandedKeys.value = keys;
  return roots;
}

async function fetchData() {
  const traceId = props.traceId?.trim();
  if (!traceId) {
    rows.value = [];
    treeData.value = [];
    return;
  }
  loading.value = true;
  error.value = '';
  try {
    const res: any = await request({
      url: '/Monitors/SampleData',
      params: { traceId, pageSize: 10000 },
    });
    const list: SampleRow[] = res?.data ?? res ?? [];
    rows.value = Array.isArray(list) ? list : [];
    treeData.value = buildTree(rows.value);
  } catch (e: any) {
    console.error('[TraceTree] fetch failed', e);
    error.value = e?.message || '加载失败';
    rows.value = [];
    treeData.value = [];
  } finally {
    loading.value = false;
  }
}

const isEmpty = computed(() => !loading.value && treeData.value.length === 0);

onMounted(fetchData);
watch(
  () => props.traceId,
  () => fetchData(),
);
</script>

<template>
  <div class="trace-tree-wrap">
    <el-empty
      v-if="isEmpty"
      description="暂无调用链数据(TraceId 无采样)"
    />

    <el-alert
      v-else-if="error"
      type="error"
      :title="error"
      :closable="false"
      show-icon
    />

    <div
      v-else
      v-loading="loading"
      class="tt-body"
    >
      <el-tree
        v-if="treeData.length"
        :data="treeData"
        :default-expanded-keys="defaultExpandedKeys"
        node-key="key"
        :expand-on-click-node="false"
        class="tt-tree"
      >
        <template #default="{ data }">
          <span class="tt-node">
            <el-icon
              v-if="data.hasError"
              class="tt-err-icon"
              color="var(--el-color-danger)"
            >
              <WarningFilled />
            </el-icon>
            <span class="tt-name">{{ data.nodeName || data.name || '未知操作' }}</span>
            <el-tag
              size="small"
              :type="data.success ? 'success' : 'danger'"
              class="tt-cost"
            >
              {{ data.cost }}ms
            </el-tag>
            <span class="tt-meta">{{ fmtTime(data.startTime) }}</span>
            <span
              v-if="data.appName"
              class="tt-app"
            >{{ data.appName }}</span>
          </span>
        </template>
      </el-tree>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.trace-tree-wrap {
  width: 100%;

  .tt-body {
    min-height: 120px;
    padding: 8px 0;
  }

  .tt-tree {
    width: 100%;
    background: transparent;
  }

  .tt-node {
    display: inline-flex;
    align-items: center;
    gap: 8px;

    .tt-err-icon {
      font-size: 14px;
    }

    .tt-name {
      font-size: 13px;
      font-weight: 600;
      color: var(--el-text-color-primary);
    }

    .tt-cost {
      font-variant-numeric: tabular-nums;
    }

    .tt-meta {
      font-size: 12px;
      color: var(--el-text-color-secondary);
      font-variant-numeric: tabular-nums;
    }

    .tt-app {
      font-size: 12px;
      color: var(--el-text-color-secondary);
      &::before {
        content: '· ';
      }
    }
  }
}
</style>