You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/06/27 01:30:37 UTC
(skywalking-horizon-ui) 04/18: feat(profiling): task-detail modals + consistent, honest task-creation gating
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch refactor/decompose-large-files
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 23fcaaf080eb96cfc18e5c4233cbe3b6d3fe0a3d
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Jun 26 23:28:50 2026 +0800
feat(profiling): task-detail modals + consistent, honest task-creation gating
pprof and async profiling tabs gain a task-detail + per-instance-logs modal (parity with trace profiling), reading OAP's existing task-progress logs.
Task creation is now consistent: the New Task button is enabled on basic entity selection (never silently disabled on process availability) and always carries a tooltip; the create box surfaces the blocker with a visible reason and disables Create accordingly — eBPF on its real OAP couldProfiling signal, pprof/async when no instances are available. The network "no process" gate is removed: OAP exposes no upfront per-instance process-availability query (only queryPrepareCreateEBPFProfi [...]
Also fixes two detail-modal bugs: a fast task switch could land one task's logs in another's modal (stale-fetch guard), and an open detail modal could linger across a layer/service change (now reset).
---
.../profiling/AsyncProfilingTaskDetailModal.vue | 177 +++++++++++++++++++++
.../layer/profiling/LayerAsyncProfilingView.vue | 69 +++++++-
.../src/layer/profiling/LayerEBPFProfilingView.vue | 11 +-
.../layer/profiling/LayerNetworkProfilingView.vue | 1 +
.../layer/profiling/LayerPprofProfilingView.vue | 75 ++++++++-
apps/ui/src/layer/profiling/NewEBPFTaskModal.vue | 38 ++++-
.../src/layer/profiling/PprofTaskDetailModal.vue | 170 ++++++++++++++++++++
7 files changed, 524 insertions(+), 17 deletions(-)
diff --git a/apps/ui/src/layer/profiling/AsyncProfilingTaskDetailModal.vue b/apps/ui/src/layer/profiling/AsyncProfilingTaskDetailModal.vue
new file mode 100644
index 0000000..ec8849d
--- /dev/null
+++ b/apps/ui/src/layer/profiling/AsyncProfilingTaskDetailModal.vue
@@ -0,0 +1,177 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Read-only task-detail modal for the async-profiler (Java) tab — the
+ trace-profiling tab's counterpart. Shows the task's create parameters
+ (events, instance fan-out, duration, exec args) plus the per-instance
+ operation logs OAP fanned the task out to. Opening it must not disturb
+ the parent's selected-task / instance-filter state, so it is driven by
+ its own `task` ref in the parent. `fmtTime` is injected so the timestamp
+ format stays identical to the surrounding task list.
+-->
+<script setup lang="ts">
+import type { AsyncProfilingTask, AsyncProfilingProgressLog } from '@/api/client';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+
+const props = defineProps<{
+ task: AsyncProfilingTask | null;
+ serviceName: string | null;
+ logs: AsyncProfilingProgressLog[];
+ fmtTime: (ms: number) => string;
+}>();
+const emit = defineEmits<{
+ (e: 'close'): void;
+}>();
+
+useEscapeToClose(
+ () => props.task != null,
+ () => emit('close'),
+);
+</script>
+
+<template>
+ <div v-if="task" class="dlg-mask" @click.self="emit('close')">
+ <div class="dlg wide">
+ <div class="dlg-head">
+ <div>Async profile task detail</div>
+ <button class="x" @click="emit('close')">×</button>
+ </div>
+ <div class="dlg-body">
+ <dl class="kv">
+ <dt>Service</dt><dd>{{ serviceName ?? task.serviceId }}</dd>
+ <dt>Events</dt><dd>{{ task.events?.join(', ') || '—' }}</dd>
+ <dt>Instances</dt>
+ <dd>
+ {{ task.serviceInstanceIds?.length ?? 0 }} instance{{ (task.serviceInstanceIds?.length ?? 0) === 1 ? '' : 's' }}
+ </dd>
+ <dt>Create time</dt><dd>{{ fmtTime(task.createTime) }}</dd>
+ <dt>Duration</dt><dd>{{ task.duration }} sec</dd>
+ <template v-if="task.execArgs">
+ <dt>Exec args</dt><dd class="mono">{{ task.execArgs }}</dd>
+ </template>
+ </dl>
+ <div v-if="logs.length" class="logs">
+ <h5>Instance logs</h5>
+ <div v-for="(log, i) in logs" :key="i" class="log-line">
+ <span class="t-tag">{{ log.operationType }}</span>
+ <span class="muted">{{ log.instanceName }}</span>
+ <span>{{ fmtTime(log.operationTime) }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.muted {
+ color: var(--sw-fg-3);
+}
+.dlg-mask {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.dlg {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 6px;
+ width: 520px;
+ max-width: 92vw;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+}
+.dlg.wide {
+ width: 640px;
+}
+.dlg-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+ background: var(--sw-bg-2);
+ font-weight: 600;
+ font-size: 12.5px;
+ color: var(--sw-fg-0);
+}
+.x {
+ background: transparent;
+ border: none;
+ color: var(--sw-fg-3);
+ font-size: 18px;
+ cursor: pointer;
+ line-height: 1;
+}
+.dlg-body {
+ padding: 14px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.kv {
+ display: grid;
+ grid-template-columns: 140px 1fr;
+ row-gap: 6px;
+ font-size: 11.5px;
+ margin: 0;
+}
+.kv dt {
+ color: var(--sw-fg-3);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-size: 10px;
+ align-self: center;
+}
+.kv dd {
+ margin: 0;
+ color: var(--sw-fg-0);
+}
+.kv dd.mono {
+ font-family: var(--sw-mono);
+ word-break: break-all;
+}
+.logs h5 {
+ margin: 14px 0 6px;
+ font-size: 11px;
+ color: var(--sw-fg-1);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+.log-line {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 11px;
+ padding: 4px 0;
+ border-bottom: 1px dotted var(--sw-line);
+}
+.t-tag {
+ font-size: 10px;
+ padding: 0 5px;
+ border-radius: 2px;
+ background: var(--sw-accent);
+ color: #fff;
+ font-weight: 600;
+}
+</style>
diff --git a/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue b/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue
index 334483c..67d164b 100644
--- a/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerAsyncProfilingView.vue
@@ -27,15 +27,18 @@
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerServices } from '@/layer/useLayerServices';
import { useSelectedService } from '@/layer/useSelectedService';
import { bffClient } from '@/api/client';
import type {
AsyncProfilingEvent,
+ AsyncProfilingProgressLog,
AsyncProfilingTask,
AsyncProfilingTree,
ProfileAnalyzationTree,
} from '@/api/client';
import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
+import AsyncProfilingTaskDetailModal from '@/layer/profiling/AsyncProfilingTaskDetailModal.vue';
import { useNewTaskPoll } from '@/layer/profiling/useNewTaskPoll';
import Icon from '@/components/icons/Icon.vue';
@@ -43,12 +46,22 @@ const route = useRoute();
const layerKey = computed(() => String(route.params.layerKey ?? ''));
const { selectedId: serviceId } = useSelectedService();
const instances = useLayerInstances(layerKey, serviceId);
+const { services: roster } = useLayerServices(layerKey);
+const serviceName = computed<string | null>(
+ () => roster.value.find((s) => s.id === serviceId.value)?.name ?? null,
+);
const tasks = ref<AsyncProfilingTask[]>([]);
const tasksError = ref<string | null>(null);
const currentTask = ref<AsyncProfilingTask | null>(null);
const tasksLoading = ref(false);
+// Track the operator's open task-detail modal independently of the
+// "current selected task" — opening the detail icon mustn't swap the
+// instance filter / flame graph out.
+const taskDetailFor = ref<AsyncProfilingTask | null>(null);
+const taskDetailLogs = ref<AsyncProfilingProgressLog[]>([]);
+
const selectedInstances = ref<string[]>([]);
const eventType = ref<string>('EXECUTION_SAMPLE');
const tree = ref<AsyncProfilingTree | null>(null);
@@ -76,7 +89,10 @@ const EVENTS: AsyncProfilingEvent[] = ['CPU', 'ALLOC', 'LOCK', 'WALL', 'CTIMER',
watch(
() => layerKey.value + '|' + (serviceId.value ?? ''),
- () => void refreshTasks(),
+ () => {
+ taskDetailFor.value = null; // drop the detail modal on context change
+ void refreshTasks();
+ },
{ immediate: true },
);
@@ -107,6 +123,19 @@ function syncInstancesFromTask(t: AsyncProfilingTask): void {
else eventType.value = 'EXECUTION_SAMPLE';
}
+async function openTaskDetail(t: AsyncProfilingTask, ev: Event): Promise<void> {
+ ev.stopPropagation();
+ taskDetailFor.value = t;
+ taskDetailLogs.value = [];
+ try {
+ const resp = await bffClient.asyncProfile.progress(t.id);
+ if (taskDetailFor.value?.id !== t.id) return; // stale: another task opened mid-fetch
+ taskDetailLogs.value = resp.progress?.logs ?? [];
+ } catch {
+ if (taskDetailFor.value?.id === t.id) taskDetailLogs.value = [];
+ }
+}
+
async function runAnalyze(): Promise<void> {
if (!currentTask.value) return;
if (!selectedInstances.value.length) {
@@ -225,6 +254,7 @@ function instanceName(id: string): string {
<button
class="btn-new"
:disabled="!serviceId"
+ :title="serviceId ? 'Create a new async profiling task' : 'Pick a service first'"
@click="showNewTask = true"
>+ New Task</button>
</div>
@@ -245,6 +275,12 @@ function instanceName(id: string): string {
<div class="row1">
<span class="t-tag">{{ t.events?.join(',') }}</span>
<span class="ep">{{ t.serviceInstanceIds?.length ?? 0 }} instance{{ (t.serviceInstanceIds?.length ?? 0) === 1 ? '' : 's' }}</span>
+ <button
+ type="button"
+ class="row-eye"
+ title="View task detail"
+ @click.stop="openTaskDetail(t, $event)"
+ >i</button>
</div>
<div class="row2">
<span>{{ fmtTime(t.createTime) }}</span>
@@ -319,7 +355,7 @@ function instanceName(id: string): string {
:class="{ on: newTask.instances.includes(i.id) }"
@click="toggleInstance(i.id, 'new')"
>{{ i.name }}</button>
- <span v-if="!instances.instances.value.length" class="muted">No instances available.</span>
+ <span v-if="!instances.instances.value.length" class="muted">No instances available for this service — an async profiling task cannot be created.</span>
</div>
</div>
<div class="field-row">
@@ -351,10 +387,23 @@ function instanceName(id: string): string {
</div>
<div class="dlg-foot">
<button class="btn-secondary" @click="showNewTask = false">Cancel</button>
- <button class="btn-primary" @click="submitNewTask">Create task</button>
+ <button
+ class="btn-primary"
+ :disabled="!newTask.instances.length || !newTask.events.length"
+ :title="!newTask.instances.length ? 'Select at least one instance' : !newTask.events.length ? 'Pick at least one event' : 'Create the async profiling task'"
+ @click="submitNewTask"
+ >Create task</button>
</div>
</div>
</div>
+
+ <AsyncProfilingTaskDetailModal
+ :task="taskDetailFor"
+ :service-name="serviceName"
+ :logs="taskDetailLogs"
+ :fmt-time="fmtTime"
+ @close="taskDetailFor = null"
+ />
</template>
<style scoped>
@@ -487,6 +536,20 @@ function instanceName(id: string): string {
text-overflow: ellipsis;
white-space: nowrap;
}
+.row-eye {
+ margin-left: auto;
+ background: transparent;
+ border: none;
+ color: var(--sw-fg-3);
+ cursor: pointer;
+ padding: 0;
+ font-style: italic;
+ font-weight: 600;
+ line-height: 1;
+}
+.row-eye:hover {
+ color: var(--sw-accent);
+}
.muted {
color: var(--sw-fg-3);
}
diff --git a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
index 36032f9..034c0bc 100644
--- a/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerEBPFProfilingView.vue
@@ -177,14 +177,8 @@ function onPickTask(t: EBPFTask): void {
><Icon name="refresh" :size="11" /></button>
<button
class="btn-new"
- :disabled="!selectedId || !couldProfiling"
- :title="
- !selectedId
- ? 'Pick a service'
- : couldProfiling
- ? 'Create a new eBPF task'
- : 'OAP reports no profilable processes for this service'
- "
+ :disabled="!selectedId"
+ :title="!selectedId ? 'Pick a service' : 'Create a new eBPF task'"
@click="showNewTask = true"
>+ New Task</button>
</div>
@@ -305,6 +299,7 @@ function onPickTask(t: EBPFTask): void {
v-model:show="showNewTask"
:service-name="serviceName"
:process-labels="allProcessLabels"
+ :could-profiling="couldProfiling"
:error="newTaskError"
@submit="onSubmitNewTask"
/>
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index babfcdb..10bf4ee 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -378,6 +378,7 @@ function fmtTime(ms: number): string {
<button
class="btn-new"
:disabled="!selectedInstanceId"
+ :title="!selectedInstanceId ? 'Pick an instance first' : 'Create a new network profile task'"
@click="showNewTask = true"
>+ New Task</button>
</div>
diff --git a/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue b/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue
index 419015b..0a3a4ce 100644
--- a/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerPprofProfilingView.vue
@@ -27,10 +27,17 @@
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerServices } from '@/layer/useLayerServices';
import { useSelectedService } from '@/layer/useSelectedService';
import { bffClient } from '@/api/client';
-import type { PprofTask, PprofTree, ProfileAnalyzationTree } from '@/api/client';
+import type {
+ AsyncProfilingProgressLog,
+ PprofTask,
+ PprofTree,
+ ProfileAnalyzationTree,
+} from '@/api/client';
import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
+import PprofTaskDetailModal from '@/layer/profiling/PprofTaskDetailModal.vue';
import { useNewTaskPoll } from '@/layer/profiling/useNewTaskPoll';
import Icon from '@/components/icons/Icon.vue';
@@ -38,6 +45,10 @@ const route = useRoute();
const layerKey = computed(() => String(route.params.layerKey ?? ''));
const { selectedId: serviceId } = useSelectedService();
const instances = useLayerInstances(layerKey, serviceId);
+const { services } = useLayerServices(layerKey);
+const serviceName = computed<string | null>(
+ () => services.value.find((s) => s.id === serviceId.value)?.name ?? null,
+);
const tasks = ref<PprofTask[]>([]);
const tasksError = ref<string | null>(null);
@@ -49,6 +60,12 @@ const tree = ref<PprofTree | null>(null);
const analyzeError = ref<string | null>(null);
const analyzeLoading = ref(false);
+// Track the operator's open task-detail modal independently of the
+// "current selected task" — opening the detail icon mustn't reset the
+// selected instances / analyze result.
+const taskDetailFor = ref<PprofTask | null>(null);
+const taskDetailLogs = ref<AsyncProfilingProgressLog[]>([]);
+
const showNewTask = ref(false);
const newTask = reactive({
instances: [] as string[],
@@ -80,7 +97,10 @@ const DURATION_OPTS = [
watch(
() => layerKey.value + '|' + (serviceId.value ?? ''),
- () => void refreshTasks(),
+ () => {
+ taskDetailFor.value = null; // drop the detail modal on context change
+ void refreshTasks();
+ },
{ immediate: true },
);
@@ -122,6 +142,19 @@ async function runAnalyze(): Promise<void> {
}
}
+async function openTaskDetail(t: PprofTask, ev: Event): Promise<void> {
+ ev.stopPropagation();
+ taskDetailFor.value = t;
+ taskDetailLogs.value = [];
+ try {
+ const resp = await bffClient.pprof.progress(t.id);
+ if (taskDetailFor.value?.id !== t.id) return; // stale: another task opened mid-fetch
+ taskDetailLogs.value = resp.progress?.logs ?? [];
+ } catch {
+ if (taskDetailFor.value?.id === t.id) taskDetailLogs.value = [];
+ }
+}
+
const profileTrees = computed<ProfileAnalyzationTree[]>(() => {
if (!tree.value) return [];
return [
@@ -201,7 +234,7 @@ function instanceName(id: string): string {
aria-label="Refresh task list"
@click="refreshTasks"
><Icon name="refresh" :size="11" /></button>
- <button class="btn-new" :disabled="!serviceId" @click="showNewTask = true">+ New Task</button>
+ <button class="btn-new" :disabled="!serviceId" :title="serviceId ? 'Create a new pprof task' : 'Pick a service first'" @click="showNewTask = true">+ New Task</button>
</div>
</div>
<div v-if="polling" class="poll-hint">Waiting for new task… ({{ pollRound }}/4)</div>
@@ -220,6 +253,12 @@ function instanceName(id: string): string {
<div class="row1">
<span class="t-tag">{{ t.events }}</span>
<span class="ep">{{ t.serviceInstanceIds?.length ?? 0 }} instance{{ (t.serviceInstanceIds?.length ?? 0) === 1 ? '' : 's' }}</span>
+ <button
+ type="button"
+ class="row-eye"
+ title="View task detail"
+ @click.stop="openTaskDetail(t, $event)"
+ >i</button>
</div>
<div class="row2">
<span>{{ fmtTime(t.createTime) }}</span>
@@ -283,7 +322,7 @@ function instanceName(id: string): string {
:class="{ on: newTask.instances.includes(i.id) }"
@click="toggleInstance(i.id, 'new')"
>{{ i.name }}</button>
- <span v-if="!instances.instances.value.length" class="muted">No instances available.</span>
+ <span v-if="!instances.instances.value.length" class="muted">No instances available for this service — a pprof task cannot be created.</span>
</div>
</div>
<div class="field">
@@ -320,10 +359,23 @@ function instanceName(id: string): string {
</div>
<div class="dlg-foot">
<button class="btn-secondary" @click="showNewTask = false">Cancel</button>
- <button class="btn-primary" @click="submitNewTask">Create task</button>
+ <button
+ class="btn-primary"
+ :disabled="!newTask.instances.length || !newTask.event"
+ :title="!newTask.instances.length ? 'Select at least one instance' : !newTask.event ? 'Pick an event type' : 'Create the pprof task'"
+ @click="submitNewTask"
+ >Create task</button>
</div>
</div>
</div>
+
+ <PprofTaskDetailModal
+ :task="taskDetailFor"
+ :service-name="serviceName"
+ :logs="taskDetailLogs"
+ :fmt-time="fmtTime"
+ @close="taskDetailFor = null"
+ />
</template>
<style scoped>
@@ -456,6 +508,19 @@ function instanceName(id: string): string {
text-overflow: ellipsis;
white-space: nowrap;
}
+.row-eye {
+ margin-left: auto;
+ background: transparent;
+ border: none;
+ color: var(--sw-fg-3);
+ cursor: pointer;
+ padding: 0;
+ font-style: italic;
+ line-height: 1;
+}
+.row-eye:hover {
+ color: var(--sw-accent);
+}
.muted {
color: var(--sw-fg-3);
}
diff --git a/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue b/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
index a6b0cba..220d59e 100644
--- a/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
+++ b/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
@@ -31,6 +31,7 @@ const props = defineProps<{
show: boolean;
serviceName: string | null;
processLabels: string[];
+ couldProfiling: boolean;
error: string | null;
}>();
const emit = defineEmits<{
@@ -91,6 +92,15 @@ function submit(): void {
<button class="x" @click="close">×</button>
</div>
<div class="dlg-body">
+ <div v-if="!couldProfiling" class="dlg-warn">
+ <strong>OAP reports no profilable processes for this service.</strong>
+ <span>A new task can't run until OAP sees an eBPF-profilable process. Check that:</span>
+ <ul>
+ <li>instances are running and instrumented;</li>
+ <li>eBPF collection is enabled in OAP;</li>
+ <li>the processes support eBPF profiling.</li>
+ </ul>
+ </div>
<div class="field">
<label>Process labels (filter; leave empty = all)</label>
<div class="chip-row">
@@ -142,7 +152,12 @@ function submit(): void {
</div>
<div class="dlg-foot">
<button class="btn-secondary" @click="close">Cancel</button>
- <button class="btn-primary" @click="submit">Create task</button>
+ <button
+ class="btn-primary"
+ :disabled="!couldProfiling"
+ :title="couldProfiling ? '' : 'OAP reports no profilable processes for this service'"
+ @click="submit"
+ >Create task</button>
</div>
</div>
</div>
@@ -307,4 +322,25 @@ function submit(): void {
color: var(--sw-err);
font-size: 11px;
}
+.dlg-warn {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 10px 12px;
+ font-size: 11.5px;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ border-left: 3px solid var(--sw-err);
+ border-radius: 4px;
+}
+.dlg-warn strong {
+ color: var(--sw-err);
+ font-weight: 600;
+}
+.dlg-warn ul {
+ margin: 0;
+ padding-left: 18px;
+ color: var(--sw-fg-2);
+}
</style>
diff --git a/apps/ui/src/layer/profiling/PprofTaskDetailModal.vue b/apps/ui/src/layer/profiling/PprofTaskDetailModal.vue
new file mode 100644
index 0000000..f86a3c9
--- /dev/null
+++ b/apps/ui/src/layer/profiling/PprofTaskDetailModal.vue
@@ -0,0 +1,170 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Read-only task-detail modal for the pprof (Go) profiling tab: the task's
+ create parameters plus the per-instance operation logs OAP fanned the task
+ out to. Opening it must not disturb the parent's selected-task / analyze
+ state, so it is driven by its own `task` ref in the parent. `fmtTime` is
+ injected so the timestamp format stays identical to the surrounding task
+ list. Mirrors TraceProfileTaskDetailModal, but pprof tasks carry an event
+ type + optional duration / dumpPeriod instead of endpoint + thresholds.
+-->
+<script setup lang="ts">
+import type { PprofTask, AsyncProfilingProgressLog } from '@/api/client';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+
+const props = defineProps<{
+ task: PprofTask | null;
+ serviceName: string | null;
+ logs: AsyncProfilingProgressLog[];
+ fmtTime: (ms: number) => string;
+}>();
+const emit = defineEmits<{
+ (e: 'close'): void;
+}>();
+
+useEscapeToClose(
+ () => props.task != null,
+ () => emit('close'),
+);
+</script>
+
+<template>
+ <div v-if="task" class="dlg-mask" @click.self="emit('close')">
+ <div class="dlg wide">
+ <div class="dlg-head">
+ <div>pprof task detail</div>
+ <button class="x" @click="emit('close')">×</button>
+ </div>
+ <div class="dlg-body">
+ <dl class="kv">
+ <dt>Service</dt><dd>{{ serviceName ?? task.serviceId }}</dd>
+ <dt>Instances</dt><dd>{{ task.serviceInstanceIds?.length ?? 0 }}</dd>
+ <dt>Event</dt><dd>{{ task.events }}</dd>
+ <dt>Create time</dt><dd>{{ fmtTime(task.createTime) }}</dd>
+ <dt v-if="task.duration != null">Duration</dt>
+ <dd v-if="task.duration != null">{{ task.duration }} min</dd>
+ <dt v-if="task.dumpPeriod != null">Dump period</dt>
+ <dd v-if="task.dumpPeriod != null">{{ task.dumpPeriod }}</dd>
+ </dl>
+ <div v-if="logs.length" class="logs">
+ <h5>Instance logs</h5>
+ <div v-for="(log, i) in logs" :key="i" class="log-line">
+ <span class="t-tag">{{ log.operationType }}</span>
+ <span class="muted">{{ log.instanceName }}</span>
+ <span>{{ fmtTime(log.operationTime) }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.muted {
+ color: var(--sw-fg-3);
+}
+.dlg-mask {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.dlg {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 6px;
+ width: 520px;
+ max-width: 92vw;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+}
+.dlg.wide {
+ width: 640px;
+}
+.dlg-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+ background: var(--sw-bg-2);
+ font-weight: 600;
+ font-size: 12.5px;
+ color: var(--sw-fg-0);
+}
+.x {
+ background: transparent;
+ border: none;
+ color: var(--sw-fg-3);
+ font-size: 18px;
+ cursor: pointer;
+ line-height: 1;
+}
+.dlg-body {
+ padding: 14px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.kv {
+ display: grid;
+ grid-template-columns: 140px 1fr;
+ row-gap: 6px;
+ font-size: 11.5px;
+ margin: 0;
+}
+.kv dt {
+ color: var(--sw-fg-3);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-size: 10px;
+ align-self: center;
+}
+.kv dd {
+ margin: 0;
+ color: var(--sw-fg-0);
+}
+.logs h5 {
+ margin: 14px 0 6px;
+ font-size: 11px;
+ color: var(--sw-fg-1);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+.log-line {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 11px;
+ padding: 4px 0;
+ border-bottom: 1px dotted var(--sw-line);
+}
+.t-tag {
+ font-size: 10px;
+ padding: 0 5px;
+ border-radius: 2px;
+ background: #6c5cd9;
+ color: #fff;
+ font-weight: 600;
+}
+</style>