This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 5f1ff7b service dashboard: smaller widget height + per-metric color
alignment
5f1ff7b is described below
commit 5f1ff7b4d5175658f41609b1a3ea63c74f7bbe95
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 21:29:17 2026 +0800
service dashboard: smaller widget height + per-metric color alignment
Widget rows shrink from 180px to 120px each — span-2 line widgets are
240px tall now (was 360), so 2 rows of 3 line charts fit without
dominating the page.
Widget colors line up with the layer-header KPI strip:
Traffic / RPM → orange (sw-accent)
Apdex / SLA → purple (sw-purple)
p99 / latency → yellow (sw-warn)
err / failure → red (sw-err)
Each widget exposes its metric color as a CSS custom property the card
head consumes — left border + title color tinted, so the dashboard
panes read at a glance with the same hue as the KPI summary above.
TimeChart now actually honors its accent prop (was declared but
unused — every line series rendered orange regardless). First series
uses the resolved accent; secondary lines (relabels() multi-series)
cycle through a distinguishable palette so percentile charts stay
readable. TopList bars take the widget color too.
---
apps/ui/src/components/charts/TimeChart.vue | 66 ++++++++++++++++++-------
apps/ui/src/views/layer/LayerDashboardsView.vue | 50 ++++++++++++++++---
2 files changed, 90 insertions(+), 26 deletions(-)
diff --git a/apps/ui/src/components/charts/TimeChart.vue
b/apps/ui/src/components/charts/TimeChart.vue
index b582f90..55d4546 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -54,13 +54,33 @@ const props = withDefaults(
},
);
-const PALETTE = [
- '#f97316', // sw-accent (orange)
- '#60a5fa', // info-ish
+/**
+ * Resolve a CSS variable like `var(--sw-accent)` to its computed RGB
+ * by querying the document root. ECharts doesn't honor CSS vars on
+ * canvas-rendered series, so we evaluate once and feed the hex. Falls
+ * back to the orange accent when the variable resolves empty.
+ */
+function cssVar(token: string): string {
+ if (!token.startsWith('var(')) return token;
+ if (typeof window === 'undefined') return '#f97316';
+ const name = token.replace(/^var\(\s*/, '').replace(/\s*\)\s*$/, '');
+ const v =
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return v || '#f97316';
+}
+
+/**
+ * Secondary palette for multi-line widgets (e.g., percentile relabels
+ * → 5 series). The first series uses the widget's accent (or the
+ * accent prop); subsequent lines pick from this palette so the lines
+ * are distinguishable. Picks deliberately don't reuse the accent.
+ */
+const SECONDARY = [
+ '#60a5fa', // info-ish (blue)
'#a78bfa', // purple
'#22d3ee', // cyan
'#f472b6', // pink
- '#34d399', // ok-ish
+ '#34d399', // ok-ish (green)
+ '#fbbf24', // amber
];
const container = ref<HTMLDivElement | null>(null);
@@ -113,20 +133,26 @@ function buildOption(): echarts.EChartsCoreOption {
axisLabel: { color: '#64748b', fontSize: 9 },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
},
- series: props.series.map((s, i) => ({
- name: s.label,
- type: 'line',
- smooth: true,
- symbol: 'none',
- lineStyle: { width: 1.5 },
- data: s.data.map((v) => (v === null ? '-' : v)),
- // Resolve CSS var for the first series; fall back to the palette.
- itemStyle: { color: i === 0 ? PALETTE[0] : PALETTE[i % PALETTE.length] },
- areaStyle:
- props.series.length === 1
- ? { color: PALETTE[0], opacity: 0.12 }
- : undefined,
- })),
+ series: props.series.map((s, i) => {
+ // First series uses the widget's accent color (resolved from a
+ // CSS var); secondary lines cycle through SECONDARY. Single-series
+ // widgets get a soft area fill in the accent tone.
+ const accentHex = cssVar(props.accent);
+ const color = i === 0 ? accentHex : SECONDARY[(i - 1) %
SECONDARY.length];
+ return {
+ name: s.label,
+ type: 'line',
+ smooth: true,
+ symbol: 'none',
+ lineStyle: { width: 1.5 },
+ data: s.data.map((v) => (v === null ? '-' : v)),
+ itemStyle: { color },
+ areaStyle:
+ props.series.length === 1
+ ? { color: accentHex, opacity: 0.12 }
+ : undefined,
+ };
+ }),
};
}
@@ -152,6 +178,10 @@ watch(
() => props.unit,
() => chart?.setOption(buildOption()),
);
+watch(
+ () => props.accent,
+ () => chart?.setOption(buildOption(), { replaceMerge: ['series'] }),
+);
</script>
<template>
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index 0ec6164..b925386 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -31,6 +31,7 @@ import { useRoute } from 'vue-router';
import type { LayerDef } from '@skywalking-horizon-ui/api-client';
import TimeChart from '@/components/charts/TimeChart.vue';
import TopList from '@/components/charts/TopList.vue';
+import { colorForMetric } from '@/composables/metricColor';
import { useLayerDashboard, useLayerDashboardConfig } from
'@/composables/useLayerDashboard';
import { useLayerLanding } from '@/composables/useLayerLanding';
import { useLayers } from '@/composables/useLayers';
@@ -95,6 +96,34 @@ function gridStyle(w: { span?: number; rowSpan?: number; w?:
number; h?: number
};
}
+/**
+ * Resolve a widget's primary metric color from its title / id / first
+ * expression. Same color scheme as the layer-header KPI strip so
+ * Apdex shows purple, Traffic orange, p99 yellow, err red across both
+ * surfaces — the operator builds one mental color map.
+ */
+function widgetColor(w: { id?: string; title?: string; expressions?: string[]
}): string {
+ // Try a few sources in priority order; the colorForMetric helper
+ // pattern-matches on the metric key (cpm / sla / apdex / err /
+ // p50/p75/p95/p99 / etc.) — the title or id usually contains one.
+ const candidates: string[] = [];
+ if (w.id) candidates.push(w.id);
+ if (w.title) candidates.push(w.title);
+ if (w.expressions?.[0]) candidates.push(w.expressions[0]);
+ // Lower-case + flatten so patterns like 'service_cpm' / 'Traffic'
+ // both hit the right band.
+ for (const c of candidates) {
+ const c2 = c.toLowerCase();
+ if (/(^|[^a-z])cpm([^a-z]|$)/.test(c2) || c2.includes('traffic') ||
c2.includes('rpm')) return 'var(--sw-accent)';
+ if (c2.includes('apdex')) return 'var(--sw-purple)';
+ if (c2.includes('sla') || c2.includes('success')) return
'var(--sw-purple)';
+ if (/p\d{2,3}/.test(c2) || c2.includes('percentile') ||
c2.includes('resp_time') || c2.includes('response time') ||
c2.includes('latency')) return 'var(--sw-warn)';
+ if (c2.includes('err') || c2.includes('error') || c2.includes('failure'))
return 'var(--sw-err)';
+ }
+ // Fall back to the metric catalog helper.
+ return colorForMetric(w.id || w.title || w.expressions?.[0] || '');
+}
+
/**
* Evaluate a widget's `visibleWhen` predicate.
* - `<metric_name> has value` → the widget's result has a non-null
@@ -149,7 +178,7 @@ function isVisible(
v-for="w in widgets.filter((wi) => isVisible(wi,
resultsById.get(wi.id)))"
:key="w.id"
class="widget sw-card"
- :style="gridStyle(w)"
+ :style="{ ...gridStyle(w), '--widget-accent': widgetColor(w) }"
>
<div class="w-head" :title="w.tip">
<h4>{{ w.title }}</h4>
@@ -172,7 +201,8 @@ function isVisible(
v-if="resultsById.get(w.id)?.series?.length"
:series="resultsById.get(w.id)!.series!"
:unit="w.unit"
- :height="(w.rowSpan ?? 1) * 160 - 60"
+ :height="(w.rowSpan ?? 1) * 110 - 50"
+ :accent="widgetColor(w)"
/>
<span v-else class="muted">no data</span>
</template>
@@ -181,6 +211,7 @@ function isVisible(
v-if="resultsById.get(w.id)?.topList?.length"
:items="resultsById.get(w.id)!.topList!"
:unit="w.unit"
+ :color="widgetColor(w)"
/>
<span v-else class="muted">no data</span>
</template>
@@ -238,13 +269,13 @@ function isVisible(
.grid {
/* 12-col flow grid with fixed row height. `grid-auto-flow: dense`
* back-fills gaps so a span-12 widget after several span-4s doesn't
- * leave a hole. Widget heights are deliberately uniform — operators
- * vary span (width) more than rowSpan. */
+ * leave a hole. Row height tuned smaller so 2-row line widgets fit
+ * comfortably without dwarfing the rest of the page. */
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
- grid-auto-rows: 180px;
+ grid-auto-rows: 120px;
grid-auto-flow: row dense;
- gap: 12px;
+ gap: 10px;
}
.widget {
display: flex;
@@ -257,14 +288,17 @@ function isVisible(
align-items: baseline;
justify-content: space-between;
gap: 8px;
- padding: 8px 12px;
+ padding: 7px 12px;
border-bottom: 1px solid var(--sw-line);
+ /* Subtle left-edge accent tinted to the widget's primary metric
+ * color — ties each card to the matching KPI in the layer header. */
+ border-left: 3px solid var(--widget-accent, var(--sw-accent));
}
.w-head h4 {
margin: 0;
font-size: 11.5px;
font-weight: 600;
- color: var(--sw-fg-0);
+ color: var(--widget-accent, var(--sw-fg-0));
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;