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 ce3d758 dashboards: per-scope widget sets, flow grid, full admin edit
UI
ce3d758 is described below
commit ce3d7580fbff1bef35b190d7c1d7267bc4232af4
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 21:01:18 2026 +0800
dashboards: per-scope widget sets, flow grid, full admin edit UI
Widget schema gains span/rowSpan/visibleWhen — operators describe a
widget's width in a 12-col grid and the view lays it out via
grid-auto-flow: dense with a uniform 180px row height. Legacy x/y/w/h
still parsed and auto-converted (w/2 → span, h/8 → rowSpan) so older
templates render without migration. visibleWhen supports
'<metric> has value' predicates so widgets like JVM CPU only render
when the instance reports the metric; '#entity.<key>' predicate is
reserved for the upcoming entity-attribute feed.
Layer templates carry a per-scope dashboards bundle now:
dashboards: {
service: [...], // primary landing
instance: [...], // drill into a single instance
endpoint: [...], // drill into a single endpoint
trace: [...], // trace explorer scope
profiling: [...] // flame graphs scope
}
Legacy flat 'widgets' migrated to dashboards.service at load time.
General template fully populated with span-based widgets across
service / instance / endpoint scopes (instance includes JVM cards
guarded by visibleWhen).
Router gets per-scope routes: /service /instance /endpoint /trace
/profiling all hit the same LayerDashboardsView, which infers scope
from the URL and asks the BFF for the right widget set. Sidebar
children point at the new singular routes; legacy /instances /
/endpoints / /traces / /dashboards redirect.
Admin → Layer dashboards is now a full editor:
- Scope tabs (service / instance / endpoint / trace / profiling)
- Per-widget inline editing: id / title / type / unit / span /
rowSpan / visibleWhen / MQE expressions (one per line)
- Add Widget + Delete + Move up/down
- Save / Reset header buttons; dirty state drives enablement
- Save writes the JSON file back via POST /api/admin/layer-templates/:key
and reloads the BFF cache
BFF: new GET /api/layer/:key/dashboard/config?scope=... param;
POST /api/layer/:key/dashboard body gains scope; new
POST /api/admin/layer-templates/:key writes the JSON via the loader's
writeLayerTemplate helper.
---
apps/bff/src/dashboard/routes.ts | 125 ++++-
apps/bff/src/layers/config/general.json | 235 +++++---
apps/bff/src/layers/loader.ts | 58 +-
apps/ui/src/api/client.ts | 14 +-
apps/ui/src/components/shell/AppSidebar.vue | 12 +-
apps/ui/src/composables/useLayerDashboard.ts | 19 +-
apps/ui/src/router/index.ts | 40 +-
apps/ui/src/views/admin/LayerDashboardsAdmin.vue | 655 +++++++++++++++--------
apps/ui/src/views/layer/LayerDashboardsView.vue | 83 ++-
packages/api-client/src/dashboard.ts | 45 +-
packages/api-client/src/index.ts | 1 +
11 files changed, 945 insertions(+), 342 deletions(-)
diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index c9c120c..595079b 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -43,7 +43,13 @@ import type { ConfigSource } from '../config/loader.js';
import type { SessionStore } from '../auth/sessions.js';
import { requireAuth } from '../auth/middleware.js';
import { graphqlPost } from '../oap/graphql-client.js';
-import { allLayerTemplates } from '../layers/loader.js';
+import {
+ allLayerTemplates,
+ getLayerTemplate,
+ widgetsForScope,
+ writeLayerTemplate,
+ type LayerTemplate,
+} from '../layers/loader.js';
import { defaultWidgetsFor } from './defaults.js';
export interface DashboardRouteDeps {
@@ -59,14 +65,20 @@ const widgetSchema = z.object({
type: z.enum(['card', 'line']),
expressions: z.array(z.string().min(1)).min(1).max(8),
unit: z.string().optional(),
- x: z.number().int().min(0),
- y: z.number().int().min(0),
- w: z.number().int().positive(),
- h: z.number().int().positive(),
+ span: z.number().int().min(1).max(12).optional(),
+ rowSpan: z.number().int().min(1).max(64).optional(),
+ visibleWhen: z.string().optional(),
+ // Legacy x/y/w/h kept optional for back-compat.
+ x: z.number().int().min(0).optional(),
+ y: z.number().int().min(0).optional(),
+ w: z.number().int().positive().optional(),
+ h: z.number().int().positive().optional(),
});
+const scopeSchema = z.enum(['service', 'instance', 'endpoint', 'trace',
'profiling']);
const bodySchema = z.object({
service: z.string().optional(),
widgets: z.array(widgetSchema).max(40).optional(),
+ scope: scopeSchema.optional(),
});
interface MqeValuesShape {
@@ -153,7 +165,11 @@ export function registerDashboardRoute(app:
FastifyInstance, deps: DashboardRout
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', detail:
parsed.error.flatten() });
}
- const widgets: DashboardWidget[] = parsed.data.widgets ??
defaultWidgetsFor(layerKey);
+ const scope = parsed.data.scope ?? 'service';
+ const tpl = getLayerTemplate(layerKey);
+ const widgets: DashboardWidget[] =
+ parsed.data.widgets ??
+ (tpl ? widgetsForScope(tpl, scope) : defaultWidgetsFor(layerKey));
let serviceName = parsed.data.service ?? '';
let normal = true;
const cfgCurrent = deps.config.current;
@@ -256,6 +272,7 @@ export function registerDashboardRoute(app:
FastifyInstance, deps: DashboardRout
// GET version returns the default widget config without running queries —
// useful for the SPA to know what to render before invoking POST.
+ // Accepts ?scope=service|instance|endpoint|trace|profiling.
app.get(
'/api/layer/:key/dashboard/config',
{ preHandler: auth },
@@ -265,7 +282,12 @@ export function registerDashboardRoute(app:
FastifyInstance, deps: DashboardRout
if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
return reply.code(400).send({ error: 'invalid_layer_key' });
}
- return reply.send({ layer: layerKey, widgets:
defaultWidgetsFor(layerKey) });
+ const q = req.query as { scope?: string };
+ const scopeParsed = q.scope ? scopeSchema.safeParse(q.scope) : null;
+ const scope = scopeParsed?.success ? scopeParsed.data : 'service';
+ const tpl = getLayerTemplate(layerKey);
+ const widgets = tpl ? widgetsForScope(tpl, scope) :
defaultWidgetsFor(layerKey);
+ return reply.send({ layer: layerKey, scope, widgets });
},
);
@@ -275,4 +297,93 @@ export function registerDashboardRoute(app:
FastifyInstance, deps: DashboardRout
app.get('/api/admin/layer-templates', { preHandler: auth }, async (_req,
reply) => {
return reply.send({ templates: allLayerTemplates() });
});
+
+ // Admin: persist an operator-edited template back to its JSON file.
+ // Body is the whole template; the BFF rewrites the file and
+ // invalidates its in-memory cache so subsequent reads see the new
+ // shape immediately.
+ const adminTemplateSchema = z.object({
+ key: z.string().regex(/^[A-Z][A-Z0-9_]*$/),
+ alias: z.string().optional(),
+ color: z.string().optional(),
+ documentLink: z.string().optional(),
+ slots: z
+ .object({
+ services: z.string().optional(),
+ instances: z.string().optional(),
+ endpoints: z.string().optional(),
+ endpointDependency: z.string().optional(),
+ })
+ .strict(),
+ components: z
+ .object({
+ service: z.boolean().optional(),
+ instances: z.boolean().optional(),
+ endpoints: z.boolean().optional(),
+ endpointDependency: z.boolean().optional(),
+ topology: z.boolean().optional(),
+ traces: z.boolean().optional(),
+ logs: z.boolean().optional(),
+ profiling: z.boolean().optional(),
+ })
+ .strict(),
+ metrics: z
+ .object({
+ orderBy: z.string().optional(),
+ throughput: z.string().optional(),
+ spark: z.string().optional(),
+ columns: z
+ .array(
+ z.object({
+ metric: z.string().min(1),
+ label: z.string(),
+ unit: z.string().optional(),
+ mqe: z.string().optional(),
+ aggregation: z.enum(['sum', 'avg']).optional(),
+ scale: z.number().finite().optional(),
+ precision: z.number().int().min(0).max(6).optional(),
+ }),
+ )
+ .max(5)
+ .optional(),
+ })
+ .strict(),
+ dashboards: z
+ .object({
+ service: z.array(widgetSchema).max(40).optional(),
+ instance: z.array(widgetSchema).max(40).optional(),
+ endpoint: z.array(widgetSchema).max(40).optional(),
+ trace: z.array(widgetSchema).max(40).optional(),
+ profiling: z.array(widgetSchema).max(40).optional(),
+ })
+ .strict()
+ .optional(),
+ widgets: z.array(widgetSchema).max(40).optional(),
+ });
+
+ app.post(
+ '/api/admin/layer-templates/:key',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const params = req.params as { key: string };
+ const layerKey = params.key.toUpperCase();
+ const parsed = adminTemplateSchema.safeParse(req.body);
+ if (!parsed.success) {
+ return reply.code(400).send({ error: 'invalid_template', detail:
parsed.error.flatten() });
+ }
+ if (parsed.data.key.toUpperCase() !== layerKey) {
+ return reply.code(400).send({ error: 'key_mismatch', detail: 'URL key
does not match body key' });
+ }
+ try {
+ writeLayerTemplate(parsed.data as LayerTemplate);
+ } catch (err) {
+ return reply.code(500).send({
+ error: 'write_failed',
+ detail: err instanceof Error ? err.message : String(err),
+ });
+ }
+ const refreshed = getLayerTemplate(layerKey);
+ return reply.send({ template: refreshed });
+ },
+ );
}
diff --git a/apps/bff/src/layers/config/general.json
b/apps/bff/src/layers/config/general.json
index f932be7..7d45f66 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -30,72 +30,171 @@
{ "metric": "err", "label": "err", "unit": "%", "mqe": "100 -
service_sla/100", "aggregation": "avg" }
]
},
- "widgets": [
- {
- "id": "apdex",
- "title": "Apdex",
- "tip": "User satisfaction score on a 0–1 scale. service_apdex is
integer-times-10000 on the server side; the expression scales it.",
- "type": "card",
- "expressions": ["avg(service_apdex)/10000"],
- "x": 0, "y": 0, "w": 8, "h": 5
- },
- {
- "id": "sla",
- "title": "Success Rate",
- "tip": "Percentage of successful requests.",
- "type": "card",
- "unit": "%",
- "expressions": ["avg(service_sla)/100"],
- "x": 8, "y": 0, "w": 8, "h": 5
- },
- {
- "id": "traffic",
- "title": "Traffic",
- "tip": "Requests per minute. For HTTP / gRPC / RPC services this
reflects request throughput.",
- "type": "card",
- "unit": "rpm",
- "expressions": ["avg(service_cpm)"],
- "x": 16, "y": 0, "w": 8, "h": 5
- },
- {
- "id": "resp_time",
- "title": "Avg Response Time",
- "tip": "Mean latency across all calls in the window.",
- "type": "line",
- "unit": "ms",
- "expressions": ["service_resp_time"],
- "x": 0, "y": 5, "w": 12, "h": 14
- },
- {
- "id": "percentile",
- "title": "Response Time Percentile",
- "tip": "Latency at p50 / p75 / p90 / p95 / p99 — useful for tail
behavior.",
- "type": "line",
- "unit": "ms",
- "expressions": [
- "service_percentile{p='50'}",
- "service_percentile{p='75'}",
- "service_percentile{p='90'}",
- "service_percentile{p='95'}",
- "service_percentile{p='99'}"
- ],
- "x": 12, "y": 5, "w": 12, "h": 14
- },
- {
- "id": "traffic_line",
- "title": "Traffic",
- "type": "line",
- "unit": "rpm",
- "expressions": ["service_cpm"],
- "x": 0, "y": 19, "w": 12, "h": 12
- },
- {
- "id": "sla_line",
- "title": "Success Rate",
- "type": "line",
- "unit": "%",
- "expressions": ["service_sla/100"],
- "x": 12, "y": 19, "w": 12, "h": 12
- }
- ]
+ "dashboards": {
+ "service": [
+ {
+ "id": "apdex",
+ "title": "Apdex",
+ "tip": "User satisfaction score on a 0–1 scale. service_apdex is
integer-times-10000 server-side.",
+ "type": "card",
+ "expressions": ["avg(service_apdex)/10000"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "sla",
+ "title": "Success Rate",
+ "type": "card",
+ "unit": "%",
+ "expressions": ["avg(service_sla)/100"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "traffic",
+ "title": "Traffic",
+ "type": "card",
+ "unit": "rpm",
+ "expressions": ["avg(service_cpm)"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "resp_time",
+ "title": "Avg Response Time",
+ "type": "line",
+ "unit": "ms",
+ "expressions": ["service_resp_time"],
+ "span": 6, "rowSpan": 2
+ },
+ {
+ "id": "percentile",
+ "title": "Response Time Percentile",
+ "tip": "p50 / p75 / p90 / p95 / p99 — useful for tail behavior.",
+ "type": "line",
+ "unit": "ms",
+ "expressions": [
+ "service_percentile{p='50'}",
+ "service_percentile{p='75'}",
+ "service_percentile{p='90'}",
+ "service_percentile{p='95'}",
+ "service_percentile{p='99'}"
+ ],
+ "span": 6, "rowSpan": 2
+ },
+ {
+ "id": "traffic_line",
+ "title": "Traffic",
+ "type": "line",
+ "unit": "rpm",
+ "expressions": ["service_cpm"],
+ "span": 6, "rowSpan": 2
+ },
+ {
+ "id": "sla_line",
+ "title": "Success Rate",
+ "type": "line",
+ "unit": "%",
+ "expressions": ["service_sla/100"],
+ "span": 6, "rowSpan": 2
+ }
+ ],
+ "instance": [
+ {
+ "id": "instance_cpm",
+ "title": "Instance Traffic",
+ "type": "card",
+ "unit": "rpm",
+ "expressions": ["avg(service_instance_cpm)"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "instance_resp",
+ "title": "Instance Avg Response Time",
+ "type": "card",
+ "unit": "ms",
+ "expressions": ["avg(service_instance_resp_time)"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "instance_sla",
+ "title": "Instance Success Rate",
+ "type": "card",
+ "unit": "%",
+ "expressions": ["avg(service_instance_sla)/100"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "instance_cpm_line",
+ "title": "Traffic",
+ "type": "line",
+ "unit": "rpm",
+ "expressions": ["service_instance_cpm"],
+ "span": 6, "rowSpan": 2
+ },
+ {
+ "id": "instance_resp_line",
+ "title": "Response Time",
+ "type": "line",
+ "unit": "ms",
+ "expressions": ["service_instance_resp_time"],
+ "span": 6, "rowSpan": 2
+ },
+ {
+ "id": "jvm_cpu",
+ "title": "JVM CPU",
+ "tip": "Renders only when the instance reports JVM metrics.",
+ "type": "line",
+ "unit": "%",
+ "expressions": ["instance_jvm_cpu"],
+ "visibleWhen": "instance_jvm_cpu has value",
+ "span": 6, "rowSpan": 2
+ },
+ {
+ "id": "jvm_heap",
+ "title": "JVM Heap (bytes)",
+ "type": "line",
+ "expressions": ["instance_jvm_memory{name='heap'}"],
+ "visibleWhen": "instance_jvm_memory has value",
+ "span": 6, "rowSpan": 2
+ }
+ ],
+ "endpoint": [
+ {
+ "id": "endpoint_cpm",
+ "title": "Endpoint Traffic",
+ "type": "card",
+ "unit": "rpm",
+ "expressions": ["avg(endpoint_cpm)"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "endpoint_resp",
+ "title": "Avg Response Time",
+ "type": "card",
+ "unit": "ms",
+ "expressions": ["avg(endpoint_resp_time)"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "endpoint_sla",
+ "title": "Success Rate",
+ "type": "card",
+ "unit": "%",
+ "expressions": ["avg(endpoint_sla)/100"],
+ "span": 4, "rowSpan": 1
+ },
+ {
+ "id": "endpoint_percentile",
+ "title": "Endpoint Response Time Percentile",
+ "type": "line",
+ "unit": "ms",
+ "expressions": [
+ "endpoint_percentile{p='50'}",
+ "endpoint_percentile{p='95'}",
+ "endpoint_percentile{p='99'}"
+ ],
+ "span": 12, "rowSpan": 2
+ }
+ ],
+ "trace": [],
+ "profiling": []
+ }
}
diff --git a/apps/bff/src/layers/loader.ts b/apps/bff/src/layers/loader.ts
index 6627752..e0a1b4f 100644
--- a/apps/bff/src/layers/loader.ts
+++ b/apps/bff/src/layers/loader.ts
@@ -32,10 +32,10 @@
* widget catalog)
*/
-import { readdirSync, readFileSync } from 'node:fs';
+import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
-import type { DashboardWidget } from '@skywalking-horizon-ui/api-client';
+import type { DashboardScope, DashboardWidget } from
'@skywalking-horizon-ui/api-client';
export interface LayerComponentFlags {
service?: boolean;
@@ -75,6 +75,21 @@ export interface LayerMetricsConfig {
columns?: LayerMetricColumn[];
}
+/**
+ * Per-scope dashboards bundled with a layer template. Each scope is an
+ * independent widget set; the SPA picks one based on the active route
+ * (`/layer/:key/service`, `/instance`, `/endpoint`, `/trace`,
+ * `/profiling`). Legacy `widgets` (flat array) is migrated to
+ * `dashboards.service` at load time.
+ */
+export interface LayerDashboards {
+ service?: DashboardWidget[];
+ instance?: DashboardWidget[];
+ endpoint?: DashboardWidget[];
+ trace?: DashboardWidget[];
+ profiling?: DashboardWidget[];
+}
+
export interface LayerTemplate {
/** UPPER_SNAKE enum key (matches OAP). */
key: string;
@@ -87,7 +102,10 @@ export interface LayerTemplate {
slots: LayerSlotsConfig;
components: LayerComponentFlags;
metrics: LayerMetricsConfig;
- widgets: DashboardWidget[];
+ /** Per-scope widget sets. `service` is the layer's primary landing. */
+ dashboards?: LayerDashboards;
+ /** Legacy single widget list — treated as `dashboards.service`. */
+ widgets?: DashboardWidget[];
}
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -106,19 +124,49 @@ function load(): Map<string, LayerTemplate> {
} catch (err) {
throw new Error(`failed to parse layer config ${file}: ${err instanceof
Error ? err.message : err}`);
}
- // File name should match the layer key (case-insensitive) so the
- // operator can rename / locate the right file without grepping JSON.
const expected = basename(file, '.json').toUpperCase();
if (parsed.key && parsed.key.toUpperCase() !== expected) {
throw new Error(
`layer config ${file}: file basename does not match \`key\`
(${parsed.key})`,
);
}
+ // Migrate legacy `widgets` (flat array) → `dashboards.service` so
+ // the rest of the codebase only needs to know about the new shape.
+ if (parsed.widgets && (!parsed.dashboards || !parsed.dashboards.service)) {
+ parsed.dashboards = { ...parsed.dashboards, service: parsed.widgets };
+ }
out.set(parsed.key.toUpperCase(), parsed);
}
return out;
}
+/** Resolve the widget set for a given scope, falling back to service. */
+export function widgetsForScope(
+ template: LayerTemplate,
+ scope: DashboardScope,
+): DashboardWidget[] {
+ const d = template.dashboards;
+ if (!d) return template.widgets ?? [];
+ return d[scope] ?? d.service ?? template.widgets ?? [];
+}
+
+/**
+ * Persist an operator-edited template back to its JSON file. Validates
+ * the basic shape, sorts keys for stable diffs, then refreshes the
+ * in-memory cache so subsequent reads see the new state. Intentionally
+ * naive: no concurrency control, no schema migrations — operators on
+ * single-node BFF deployments, single admin user.
+ */
+export function writeLayerTemplate(template: LayerTemplate): void {
+ if (!template.key || !/^[A-Z][A-Z0-9_]*$/.test(template.key)) {
+ throw new Error('invalid template key (must be UPPER_SNAKE_CASE)');
+ }
+ const file = join(CONFIG_DIR, `${template.key.toLowerCase()}.json`);
+ const serialised = JSON.stringify(template, null, 2) + '\n';
+ writeFileSync(file, serialised, 'utf-8');
+ reloadLayerTemplates();
+}
+
/**
* Lookup a layer template by enum key (case-insensitive). Returns
* `null` when no template is defined for the layer — call sites should
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 652b38a..6247ba2 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -187,15 +187,16 @@ export class BffClient {
}
// ── dashboards (per-layer widget data) ───────────────────────────────
- dashboardConfig(layerKey: string): Promise<DashboardConfig> {
+ dashboardConfig(layerKey: string, scope?: string): Promise<DashboardConfig> {
+ const qs = scope ? `?scope=${encodeURIComponent(scope)}` : '';
return this.request<DashboardConfig>(
'GET',
- `/api/layer/${encodeURIComponent(layerKey)}/dashboard/config`,
+ `/api/layer/${encodeURIComponent(layerKey)}/dashboard/config${qs}`,
);
}
dashboard(
layerKey: string,
- body: { service?: string; widgets?: DashboardWidget[] } = {},
+ body: { service?: string; widgets?: DashboardWidget[]; scope?: string } =
{},
): Promise<DashboardResponse> {
return this.request<DashboardResponse>(
'POST',
@@ -203,6 +204,13 @@ export class BffClient {
body,
);
}
+ saveLayerTemplate(template: AdminLayerTemplate): Promise<{ template:
AdminLayerTemplate }> {
+ return this.request<{ template: AdminLayerTemplate }>(
+ 'POST',
+ `/api/admin/layer-templates/${encodeURIComponent(template.key)}`,
+ template,
+ );
+ }
/** Admin: list every loaded layer template (alias / components / widgets).
*/
adminLayerTemplates(): Promise<{ templates: AdminLayerTemplate[] }> {
diff --git a/apps/ui/src/components/shell/AppSidebar.vue
b/apps/ui/src/components/shell/AppSidebar.vue
index f5dff5b..40d7f16 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -224,17 +224,17 @@ const sections: NavSection[] = [
</RouterLink>
<RouterLink
v-if="L.slots.instances"
- :to="`/layer/${L.key}/instances`"
+ :to="`/layer/${L.key}/instance`"
class="sw-nav-item"
- :class="{ 'is-active': isActive(`/layer/${L.key}/instances`) }"
+ :class="{ 'is-active': isActive(`/layer/${L.key}/instance`) }"
>
<Icon name="prof" /><span>{{ L.slots.instances }}</span>
</RouterLink>
<RouterLink
v-if="L.slots.endpoints"
- :to="`/layer/${L.key}/endpoints`"
+ :to="`/layer/${L.key}/endpoint`"
class="sw-nav-item"
- :class="{ 'is-active': isActive(`/layer/${L.key}/endpoints`) }"
+ :class="{ 'is-active': isActive(`/layer/${L.key}/endpoint`) }"
>
<Icon name="ep" /><span>{{ L.slots.endpoints }}</span>
</RouterLink>
@@ -257,9 +257,9 @@ const sections: NavSection[] = [
</RouterLink>
<RouterLink
v-if="L.caps.traces"
- :to="`/layer/${L.key}/traces`"
+ :to="`/layer/${L.key}/trace`"
class="sw-nav-item"
- :class="{ 'is-active': isActive(`/layer/${L.key}/traces`) }"
+ :class="{ 'is-active': isActive(`/layer/${L.key}/trace`) }"
>
<Icon name="trace" /><span>Traces</span>
</RouterLink>
diff --git a/apps/ui/src/composables/useLayerDashboard.ts
b/apps/ui/src/composables/useLayerDashboard.ts
index d66f33b..703d52c 100644
--- a/apps/ui/src/composables/useLayerDashboard.ts
+++ b/apps/ui/src/composables/useLayerDashboard.ts
@@ -31,10 +31,10 @@ import { computed, type Ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { bffClient } from '@/api/client';
-export function useLayerDashboardConfig(layerKey: Ref<string>) {
+export function useLayerDashboardConfig(layerKey: Ref<string>, scope?:
Ref<string>) {
const q = useQuery({
- queryKey: ['dashboard-config', layerKey],
- queryFn: () => bffClient.dashboardConfig(layerKey.value),
+ queryKey: ['dashboard-config', layerKey, scope ?? computed(() =>
'service')],
+ queryFn: () => bffClient.dashboardConfig(layerKey.value, scope?.value),
enabled: computed(() => layerKey.value.length > 0),
staleTime: 5 * 60_000,
});
@@ -45,11 +45,18 @@ export function useLayerDashboardConfig(layerKey:
Ref<string>) {
};
}
-export function useLayerDashboard(layerKey: Ref<string>, service: Ref<string |
null>) {
+export function useLayerDashboard(
+ layerKey: Ref<string>,
+ service: Ref<string | null>,
+ scope?: Ref<string>,
+) {
const q = useQuery({
- queryKey: ['dashboard', layerKey, service],
+ queryKey: ['dashboard', layerKey, service, scope ?? computed(() =>
'service')],
queryFn: () =>
- bffClient.dashboard(layerKey.value, service.value ? { service:
service.value } : {}),
+ bffClient.dashboard(layerKey.value, {
+ ...(service.value ? { service: service.value } : {}),
+ ...(scope?.value ? { scope: scope.value } : {}),
+ }),
enabled: computed(() => layerKey.value.length > 0),
staleTime: 45_000,
refetchInterval: 60_000,
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 58fa910..f92be31 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -31,14 +31,14 @@ function layerRoute(): RouteRecordRaw {
// Per-layer sub-routes that still render generic placeholders until
// their phases land. The canonical landing is `/service` — that's
// the widget-grid view operators see when they click a layer.
+ // Tabs that still don't have a per-scope dashboard set. Topology +
+ // dependency + logs need their own page treatments (Phase 4 / 5);
+ // their JSON template `components.*` flag still gates the sidebar
+ // entry, this just keeps the URL routing legible.
const placeholderTabs: { path: string; label: string; phase: string }[] = [
- { path: 'instances', label: 'Instances', phase: 'Phase 2 / 3' },
- { path: 'endpoints', label: 'Endpoints', phase: 'Phase 2 / 3' },
{ path: 'topology', label: 'Topology', phase: 'Phase 4' },
{ path: 'dependency', label: 'API dependency', phase: 'Phase 4' },
- { path: 'traces', label: 'Traces', phase: 'Phase 5' },
{ path: 'logs', label: 'Logs', phase: 'Phase 5' },
- { path: 'profiling', label: 'Profiling', phase: 'Phase 8' },
];
return {
path: 'layer/:layerKey',
@@ -47,15 +47,18 @@ function layerRoute(): RouteRecordRaw {
// Bare /layer/:layerKey lands on the Service view — the per-layer
// widget grid driven by the dashboard config.
{ path: '', redirect: (to) => ({ path:
`/layer/${to.params.layerKey}/service` }) },
- // Canonical per-layer landing page.
+ // Per-scope dashboards. Same view component, scope inferred from
+ // the URL — widget set differs per scope via the JSON template's
+ // `dashboards.<scope>` array.
{ path: 'service', component: () =>
import('@/views/layer/LayerDashboardsView.vue') },
- // Legacy routes — redirect to /service so old bookmarks keep working.
+ { path: 'instance', component: () =>
import('@/views/layer/LayerDashboardsView.vue') },
+ { path: 'endpoint', component: () =>
import('@/views/layer/LayerDashboardsView.vue') },
+ { path: 'trace', component: () =>
import('@/views/layer/LayerDashboardsView.vue') },
+ { path: 'profiling', component: () =>
import('@/views/layer/LayerDashboardsView.vue') },
+ // Legacy routes redirect to /service.
{
path: 'services',
- redirect: (to) => ({
- path: `/layer/${to.params.layerKey}/service`,
- query: to.query,
- }),
+ redirect: (to) => ({ path: `/layer/${to.params.layerKey}/service`,
query: to.query }),
},
{
path: 'services/:serviceId',
@@ -66,10 +69,19 @@ function layerRoute(): RouteRecordRaw {
},
{
path: 'dashboards',
- redirect: (to) => ({
- path: `/layer/${to.params.layerKey}/service`,
- query: to.query,
- }),
+ redirect: (to) => ({ path: `/layer/${to.params.layerKey}/service`,
query: to.query }),
+ },
+ {
+ path: 'instances',
+ redirect: (to) => ({ path: `/layer/${to.params.layerKey}/instance`,
query: to.query }),
+ },
+ {
+ path: 'endpoints',
+ redirect: (to) => ({ path: `/layer/${to.params.layerKey}/endpoint`,
query: to.query }),
+ },
+ {
+ path: 'traces',
+ redirect: (to) => ({ path: `/layer/${to.params.layerKey}/trace`,
query: to.query }),
},
...placeholderTabs.map<RouteRecordRaw>((f) => ({
path: f.path,
diff --git a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
index f9265b8..16c40db 100644
--- a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
@@ -15,37 +15,158 @@
limitations under the License.
-->
<!--
- Admin / Layer dashboards setup. Lists every layer template the BFF
- loaded from JSON and shows its current configuration: alias, enabled
- components, landing card metrics, dashboard widget set. Editing comes
- in the next iteration — this commit gets the view live so operators
- can see what's configured per layer.
+ Admin / Layer dashboards. List every loaded layer template, pick one
+ on the left, edit its per-scope widget set on the right. Saves write
+ the JSON file back via POST /api/admin/layer-templates/:key so the
+ BFF refreshes its in-memory cache.
+
+ Widget editor presents the new span-based fields (12-col flow
+ layout): operator picks a column span, optional row span, MQE
+ expressions, type, title, unit, and an optional visibility predicate.
+ Legacy x/y/w/h are NOT shown — they're kept on the wire for
+ back-compat with older JSONs but operators don't edit them.
-->
<script setup lang="ts">
-import { computed, ref, onMounted } from 'vue';
+import { computed, reactive, ref, onMounted, watch } from 'vue';
import type { AdminLayerTemplate } from '@/api/client';
+import type { DashboardScope, DashboardWidget } from
'@skywalking-horizon-ui/api-client';
import { bffClient } from '@/api/client';
+const SCOPES: DashboardScope[] = ['service', 'instance', 'endpoint', 'trace',
'profiling'];
+
const templates = ref<AdminLayerTemplate[]>([]);
const isLoading = ref(true);
const error = ref<string | null>(null);
const selectedKey = ref<string>('');
+const activeScope = ref<DashboardScope>('service');
+const isSaving = ref(false);
+const saveMsg = ref<string | null>(null);
-onMounted(async () => {
+/** Working copy — reactively edited. Diffs against `templates` to drive
+ * the Save / Reset state. */
+const draft = reactive<{ template: AdminLayerTemplate | null }>({ template:
null });
+
+async function loadAll(): Promise<void> {
+ isLoading.value = true;
+ error.value = null;
try {
const res = await bffClient.adminLayerTemplates();
templates.value = res.templates;
- if (res.templates.length > 0) selectedKey.value = res.templates[0].key;
+ if (res.templates.length > 0 && !selectedKey.value) {
+ selectedKey.value = res.templates[0].key;
+ }
+ syncDraft();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
isLoading.value = false;
}
+}
+
+function syncDraft(): void {
+ const tpl = templates.value.find((t) => t.key === selectedKey.value);
+ draft.template = tpl ? JSON.parse(JSON.stringify(tpl)) : null;
+ saveMsg.value = null;
+}
+
+watch(selectedKey, syncDraft);
+onMounted(loadAll);
+
+const dirty = computed(() => {
+ const original = templates.value.find((t) => t.key === selectedKey.value);
+ if (!original || !draft.template) return false;
+ return JSON.stringify(original) !== JSON.stringify(draft.template);
});
-const selected = computed<AdminLayerTemplate | null>(
- () => templates.value.find((t) => t.key === selectedKey.value) ?? null,
-);
+function widgetsFor(scope: DashboardScope): DashboardWidget[] {
+ const tpl = draft.template;
+ if (!tpl) return [];
+ // Read from `dashboards.<scope>`, falling back to legacy `widgets`
+ // for the service scope so the existing JSONs keep their content
+ // until we explicitly migrate them.
+ const d = (tpl as unknown as { dashboards?: Record<string,
DashboardWidget[]> }).dashboards;
+ if (d?.[scope]) return d[scope];
+ if (scope === 'service' && tpl.widgets) return tpl.widgets;
+ return [];
+}
+
+function setWidgetsFor(scope: DashboardScope, widgets: DashboardWidget[]):
void {
+ const tpl = draft.template;
+ if (!tpl) return;
+ const dashboards =
+ (tpl as unknown as { dashboards?: Record<string, DashboardWidget[]>
}).dashboards ?? {};
+ dashboards[scope] = widgets;
+ (tpl as unknown as { dashboards?: Record<string, DashboardWidget[]>
}).dashboards = dashboards;
+ // Drop the legacy `widgets` once we've split — keeps the JSON clean.
+ if (scope === 'service' && tpl.widgets) {
+ (tpl as unknown as { widgets?: DashboardWidget[] }).widgets = undefined;
+ }
+}
+
+function addWidget(): void {
+ const widgets = [...widgetsFor(activeScope.value)];
+ const idx = widgets.length;
+ widgets.push({
+ id: `widget_${idx + 1}`,
+ title: `Widget ${idx + 1}`,
+ type: 'card',
+ expressions: [''],
+ span: 4,
+ rowSpan: 1,
+ });
+ setWidgetsFor(activeScope.value, widgets);
+}
+
+function deleteWidget(i: number): void {
+ const widgets = [...widgetsFor(activeScope.value)];
+ widgets.splice(i, 1);
+ setWidgetsFor(activeScope.value, widgets);
+}
+
+function moveWidget(i: number, dir: -1 | 1): void {
+ const widgets = [...widgetsFor(activeScope.value)];
+ const j = i + dir;
+ if (j < 0 || j >= widgets.length) return;
+ [widgets[i], widgets[j]] = [widgets[j], widgets[i]];
+ setWidgetsFor(activeScope.value, widgets);
+}
+
+function expressionsToText(arr: string[]): string {
+ return arr.join('\n');
+}
+function textToExpressions(s: string): string[] {
+ return s
+ .split('\n')
+ .map((x) => x.trim())
+ .filter((x) => x.length > 0);
+}
+
+async function save(): Promise<void> {
+ if (!draft.template || isSaving.value) return;
+ isSaving.value = true;
+ saveMsg.value = null;
+ try {
+ const res = await bffClient.saveLayerTemplate(draft.template);
+ // Splice the returned template back into the list so subsequent
+ // dirty diffs are against the persisted state.
+ const idx = templates.value.findIndex((t) => t.key === selectedKey.value);
+ if (idx >= 0 && res.template) templates.value[idx] = res.template;
+ syncDraft();
+ saveMsg.value = 'Saved.';
+ setTimeout(() => (saveMsg.value = null), 2400);
+ } catch (err) {
+ saveMsg.value = err instanceof Error ? err.message : String(err);
+ } finally {
+ isSaving.value = false;
+ }
+}
+
+function reset(): void {
+ syncDraft();
+}
+
+const selectedTpl = computed(() => draft.template);
+const currentWidgets = computed(() => widgetsFor(activeScope.value));
function componentFlags(t: AdminLayerTemplate): string[] {
const c = t.components;
@@ -69,9 +190,9 @@ function componentFlags(t: AdminLayerTemplate): string[] {
<div class="kicker">Admin</div>
<h1>Layer dashboards</h1>
<p class="lede">
- Each layer ships with a JSON template defining its alias, enabled
components,
- landing card metrics, and dashboard widgets. This view shows the
current
- template per layer. Inline editing + operator overrides are next.
+ Each layer ships with a JSON template (alias, components, metric
columns, widgets).
+ Pick a layer on the left, switch scopes (service / instance /
endpoint / trace /
+ profiling), edit widgets in place, and save back to the JSON.
</p>
</div>
</header>
@@ -81,7 +202,6 @@ function componentFlags(t: AdminLayerTemplate): string[] {
<div v-else-if="templates.length === 0" class="empty">No layer templates
loaded.</div>
<div v-else class="grid">
- <!-- Layer picker (left) -->
<aside class="sw-card layer-list">
<div class="list-head">
<h4>Layers</h4>
@@ -96,111 +216,147 @@ function componentFlags(t: AdminLayerTemplate): string[]
{
>
<span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)'
}" />
<span class="name">{{ t.alias || t.key }}</span>
- <span class="badge">{{ t.widgets.length }}</span>
</button>
</aside>
- <!-- Template detail (right) -->
- <main v-if="selected" class="detail">
- <section class="sw-card">
- <div class="card-head">
- <h4>Identity</h4>
+ <main v-if="selectedTpl" class="detail">
+ <!-- Identity strip + save controls -->
+ <section class="sw-card identity-card">
+ <div class="identity-row">
+ <span class="dot inline" :style="{ background: selectedTpl.color
|| 'var(--sw-fg-3)' }" />
+ <div>
+ <h2>{{ selectedTpl.alias || selectedTpl.key }}</h2>
+ <div class="meta">
+ <code>{{ selectedTpl.key }}</code>
+ <span v-for="c in componentFlags(selectedTpl)" :key="c"
class="chip on">{{ c }}</span>
+ </div>
+ </div>
+ <div class="actions">
+ <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
+ <button
+ class="sw-btn"
+ type="button"
+ :disabled="!dirty || isSaving"
+ @click="reset"
+ >
+ Reset
+ </button>
+ <button
+ class="sw-btn is-primary"
+ type="button"
+ :disabled="!dirty || isSaving"
+ @click="save"
+ >
+ {{ isSaving ? 'Saving…' : 'Save' }}
+ </button>
+ </div>
</div>
- <table class="kv">
- <tbody>
- <tr><th>Key</th><td class="mono">{{ selected.key }}</td></tr>
- <tr><th>Alias</th><td>{{ selected.alias || '—' }}</td></tr>
- <tr><th>Color</th><td>
- <span class="dot inline" :style="{ background: selected.color
|| 'var(--sw-fg-3)' }" />
- <code>{{ selected.color || '—' }}</code>
- </td></tr>
- <tr v-if="selected.documentLink"><th>Docs</th>
- <td><a :href="selected.documentLink" target="_blank"
rel="noopener noreferrer">{{ selected.documentLink }} ↗</a></td>
- </tr>
- </tbody>
- </table>
</section>
- <section class="sw-card">
- <div class="card-head"><h4>Components enabled</h4></div>
- <div class="chips">
- <span v-for="c in componentFlags(selected)" :key="c" class="chip
on">{{ c }}</span>
- <span v-if="componentFlags(selected).length === 0" class="chip
off">none</span>
- </div>
- </section>
+ <!-- Scope tabs -->
+ <nav class="scope-tabs sw-card">
+ <button
+ v-for="s in SCOPES"
+ :key="s"
+ class="scope-tab"
+ :class="{ on: activeScope === s }"
+ type="button"
+ @click="activeScope = s"
+ >
+ {{ s }}
+ <span class="count">{{ widgetsFor(s).length }}</span>
+ </button>
+ </nav>
- <section class="sw-card">
+ <!-- Widget editor -->
+ <section class="sw-card widgets-card">
<div class="card-head">
- <h4>Slots</h4>
- <span class="sub">term aliases for service / instance / endpoint
scopes</span>
+ <h4>{{ activeScope }} widgets</h4>
+ <span class="sub">12-col flow grid · uniform 180px row height ·
drag-free</span>
+ <button class="sw-btn add" type="button" @click="addWidget">+ Add
widget</button>
</div>
- <table class="kv">
- <tbody>
- <tr><th>Services</th><td>{{ selected.slots.services || '—'
}}</td></tr>
- <tr><th>Instances</th><td>{{ selected.slots.instances || '—'
}}</td></tr>
- <tr><th>Endpoints</th><td>{{ selected.slots.endpoints || '—'
}}</td></tr>
- <tr v-if="selected.slots.endpointDependency">
- <th>Endpoint dependency</th><td>{{
selected.slots.endpointDependency }}</td>
- </tr>
- </tbody>
- </table>
- </section>
- <section class="sw-card">
- <div class="card-head">
- <h4>Landing card metrics</h4>
- <span class="sub">columns shown on the Overview KPI tile +
per-layer header</span>
- </div>
- <table v-if="selected.metrics.columns?.length" class="sw-table">
- <thead>
- <tr>
-
<th>metric</th><th>label</th><th>unit</th><th>aggregation</th><th>mqe</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="c in selected.metrics.columns" :key="c.metric">
- <td class="mono">{{ c.metric }}</td>
- <td>{{ c.label }}</td>
- <td>{{ c.unit || '—' }}</td>
- <td><span class="tag">{{ c.aggregation || 'avg' }}</span></td>
- <td class="mono">{{ c.mqe || '(catalog default)' }}</td>
- </tr>
- </tbody>
- </table>
- <p v-else class="empty">No columns defined.</p>
- <div class="extras">
- <span><strong>orderBy:</strong> <code>{{ selected.metrics.orderBy
|| '—' }}</code></span>
- <span><strong>throughput:</strong> <code>{{
selected.metrics.throughput || '—' }}</code></span>
- <span><strong>spark:</strong> <code>{{ selected.metrics.spark ||
'—' }}</code></span>
+ <div v-if="currentWidgets.length === 0" class="empty inset">
+ No widgets defined for this scope. Click "Add widget" to start.
</div>
- </section>
- <section class="sw-card">
- <div class="card-head">
- <h4>Dashboard widgets</h4>
- <span class="sub">{{ selected.widgets.length }} widget{{
selected.widgets.length === 1 ? '' : 's' }} · grid is 24-col</span>
- </div>
- <table v-if="selected.widgets.length > 0" class="sw-table">
- <thead>
- <tr>
-
<th>id</th><th>title</th><th>type</th><th>unit</th><th>x,y</th><th>w×h</th><th>expressions</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="w in selected.widgets" :key="w.id">
- <td class="mono">{{ w.id }}</td>
- <td>{{ w.title }}</td>
- <td><span class="tag">{{ w.type }}</span></td>
- <td>{{ w.unit || '—' }}</td>
- <td class="mono">{{ w.x }},{{ w.y }}</td>
- <td class="mono">{{ w.w }}×{{ w.h }}</td>
- <td class="mono mqe">
- <div v-for="(e, i) in w.expressions" :key="i">{{ e }}</div>
- </td>
- </tr>
- </tbody>
- </table>
- <p v-else class="empty">No widgets defined.</p>
+ <ul v-else class="widget-list">
+ <li v-for="(w, i) in currentWidgets" :key="i" class="widget-edit">
+ <div class="we-row">
+ <div class="we-handle">
+ <button
+ class="sw-btn ghost small"
+ type="button"
+ :disabled="i === 0"
+ title="Move up"
+ @click="moveWidget(i, -1)"
+ >↑</button>
+ <button
+ class="sw-btn ghost small"
+ type="button"
+ :disabled="i === currentWidgets.length - 1"
+ title="Move down"
+ @click="moveWidget(i, 1)"
+ >↓</button>
+ </div>
+ <div class="we-fields">
+ <div class="row">
+ <label>
+ <span>id</span>
+ <input class="mono" v-model="w.id" />
+ </label>
+ <label class="grow">
+ <span>Title</span>
+ <input v-model="w.title" />
+ </label>
+ <label>
+ <span>Type</span>
+ <select v-model="w.type">
+ <option value="card">card</option>
+ <option value="line">line</option>
+ </select>
+ </label>
+ <label>
+ <span>Unit</span>
+ <input v-model="w.unit" placeholder="—" />
+ </label>
+ <label>
+ <span>Span (1–12)</span>
+ <input type="number" min="1" max="12"
v-model.number="w.span" />
+ </label>
+ <label>
+ <span>Row span</span>
+ <input type="number" min="1" max="6"
v-model.number="w.rowSpan" />
+ </label>
+ </div>
+ <div class="row">
+ <label class="grow wide">
+ <span>Visible when (optional)</span>
+ <input
+ class="mono"
+ v-model="w.visibleWhen"
+ placeholder="#entity.jvm or service_jvm_cpu has
value"
+ />
+ </label>
+ </div>
+ <div class="row">
+ <label class="grow wide">
+ <span>MQE expressions (one per line)</span>
+ <textarea
+ class="mono"
+ rows="3"
+ :value="expressionsToText(w.expressions)"
+ @input="w.expressions =
textToExpressions(($event.target as HTMLTextAreaElement).value)"
+ ></textarea>
+ </label>
+ </div>
+ </div>
+ <button class="sw-btn danger" type="button" title="Delete"
@click="deleteWidget(i)">
+ ✕
+ </button>
+ </div>
+ </li>
+ </ul>
</section>
</main>
</div>
@@ -213,9 +369,7 @@ function componentFlags(t: AdminLayerTemplate): string[] {
max-width: 1440px;
margin: 0 auto;
}
-.page-head {
- margin-bottom: 18px;
-}
+.page-head { margin-bottom: 18px; }
.kicker {
font-size: 10px;
text-transform: uppercase;
@@ -252,10 +406,15 @@ function componentFlags(t: AdminLayerTemplate): string[] {
color: var(--sw-fg-3);
font-size: 12px;
}
+.empty.inset {
+ padding: 18px;
+ font-size: 11.5px;
+}
.grid {
display: grid;
grid-template-columns: 220px 1fr;
gap: 14px;
+ align-items: start;
}
.layer-list {
padding: 8px;
@@ -263,6 +422,8 @@ function componentFlags(t: AdminLayerTemplate): string[] {
flex-direction: column;
gap: 2px;
align-self: start;
+ position: sticky;
+ top: 16px;
}
.list-head {
padding: 6px 10px 10px;
@@ -293,17 +454,14 @@ function componentFlags(t: AdminLayerTemplate): string[] {
text-align: left;
font: inherit;
}
-.layer-row:hover {
- background: var(--sw-bg-2);
-}
+.layer-row:hover { background: var(--sw-bg-2); }
.layer-row.active {
background: var(--sw-bg-3);
color: var(--sw-fg-0);
box-shadow: inset 2px 0 0 var(--sw-accent);
}
.layer-row .dot {
- width: 7px;
- height: 7px;
+ width: 7px; height: 7px;
border-radius: 50%;
flex: 0 0 7px;
}
@@ -313,144 +471,217 @@ function componentFlags(t: AdminLayerTemplate):
string[] {
overflow: hidden;
text-overflow: ellipsis;
}
-.layer-row .badge {
- font-family: var(--sw-mono);
- font-size: 10px;
- color: var(--sw-fg-3);
-}
.detail {
display: flex;
flex-direction: column;
gap: 14px;
+ min-width: 0;
}
-.card-head {
+.identity-card { padding: 12px 16px; }
+.identity-row {
display: flex;
- align-items: baseline;
- gap: 10px;
- padding: 10px 14px;
- border-bottom: 1px solid var(--sw-line);
+ align-items: center;
+ gap: 14px;
}
-.card-head h4 {
+.identity-row h2 {
margin: 0;
- font-size: 12px;
+ font-size: 15px;
font-weight: 600;
color: var(--sw-fg-0);
}
-.card-head .sub {
+.identity-row .meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-top: 4px;
font-size: 10.5px;
- color: var(--sw-fg-3);
-}
-.kv {
- width: 100%;
- font-size: 12px;
-}
-.kv th, .kv td {
- padding: 6px 14px;
- text-align: left;
- border-bottom: 1px solid var(--sw-line);
- vertical-align: top;
}
-.kv th {
- width: 140px;
- color: var(--sw-fg-3);
- font-weight: 500;
-}
-.kv tr:last-child th, .kv tr:last-child td {
- border-bottom: none;
-}
-.mono {
+.identity-row .meta code {
font-family: var(--sw-mono);
- font-size: 11.5px;
+ background: var(--sw-bg-2);
+ padding: 1px 6px;
+ border-radius: 3px;
color: var(--sw-fg-1);
}
-.mono code {
- background: transparent;
- padding: 0;
+.chip {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-2);
+}
+.chip.on {
+ background: var(--sw-accent-soft);
+ color: var(--sw-accent-2);
}
.dot.inline {
- display: inline-block;
- width: 8px;
- height: 8px;
+ width: 12px; height: 12px;
border-radius: 50%;
- margin-right: 6px;
- vertical-align: middle;
+ display: inline-block;
}
-.chips {
+.actions {
+ margin-left: auto;
display: flex;
- flex-wrap: wrap;
- gap: 6px;
- padding: 12px 14px;
+ align-items: center;
+ gap: 8px;
}
-.chip {
- font-size: 10.5px;
- padding: 3px 8px;
+.actions .sw-btn { font-size: 11.5px; }
+.actions .sw-btn[disabled] { opacity: 0.4; pointer-events: none; }
+.save-msg {
+ font-size: 11px;
+ color: var(--sw-ok);
+}
+
+.scope-tabs {
+ display: flex;
+ gap: 2px;
+ padding: 6px;
+}
+.scope-tab {
+ flex: 1;
+ padding: 8px 12px;
+ font-size: 11.5px;
+ font-weight: 500;
+ color: var(--sw-fg-2);
+ background: transparent;
+ border: none;
border-radius: 4px;
- background: var(--sw-bg-2);
- color: var(--sw-fg-1);
- border: 1px solid var(--sw-line-2);
+ cursor: pointer;
+ text-transform: capitalize;
+ font: inherit;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
}
-.chip.on {
+.scope-tab:hover { background: var(--sw-bg-2); color: var(--sw-fg-1); }
+.scope-tab.on {
background: var(--sw-accent-soft);
color: var(--sw-accent-2);
- border-color: var(--sw-accent-line);
+ font-weight: 600;
}
-.chip.off {
+.scope-tab .count {
+ font-family: var(--sw-mono);
+ font-size: 10px;
color: var(--sw-fg-3);
}
-.sw-table {
- width: 100%;
- font-size: 11.5px;
+.scope-tab.on .count { color: var(--sw-accent-2); }
+
+.widgets-card { padding: 0; }
+.card-head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--sw-line);
}
-.sw-table th {
- text-align: left;
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
+.card-head h4 {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ text-transform: capitalize;
+}
+.card-head .sub {
+ font-size: 10.5px;
color: var(--sw-fg-3);
- font-weight: 500;
- padding: 6px 14px;
- border-bottom: 1px solid var(--sw-line);
}
-.sw-table td {
- padding: 6px 14px;
+.card-head .add {
+ margin-left: auto;
+ font-size: 11.5px;
+ background: var(--sw-accent-soft);
+ color: var(--sw-accent-2);
+ border-color: var(--sw-accent-line);
+}
+
+.widget-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.widget-edit {
border-bottom: 1px solid var(--sw-line);
- color: var(--sw-fg-1);
- vertical-align: top;
}
-.sw-table td.mqe div + div {
- margin-top: 2px;
+.widget-edit:last-child { border-bottom: none; }
+.we-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px 16px;
+}
+.we-handle {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-top: 18px;
}
-.tag {
+.we-handle .sw-btn {
+ width: 24px;
+ height: 22px;
+ padding: 0;
font-size: 10px;
- padding: 1px 6px;
- border-radius: 3px;
- background: var(--sw-bg-2);
- color: var(--sw-fg-2);
- font-family: var(--sw-mono);
}
-.extras {
+.we-fields {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.row {
display: flex;
flex-wrap: wrap;
- gap: 18px;
- padding: 10px 14px;
- border-top: 1px dashed var(--sw-line);
- font-size: 11px;
- color: var(--sw-fg-2);
+ gap: 8px;
}
-.extras strong {
+.row label {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: 10.5px;
color: var(--sw-fg-3);
- font-weight: 500;
- text-transform: uppercase;
- font-size: 9.5px;
- letter-spacing: 0.08em;
- margin-right: 4px;
+ flex: 0 1 auto;
+}
+.row label.grow { flex: 1 1 auto; min-width: 140px; }
+.row label.wide { flex: 1 1 100%; }
+.row input,
+.row select,
+.row textarea {
+ height: 26px;
+ padding: 0 8px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 11.5px;
+}
+.row textarea {
+ height: auto;
+ padding: 6px 8px;
+ resize: vertical;
}
-.extras code {
+.row input.mono,
+.row textarea.mono {
font-family: var(--sw-mono);
- font-size: 10.5px;
- background: var(--sw-bg-2);
- padding: 1px 4px;
- border-radius: 3px;
- color: var(--sw-fg-1);
+ font-size: 11px;
+}
+.row input:focus,
+.row select:focus,
+.row textarea:focus {
+ outline: none;
+ border-color: var(--sw-accent-line);
+}
+.sw-btn.danger {
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ font-size: 11px;
+ margin-top: 18px;
+ border-color: rgba(239, 68, 68, 0.3);
+ color: #f87171;
+}
+.sw-btn.danger:hover {
+ background: var(--sw-err-soft);
}
</style>
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index 6372c45..b55c5b1 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -39,14 +39,21 @@ import { fmtMetric } from '@/utils/formatters';
const route = useRoute();
const layerKey = computed(() => String(route.params.layerKey ?? ''));
+// Scope is inferred from the active sub-route (`service` / `instance`
+// / `endpoint` / `trace` / `profiling`). The view is shared across
+// all per-layer scope routes, the BFF returns a different widget set
+// per scope.
+const scope = computed<string>(() => {
+ const path = route.path;
+ for (const s of ['instance', 'endpoint', 'trace', 'profiling']) {
+ if (path.endsWith(`/${s}`)) return s;
+ }
+ return 'service';
+});
const { selectedId } = useSelectedService();
const { layers } = useLayers();
const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key
=== layerKey.value) ?? null);
-// Look up the service NAME from landing data — selectedId is the
-// base64 OAP service id, which MQE doesn't accept; MQE entities are
-// keyed by serviceName. We share the landing query with the rest of
-// the per-layer page so this is free (cached by vue-query).
const store = useSetupStore();
const safeLayer = computed<LayerDef>(() => layer.value ?? {
key: layerKey.value, name: layerKey.value, color: 'var(--sw-fg-2)',
@@ -63,8 +70,8 @@ const serviceName = computed<string | null>(() => {
return match?.serviceName ?? null;
});
-const { config, isLoading: configLoading } = useLayerDashboardConfig(layerKey);
-const { data, isFetching, error } = useLayerDashboard(layerKey, serviceName);
+const { config, isLoading: configLoading } = useLayerDashboardConfig(layerKey,
scope);
+const { data, isFetching, error } = useLayerDashboard(layerKey, serviceName,
scope);
const widgets = computed(() => config.value?.widgets ?? []);
const resultsById = computed(() => {
@@ -75,6 +82,50 @@ const resultsById = computed(() => {
const reachable = computed(() => data.value?.reachable !== false);
const errorText = computed(() => data.value?.error ?? (error.value ?
String(error.value) : null));
const headerTitle = computed(() => serviceName.value ?? data.value?.service ??
'Pick a service');
+
+/** Map a widget's grid footprint into the new 12-col flow grid. Honors
+ * `span` / `rowSpan` first; falls back to legacy `w` / `h` (24-col
+ * scaled to 12 by halving) so older templates still render. */
+function gridStyle(w: { span?: number; rowSpan?: number; w?: number; h?:
number }): Record<string, string> {
+ const span = w.span ?? (w.w ? Math.max(1, Math.min(12, Math.round(w.w / 2)))
: 4);
+ const rowSpan = w.rowSpan ?? (w.h ? Math.max(1, Math.round(w.h / 8)) : 1);
+ return {
+ gridColumn: `span ${span}`,
+ gridRow: `span ${rowSpan}`,
+ };
+}
+
+/**
+ * Evaluate a widget's `visibleWhen` predicate.
+ * - `<metric_name> has value` → the widget's result has a non-null
+ * scalar / a non-empty series.
+ * - `#entity.<key>` → entity attribute exists (deferred —
+ * we don't surface entity attributes yet; defaults true).
+ * - anything else → treated as "always visible".
+ *
+ * Empty / unset → always visible. Predicates that mention a metric not
+ * in the widget's own results never hide the widget either; they're
+ * advisory hints for the operator's mental model.
+ */
+function isVisible(
+ w: { id: string; visibleWhen?: string },
+ result: { value?: number | null; series?: Array<{ data: Array<number | null>
}> } | undefined,
+): boolean {
+ const cond = w.visibleWhen?.trim();
+ if (!cond) return true;
+ const hasValueMatch = /^(\S+)\s+has\s+value$/i.exec(cond);
+ if (hasValueMatch && result) {
+ if (result.value !== undefined && result.value !== null) return true;
+ if (result.series && result.series.some((s) => s.data.some((v) => v !==
null))) return true;
+ return false;
+ }
+ if (cond.startsWith('#entity.')) {
+ // Entity-attribute predicates need an attributes feed we don't
+ // surface yet (Phase 7-ish). Render the widget for now.
+ return true;
+ }
+ return true;
+}
</script>
<template>
@@ -98,13 +149,10 @@ const headerTitle = computed(() => serviceName.value ??
data.value?.service ?? '
</div>
<div v-else class="grid">
<div
- v-for="w in widgets"
+ v-for="w in widgets.filter((wi) => isVisible(wi,
resultsById.get(wi.id)))"
:key="w.id"
class="widget sw-card"
- :style="{
- gridColumn: `span ${w.w}`,
- gridRow: `span ${w.h}`,
- }"
+ :style="gridStyle(w)"
>
<div class="w-head" :title="w.tip">
<h4>{{ w.title }}</h4>
@@ -127,7 +175,7 @@ const headerTitle = computed(() => serviceName.value ??
data.value?.service ?? '
v-if="resultsById.get(w.id)?.series?.length"
:series="resultsById.get(w.id)!.series!"
:unit="w.unit"
- :height="Math.max(120, w.h * 14)"
+ :height="(w.rowSpan ?? 1) * 160 - 60"
/>
<span v-else class="muted">no data</span>
</template>
@@ -193,10 +241,15 @@ const headerTitle = computed(() => serviceName.value ??
data.value?.service ?? '
font-size: 12px;
}
.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. */
display: grid;
- grid-template-columns: repeat(24, 1fr);
- grid-auto-rows: 14px;
- gap: 10px;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ grid-auto-rows: 180px;
+ grid-auto-flow: row dense;
+ gap: 12px;
}
.widget {
display: flex;
diff --git a/packages/api-client/src/dashboard.ts
b/packages/api-client/src/dashboard.ts
index 9f93bec..2b73727 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -31,6 +31,19 @@
export type DashboardWidgetType = 'card' | 'line';
+/**
+ * Per-entity dashboard scope. Each layer carries an independent widget
+ * set per scope; the SPA picks the right set based on the active
+ * sub-route under `/layer/:key/`.
+ *
+ * `service` = the layer's primary landing (was `/dashboards`)
+ * `instance` = drill into a single service instance
+ * `endpoint` = drill into a single endpoint
+ * `trace` = trace explorer for the selected entity
+ * `profiling` = flame graphs / sampled stacks
+ */
+export type DashboardScope = 'service' | 'instance' | 'endpoint' | 'trace' |
'profiling';
+
export interface DashboardWidget {
/** Stable id within the layer's dashboard. */
id: string;
@@ -43,17 +56,37 @@ export interface DashboardWidget {
expressions: string[];
/** Suffix unit (`%`, `ms`, `calls / min`). */
unit?: string;
- /** 24-column grid coordinates — operator can re-layout later. */
- x: number;
- y: number;
- w: number;
- h: number;
+ /**
+ * Column span in a 12-column flow grid. Default 4. Widgets pack via
+ * `grid-auto-flow: dense` so positions are dynamic — operators
+ * describe a widget's width once and the grid lays it out.
+ */
+ span?: number;
+ /** Row span (number of 14px rows). Default 8. */
+ rowSpan?: number;
+ /**
+ * Optional visibility predicate. When set, the widget only renders if
+ * the predicate is truthy for the active entity. Supported forms:
+ * - `#entity.<key>` — entity attribute exists
+ * - `<metric_name> has value` — at least one bucket is non-null
+ * Future-compatible; the SPA evaluates this client-side.
+ */
+ visibleWhen?: string;
+ /** Legacy 24-col grid coordinates — kept for back-compat during the
+ * span-based flow-layout migration. New widgets should leave these
+ * unset and use `span` / `rowSpan` instead. */
+ x?: number;
+ y?: number;
+ w?: number;
+ h?: number;
}
export interface DashboardConfig {
/** Layer enum (UPPER_SNAKE). */
layer: string;
- /** Widget set. Order is irrelevant — grid coords drive placement. */
+ /** Widget set for the requested scope. */
+ scope?: DashboardScope;
+ /** Order is irrelevant — flow grid drives placement. */
widgets: DashboardWidget[];
}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 02852a9..feeda31 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -30,6 +30,7 @@ export type { LandingAggregates, LandingResponse,
LandingServiceRow } from './la
export type {
DashboardConfig,
DashboardResponse,
+ DashboardScope,
DashboardSeries,
DashboardWidget,
DashboardWidgetResult,