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': {

Reply via email to