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 7ac1e59 ui: move dev server to 9090 and enlarge login card
7ac1e59 is described below
commit 7ac1e59e585bea56524489b7f730de601046c7e5
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:19:23 2026 +0800
ui: move dev server to 9090 and enlarge login card
Frees port 8080 for the legacy booster-ui that operators may run
side-by-side during migration. Vite's strictPort:true so we fail loud
instead of silently picking the next free port.
Login card resized: 360x → 440x, logo 24→ 44px, inputs 32→ 38px,
button 34→ 40px, tighter padding ramp. Brand-sub picks up a bit more
letter-spacing for the 'HORIZON UI' kicker.
---
apps/ui/src/router/index.ts | 5 +-
apps/ui/src/stores/setup.ts | 111 +++++++++
apps/ui/src/views/auth/LoginView.vue | 43 ++--
apps/ui/src/views/setup/LayerSetupCard.vue | 383 +++++++++++++++++++++++++++++
apps/ui/src/views/setup/SetupView.vue | 316 ++++++++++++++++++++++++
apps/ui/vite.config.ts | 5 +-
6 files changed, 841 insertions(+), 22 deletions(-)
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 79fdcd5..0bdd479 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -65,7 +65,10 @@ function layerSubRoutes(): RouteRecordRaw[] {
}
const shellRoutes: RouteRecordRaw[] = [
- { path: '', name: 'home', component: () =>
import('@/views/landing/LandingView.vue') },
+ { path: '', name: 'setup', component: () =>
import('@/views/setup/SetupView.vue') },
+ // The eventual landing route — wired live in Stage 2.4 once the layer
+ // landing data endpoint exists. For now it shares the setup component.
+ { path: 'landing', name: 'landing', component: () =>
import('@/views/setup/SetupView.vue') },
...layerSubRoutes(),
// Alerts (user-facing — alarms are observability data, not operator-only)
{ path: 'alarms', component: placeholder, props: { title: 'Alarms', phase:
'Phase 5', note: 'Read-only; recovery is backend-auto. Live debug card via
admin REST.' } },
diff --git a/apps/ui/src/stores/setup.ts b/apps/ui/src/stores/setup.ts
new file mode 100644
index 0000000..1fa4d34
--- /dev/null
+++ b/apps/ui/src/stores/setup.ts
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ */
+
+import { defineStore } from 'pinia';
+import { reactive } from 'vue';
+import type { LayerCaps, LayerSlots } from '@skywalking-horizon-ui/api-client';
+
+/** Per-layer landing-card configuration. See
docs/design/landing-composition.md. */
+export interface LandingConfig {
+ enabled: boolean;
+ /** Lower number → higher on the page. Defaults seeded from priority table.
*/
+ priority: number;
+ /** 5..8. */
+ topN: number;
+ /** MQE key used to rank the top-N services for this layer. */
+ orderBy: string;
+ /** Columns shown per service row in the card. */
+ columns: Array<{ metric: string; label: string; unit?: string }>;
+ /** Optional sparkline column metric. */
+ spark?: { metric: string; height: number };
+ style: 'table' | 'bar' | 'mini-topology';
+}
+
+/** Editable per-layer config the setup page mutates. Mirrors what the
+ * Phase 7 admin UI will persist via the BFF. */
+export interface LayerConfig {
+ /** Override display name (defaults to the menu title from OAP). */
+ displayName?: string;
+ /** Term aliases — overrides slots from /api/menu. */
+ slots: LayerSlots;
+ /** Feature toggles — start from /api/menu defaults, operator can disable. */
+ caps: LayerCaps;
+ landing: LandingConfig;
+}
+
+/** Default-priority table per the design (General → Virtual* → Mesh → K8s). */
+function defaultPriority(layerKey: string): number {
+ const k = layerKey.toLowerCase();
+ if (k === 'general') return 10;
+ if (k.startsWith('virtual_')) return 20;
+ if (k === 'mesh' || k === 'mesh_cp' || k === 'mesh_dp') return 30;
+ if (k === 'k8s' || k === 'k8s_service') return 40;
+ return 99;
+}
+
+/** Default-columns table per layer category. Concrete MQE metric names are
+ * illustrative until Stage 2.4 wires them up — adjust per layer admin. */
+function defaultColumns(_layerKey: string): LandingConfig['columns'] {
+ return [
+ { metric: 'cpm', label: 'cpm' },
+ { metric: 'p99', label: 'p99', unit: 'ms' },
+ { metric: 'sla', label: 'SLA', unit: '%' },
+ { metric: 'err', label: 'err', unit: '%' },
+ ];
+}
+
+export function defaultLandingFor(layerKey: string): LandingConfig {
+ return {
+ enabled: false, // operator opts-in per layer in the setup page
+ priority: defaultPriority(layerKey),
+ topN: 5,
+ orderBy: 'cpm',
+ columns: defaultColumns(layerKey),
+ spark: { metric: 'cpm', height: 28 },
+ style: 'table',
+ };
+}
+
+export function defaultLayerConfig(
+ layerKey: string,
+ defaults: { slots: LayerSlots; caps: LayerCaps },
+): LayerConfig {
+ return {
+ slots: { ...defaults.slots },
+ caps: { ...defaults.caps },
+ landing: defaultLandingFor(layerKey),
+ };
+}
+
+export const useSetupStore = defineStore('setup', () => {
+ /** Layer key → edited config. Phase 2.4 will persist this via BFF. */
+ const configs = reactive<Record<string, LayerConfig>>({});
+
+ function ensure(
+ layerKey: string,
+ defaults: { slots: LayerSlots; caps: LayerCaps },
+ ): LayerConfig {
+ if (!configs[layerKey]) configs[layerKey] = defaultLayerConfig(layerKey,
defaults);
+ return configs[layerKey];
+ }
+
+ function reset(layerKey: string, defaults: { slots: LayerSlots; caps:
LayerCaps }): void {
+ configs[layerKey] = defaultLayerConfig(layerKey, defaults);
+ }
+
+ return { configs, ensure, reset };
+});
diff --git a/apps/ui/src/views/auth/LoginView.vue
b/apps/ui/src/views/auth/LoginView.vue
index a3db596..4009df5 100644
--- a/apps/ui/src/views/auth/LoginView.vue
+++ b/apps/ui/src/views/auth/LoginView.vue
@@ -98,57 +98,58 @@ async function submit(): Promise<void> {
var(--sw-bg-0);
}
.login-card {
- width: 360px;
+ width: 440px;
background: var(--sw-bg-1);
border: 1px solid var(--sw-line);
- border-radius: 10px;
- padding: 24px 24px 18px;
- box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.6);
+ border-radius: 12px;
+ padding: 36px 36px 26px;
+ box-shadow: 0 32px 80px -28px rgba(0, 0, 0, 0.7);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
- gap: 6px;
- margin-bottom: 22px;
+ gap: 10px;
+ margin-bottom: 28px;
}
.brand-logo {
display: inline-flex;
color: var(--sw-fg-0);
}
.brand-logo :deep(svg) {
- height: 24px;
+ height: 44px;
width: auto;
display: block;
}
.brand-sub {
- font-size: 11px;
+ font-size: 12px;
color: var(--sw-fg-2);
- letter-spacing: 0.06em;
+ letter-spacing: 0.12em;
text-transform: uppercase;
+ font-weight: 500;
}
.field {
display: block;
- margin-bottom: 12px;
+ margin-bottom: 14px;
}
.field span {
display: block;
- font-size: 10px;
+ font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sw-fg-2);
- margin-bottom: 6px;
+ margin-bottom: 7px;
}
.field input {
width: 100%;
- height: 32px;
- padding: 0 10px;
+ height: 38px;
+ padding: 0 12px;
background: var(--sw-bg-2);
border: 1px solid var(--sw-line-2);
- border-radius: 6px;
+ border-radius: 7px;
color: var(--sw-fg-0);
font: inherit;
- font-size: 13px;
+ font-size: 14px;
outline: none;
transition: border-color 0.1s;
}
@@ -166,18 +167,20 @@ async function submit(): Promise<void> {
}
.submit {
width: 100%;
- height: 34px;
- margin-top: 6px;
- font-size: 13px;
+ height: 40px;
+ margin-top: 10px;
+ font-size: 14px;
+ font-weight: 600;
}
.submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.foot {
- margin-top: 14px;
+ margin-top: 18px;
font-size: 11px;
color: var(--sw-fg-3);
text-align: center;
+ line-height: 1.5;
}
</style>
diff --git a/apps/ui/src/views/setup/LayerSetupCard.vue
b/apps/ui/src/views/setup/LayerSetupCard.vue
new file mode 100644
index 0000000..127f881
--- /dev/null
+++ b/apps/ui/src/views/setup/LayerSetupCard.vue
@@ -0,0 +1,383 @@
+<!--
+ 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.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import Icon from '@/components/icons/Icon.vue';
+import { useSetupStore, defaultLandingFor } from '@/stores/setup';
+
+const props = defineProps<{ layer: LayerDef; expanded?: boolean }>();
+const emit = defineEmits<{ (e: 'toggle'): void }>();
+
+const store = useSetupStore();
+const cfg = computed(() => store.ensure(props.layer.key, { slots:
props.layer.slots, caps: props.layer.caps }));
+
+const open = ref(props.expanded ?? false);
+function toggle(): void {
+ open.value = !open.value;
+ emit('toggle');
+}
+
+function resetThisLayer(): void {
+ store.reset(props.layer.key, { slots: props.layer.slots, caps:
props.layer.caps });
+}
+
+const summary = computed<string>(() => {
+ const c = cfg.value;
+ if (!c.landing.enabled) {
+ return props.layer.active
+ ? 'Hidden from landing — toggle to show'
+ : `${props.layer.name} has no data yet — set up a receiver to start
ingesting`;
+ }
+ const cols = c.landing.columns.map((x) => x.metric).join(', ');
+ return `Top ${c.landing.topN} by ${c.landing.orderBy} · ${cols}${
+ c.landing.spark ? ` · sparkline ${c.landing.spark.metric}` : ''
+ } · priority ${c.landing.priority}`;
+});
+
+// Default cap labels with the "Topology" trio collapsed for compact display.
+const capRows: Array<{ key: keyof typeof cfg.value.caps; label: string }> = [
+ { key: 'serviceMap', label: 'Service map' },
+ { key: 'endpointDependency', label: 'API dependency' },
+ { key: 'instanceTopology', label: 'Instance map' },
+ { key: 'processTopology', label: 'Process map' },
+ { key: 'dashboards', label: 'Dashboards' },
+ { key: 'traces', label: 'Traces' },
+ { key: 'logs', label: 'Logs' },
+ { key: 'profiling', label: 'Profiling' },
+ { key: 'events', label: 'Events' },
+];
+
+// Columns the operator can enable on the landing card.
+const availableColumns = [
+ { metric: 'cpm', label: 'cpm' },
+ { metric: 'p99', label: 'p99', unit: 'ms' },
+ { metric: 'p95', label: 'p95', unit: 'ms' },
+ { metric: 'sla', label: 'SLA', unit: '%' },
+ { metric: 'apdex', label: 'apdex' },
+ { metric: 'err', label: 'err', unit: '%' },
+ { metric: 'resp', label: 'avg resp', unit: 'ms' },
+] as const;
+function isColumnSelected(metric: string): boolean {
+ return cfg.value.landing.columns.some((c) => c.metric === metric);
+}
+function toggleColumn(metric: string, label: string, unit?: string): void {
+ const cols = cfg.value.landing.columns;
+ const idx = cols.findIndex((c) => c.metric === metric);
+ if (idx >= 0) {
+ cols.splice(idx, 1);
+ } else if (cols.length < 5) {
+ cols.push({ metric, label, ...(unit ? { unit } : {}) });
+ }
+}
+
+function clampTopN(n: number): void {
+ const v = Math.max(5, Math.min(8, Math.round(n || 5)));
+ cfg.value.landing.topN = v;
+}
+
+const headerColor = computed(() => props.layer.color);
+const isDefaultLanding = computed(() => {
+ const d = defaultLandingFor(props.layer.key);
+ return (
+ !cfg.value.landing.enabled &&
+ cfg.value.landing.priority === d.priority &&
+ cfg.value.landing.topN === d.topN
+ );
+});
+</script>
+
+<template>
+ <div class="sw-card layer-card" :class="{ 'is-open': open, 'is-inactive':
!layer.active }">
+ <div class="head" @click="toggle">
+ <span class="dot" :style="{ background: headerColor }" />
+ <span class="name">{{ cfg.displayName || layer.name }}</span>
+ <span v-if="layer.active" class="sw-badge ok dot-mark">{{
layer.serviceCount >= 0 ? `${layer.serviceCount} services` : 'active' }}</span>
+ <span v-else class="sw-badge">no data</span>
+ <span v-if="cfg.landing.enabled" class="sw-badge info"
style="margin-left: auto">on landing</span>
+ <span v-else-if="!isDefaultLanding" class="sw-badge" style="margin-left:
auto">customized</span>
+ <span class="caret" :class="{ open }"><Icon name="caret" :size="12"
/></span>
+ </div>
+ <div class="summary">{{ summary }}</div>
+
+ <div v-if="open" class="body">
+ <section>
+ <h4>Term aliases</h4>
+ <div class="field-grid">
+ <label>
+ <span>Display name</span>
+ <input v-model="cfg.displayName" :placeholder="layer.name" />
+ </label>
+ <label v-if="layer.slots.services !== undefined">
+ <span>Services</span>
+ <input v-model="cfg.slots.services"
:placeholder="layer.slots.services" />
+ </label>
+ <label v-if="layer.slots.instances !== undefined">
+ <span>Instances</span>
+ <input v-model="cfg.slots.instances"
:placeholder="layer.slots.instances" />
+ </label>
+ <label v-if="layer.slots.endpoints !== undefined">
+ <span>Endpoints</span>
+ <input v-model="cfg.slots.endpoints"
:placeholder="layer.slots.endpoints" />
+ </label>
+ <label v-if="cfg.caps.endpointDependency">
+ <span>Endpoint dependency</span>
+ <input v-model="cfg.slots.endpointDependency"
:placeholder="layer.slots.endpointDependency ?? `${cfg.slots.endpoints ??
'Endpoint'} dependency`" />
+ </label>
+ </div>
+ </section>
+
+ <section>
+ <h4>Features</h4>
+ <div class="caps-grid">
+ <label v-for="row in capRows" :key="row.key" class="cap-toggle">
+ <input type="checkbox" v-model="cfg.caps[row.key]" />
+ <span>{{ row.label }}</span>
+ </label>
+ </div>
+ </section>
+
+ <section>
+ <h4>Landing card</h4>
+ <div class="field-grid landing">
+ <label class="wide">
+ <input type="checkbox" v-model="cfg.landing.enabled" />
+ <span>Show this layer on the landing</span>
+ </label>
+ <label>
+ <span>Priority</span>
+ <input type="number" v-model.number="cfg.landing.priority" min="0"
max="99" />
+ </label>
+ <label>
+ <span>Top N (5–8)</span>
+ <input type="number" :value="cfg.landing.topN" min="5" max="8"
@input="(e) => clampTopN(Number((e.target as HTMLInputElement).value))" />
+ </label>
+ <label>
+ <span>Order by</span>
+ <select v-model="cfg.landing.orderBy">
+ <option v-for="c in availableColumns" :key="c.metric"
:value="c.metric">{{ c.label }}</option>
+ </select>
+ </label>
+ <label>
+ <span>Sparkline</span>
+ <select :value="cfg.landing.spark?.metric ?? ''" @change="(e) => {
const v = (e.target as HTMLSelectElement).value; cfg.landing.spark = v ? {
metric: v, height: 28 } : undefined; }">
+ <option value="">none</option>
+ <option v-for="c in availableColumns" :key="c.metric"
:value="c.metric">{{ c.label }}</option>
+ </select>
+ </label>
+ <label>
+ <span>Style</span>
+ <select v-model="cfg.landing.style">
+ <option value="table">Table</option>
+ <option value="bar">Bar</option>
+ <option value="mini-topology">Mini topology</option>
+ </select>
+ </label>
+ </div>
+ <div class="cols-row">
+ <span class="cols-label">Columns (max 5)</span>
+ <div class="cols-chips">
+ <button
+ v-for="c in availableColumns"
+ :key="c.metric"
+ class="chip"
+ :class="{ on: isColumnSelected(c.metric) }"
+ type="button"
+ @click="toggleColumn(c.metric, c.label, 'unit' in c ? c.unit :
undefined)"
+ >
+ {{ c.label }}<span v-if="'unit' in c && c.unit" class="unit">{{
c.unit }}</span>
+ </button>
+ </div>
+ </div>
+ </section>
+
+ <div class="actions">
+ <button class="sw-btn" type="button" @click="resetThisLayer">Reset to
defaults</button>
+ <span class="hint">Changes are local until persisted via /api/setup
(Stage 2.4).</span>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.layer-card {
+ margin-bottom: 10px;
+}
+.layer-card.is-inactive .name {
+ color: var(--sw-fg-2);
+}
+.head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ cursor: pointer;
+ user-select: none;
+}
+.head .dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: 0 0 8px;
+}
+.head .name {
+ font-weight: 600;
+ color: var(--sw-fg-0);
+}
+.head .dot-mark::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ margin-right: 4px;
+ display: inline-block;
+}
+.caret {
+ color: var(--sw-fg-3);
+ transition: transform 0.12s;
+ transform: rotate(-90deg);
+ display: inline-flex;
+ margin-left: 8px;
+}
+.caret.open {
+ transform: rotate(0);
+}
+.summary {
+ padding: 0 12px 10px;
+ font-size: 11px;
+ color: var(--sw-fg-2);
+}
+.body {
+ border-top: 1px solid var(--sw-line);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.body h4 {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-2);
+ margin: 0 0 8px;
+ font-weight: 600;
+}
+.field-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 10px;
+}
+.field-grid label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--sw-fg-2);
+}
+.field-grid label.wide {
+ grid-column: 1 / -1;
+ flex-direction: row;
+ align-items: center;
+}
+.field-grid input,
+.field-grid select {
+ height: 28px;
+ 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: 12px;
+}
+.field-grid input[type='checkbox'] {
+ height: auto;
+ margin-right: 6px;
+ padding: 0;
+}
+.caps-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 6px;
+}
+.cap-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--sw-fg-1);
+ padding: 4px 6px;
+ border-radius: 4px;
+ background: var(--sw-bg-2);
+}
+.cap-toggle input {
+ accent-color: var(--sw-accent);
+}
+.cols-row {
+ margin-top: 10px;
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+}
+.cols-label {
+ font-size: 11px;
+ color: var(--sw-fg-2);
+ padding-top: 4px;
+ flex: 0 0 80px;
+}
+.cols-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.chip {
+ height: 24px;
+ padding: 0 10px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 11px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.chip.on {
+ background: var(--sw-accent-soft);
+ color: var(--sw-accent-2);
+ border-color: var(--sw-accent-line);
+}
+.chip .unit {
+ color: var(--sw-fg-3);
+ font-size: 10px;
+}
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-top: 6px;
+ border-top: 1px dashed var(--sw-line);
+}
+.actions .hint {
+ margin-left: auto;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+</style>
diff --git a/apps/ui/src/views/setup/SetupView.vue
b/apps/ui/src/views/setup/SetupView.vue
new file mode 100644
index 0000000..6cea498
--- /dev/null
+++ b/apps/ui/src/views/setup/SetupView.vue
@@ -0,0 +1,316 @@
+<!--
+ 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.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import LayerSetupCard from './LayerSetupCard.vue';
+import { useLayers } from '@/composables/useLayers';
+import { useSetupStore } from '@/stores/setup';
+
+const { layers, oapReachable, oapError, isLoading } = useLayers();
+const store = useSetupStore();
+
+// Order by priority (lower first), with active layers always above inactive at
+// the same priority. The layer order on the landing will mirror this.
+const orderedLayers = computed(() =>
+ [...layers.value].sort((a, b) => {
+ const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps
}).landing.priority;
+ const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps
}).landing.priority;
+ if (pa !== pb) return pa - pb;
+ if (a.active !== b.active) return a.active ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ }),
+);
+
+const enabledOnLanding = computed(() =>
+ orderedLayers.value.filter((L) => store.ensure(L.key, { slots: L.slots,
caps: L.caps }).landing.enabled),
+);
+
+const filter = ref<'all' | 'active' | 'enabled'>('all');
+const visibleLayers = computed(() => {
+ if (filter.value === 'active') return orderedLayers.value.filter((L) =>
L.active);
+ if (filter.value === 'enabled')
+ return orderedLayers.value.filter((L) =>
+ store.ensure(L.key, { slots: L.slots, caps: L.caps }).landing.enabled,
+ );
+ return orderedLayers.value;
+});
+</script>
+
+<template>
+ <div class="setup">
+ <header class="page-head">
+ <div>
+ <div class="kicker">Setup</div>
+ <h1>Configure layers and the landing page</h1>
+ <p class="lede">
+ Each detected layer can appear on the landing as its own card with
the top services and a
+ set of metrics. Pick which layers show up, set their priority,
choose the columns, and
+ rename slots if the default terms don't fit. Inactive layers (no
data) can still be
+ configured — they appear once their receiver starts reporting.
+ </p>
+ </div>
+ <div class="kpi-strip">
+ <div class="kpi">
+ <span class="kpi-label">Detected</span>
+ <span class="kpi-value">{{ layers.length }}</span>
+ </div>
+ <div class="kpi">
+ <span class="kpi-label">Active</span>
+ <span class="kpi-value">{{ layers.filter((L) => L.active).length
}}</span>
+ </div>
+ <div class="kpi">
+ <span class="kpi-label">On landing</span>
+ <span class="kpi-value">{{ enabledOnLanding.length }}</span>
+ </div>
+ </div>
+ </header>
+
+ <div v-if="!oapReachable && !isLoading" class="banner err">
+ <strong>OAP unreachable.</strong>
+ {{ oapError ?? 'Check that the OAP query host is up and reachable from
the BFF.' }}
+ Layer detection runs against <code>/graphql</code> on the OAP host
configured in
+ <code>horizon.yaml</code>.
+ </div>
+
+ <div v-if="layers.length === 0 && !isLoading" class="empty">
+ <div class="empty-card">
+ <div class="empty-icon">○</div>
+ <h2>No layers detected</h2>
+ <p>
+ Once data starts flowing through OAP — agents reporting, OTel
collectors forwarding,
+ virtual receivers ingesting — the layers will appear here.
+ </p>
+ </div>
+ </div>
+
+ <template v-else>
+ <div class="controls">
+ <div class="seg">
+ <button class="seg-btn" :class="{ on: filter === 'all' }"
@click="filter = 'all'">
+ All <span class="count">{{ orderedLayers.length }}</span>
+ </button>
+ <button class="seg-btn" :class="{ on: filter === 'active' }"
@click="filter = 'active'">
+ Active <span class="count">{{ layers.filter((L) =>
L.active).length }}</span>
+ </button>
+ <button class="seg-btn" :class="{ on: filter === 'enabled' }"
@click="filter = 'enabled'">
+ On landing <span class="count">{{ enabledOnLanding.length }}</span>
+ </button>
+ </div>
+ </div>
+
+ <div class="cards">
+ <LayerSetupCard v-for="L in visibleLayers" :key="L.key" :layer="L" />
+ </div>
+
+ <footer class="page-foot">
+ <div class="foot-left">
+ <strong>{{ enabledOnLanding.length }}</strong> layer(s) enabled on
the landing,
+ in priority order:
+ <span v-for="(L, i) in enabledOnLanding" :key="L.key"
class="chip-name">
+ {{ L.name }}<span v-if="i < enabledOnLanding.length - 1">,</span>
+ </span>
+ </div>
+ <div class="foot-right">
+ <span class="hint">Persistence wires in at Stage 2.4. For now,
changes live in this tab only.</span>
+ </div>
+ </footer>
+ </template>
+ </div>
+</template>
+
+<style scoped>
+.setup {
+ padding: 20px 20px 60px;
+ max-width: 980px;
+ margin: 0 auto;
+}
+.page-head {
+ display: flex;
+ gap: 24px;
+ align-items: flex-end;
+ margin-bottom: 18px;
+}
+.page-head > div:first-child {
+ flex: 1;
+ min-width: 0;
+}
+.kicker {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--sw-accent);
+ margin-bottom: 6px;
+}
+.page-head h1 {
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-0);
+ margin: 0 0 8px;
+}
+.lede {
+ font-size: 12.5px;
+ color: var(--sw-fg-1);
+ line-height: 1.5;
+ margin: 0;
+ max-width: 640px;
+}
+.kpi-strip {
+ display: flex;
+ gap: 14px;
+ flex: 0 0 auto;
+}
+.kpi {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ padding: 8px 14px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ min-width: 84px;
+}
+.kpi-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-2);
+}
+.kpi-value {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+}
+.banner.err {
+ margin: 0 0 16px;
+ padding: 10px 12px;
+ background: var(--sw-err-soft);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ color: #f87171;
+ font-size: 12px;
+ line-height: 1.5;
+}
+.banner.err code {
+ background: var(--sw-bg-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-family: var(--sw-mono);
+ font-size: 10.5px;
+ color: var(--sw-fg-1);
+}
+.empty {
+ margin-top: 20px;
+}
+.empty-card {
+ background: var(--sw-bg-1);
+ border: 1px dashed var(--sw-line-2);
+ border-radius: 10px;
+ padding: 28px;
+ text-align: center;
+}
+.empty-card .empty-icon {
+ font-size: 36px;
+ color: var(--sw-fg-3);
+ margin-bottom: 6px;
+}
+.empty-card h2 {
+ font-size: 15px;
+ color: var(--sw-fg-0);
+ margin: 0 0 6px;
+}
+.empty-card p {
+ font-size: 12px;
+ color: var(--sw-fg-2);
+ margin: 0;
+}
+.controls {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+}
+.seg {
+ display: flex;
+ gap: 0;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 6px;
+ overflow: hidden;
+}
+.seg-btn {
+ height: 28px;
+ padding: 0 12px;
+ background: transparent;
+ border: 0;
+ color: var(--sw-fg-2);
+ font: inherit;
+ font-size: 11px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.seg-btn:not(:last-child) {
+ border-right: 1px solid var(--sw-line);
+}
+.seg-btn.on {
+ background: var(--sw-bg-3);
+ color: var(--sw-fg-0);
+}
+.seg-btn .count {
+ font-size: 10px;
+ color: var(--sw-fg-3);
+ padding: 0 5px;
+ background: var(--sw-bg-2);
+ border-radius: 3px;
+}
+.seg-btn.on .count {
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-2);
+}
+.cards {
+ display: flex;
+ flex-direction: column;
+}
+.page-foot {
+ margin-top: 18px;
+ padding: 12px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.foot-left {
+ font-size: 12px;
+ color: var(--sw-fg-1);
+ flex: 1;
+ min-width: 0;
+}
+.foot-left strong {
+ color: var(--sw-accent-2);
+ font-weight: 700;
+}
+.chip-name {
+ color: var(--sw-fg-0);
+}
+.foot-right .hint {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+</style>
diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts
index 8611e30..0507e55 100644
--- a/apps/ui/vite.config.ts
+++ b/apps/ui/vite.config.ts
@@ -28,7 +28,10 @@ export default defineConfig({
},
},
server: {
- port: 8080,
+ // 9090: horizon-side. 8080 is reserved for the legacy booster-ui that
+ // operators may run side-by-side during migration.
+ port: 9090,
+ strictPort: true,
proxy: {
// proxy to the BFF (`apps/bff`) during dev
'/api': {