{{ instance.name }}
@@ -26,11 +26,15 @@
Edit
-
+
Remove
@@ -52,7 +56,7 @@ const props = defineProps({
instance: { type: Object, required: true },
});
-defineEmits(['edit', 'delete']);
+defineEmits(['click', 'edit', 'delete']);
const isStale = computed(() => isInstanceStale(props.instance));
const statusLabel = computed(() =>
@@ -62,7 +66,12 @@ const statusLabel = computed(() =>
diff --git a/admin/ui/src/components/OverviewTab.vue b/admin/ui/src/components/OverviewTab.vue
new file mode 100644
index 0000000..f792cb5
--- /dev/null
+++ b/admin/ui/src/components/OverviewTab.vue
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Latency Over Time
+
+
+
+ Collecting data points...
+
+
+
+
+
+
+
+
+
diff --git a/admin/ui/src/components/TrafficTab.vue b/admin/ui/src/components/TrafficTab.vue
new file mode 100644
index 0000000..ba8f962
--- /dev/null
+++ b/admin/ui/src/components/TrafficTab.vue
@@ -0,0 +1,375 @@
+
+
+
+
+
+
+
Requests Per Second
+ syncZoom('rps', e)"
+ />
+
+
+
Latency
+ syncZoom('latency', e)"
+ />
+
+
+
Error Rate
+ syncZoom('error', e)"
+ />
+
+
+
+
+ Collecting time series data...
+
+
+
+ No vendor traffic data available yet.
+
+
+
+
+
+
+
diff --git a/admin/ui/src/components/VendorTable.vue b/admin/ui/src/components/VendorTable.vue
new file mode 100644
index 0000000..873f8c8
--- /dev/null
+++ b/admin/ui/src/components/VendorTable.vue
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+
diff --git a/admin/ui/src/composables/useAnimatedValue.js b/admin/ui/src/composables/useAnimatedValue.js
new file mode 100644
index 0000000..97e5d41
--- /dev/null
+++ b/admin/ui/src/composables/useAnimatedValue.js
@@ -0,0 +1,48 @@
+import { ref, watch, toValue, onUnmounted } from 'vue';
+
+export function easeOutCubic(t) {
+ return 1 - Math.pow(1 - t, 3);
+}
+
+export function useAnimatedValue(source, duration = 400) {
+ const display = ref(toValue(source) ?? 0);
+ let frameId = null;
+ let startTime = 0;
+ let startVal = 0;
+ let endVal = 0;
+
+ function tick(timestamp) {
+ if (startTime === 0) startTime = timestamp;
+ const progress = Math.min((timestamp - startTime) / duration, 1);
+ display.value = startVal + (endVal - startVal) * easeOutCubic(progress);
+
+ if (progress < 1) {
+ frameId = requestAnimationFrame(tick);
+ } else {
+ frameId = null;
+ }
+ }
+
+ watch(
+ () => toValue(source),
+ (newVal) => {
+ if (newVal == null) {
+ if (frameId != null) cancelAnimationFrame(frameId);
+ frameId = null;
+ display.value = 0;
+ return;
+ }
+ if (frameId != null) cancelAnimationFrame(frameId);
+ startVal = display.value;
+ endVal = newVal;
+ startTime = 0;
+ frameId = requestAnimationFrame(tick);
+ },
+ );
+
+ onUnmounted(() => {
+ if (frameId != null) cancelAnimationFrame(frameId);
+ });
+
+ return display;
+}
diff --git a/admin/ui/src/composables/useAnimatedValue.test.js b/admin/ui/src/composables/useAnimatedValue.test.js
new file mode 100644
index 0000000..5fe8c83
--- /dev/null
+++ b/admin/ui/src/composables/useAnimatedValue.test.js
@@ -0,0 +1,144 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { ref, nextTick } from 'vue';
+import { withSetup } from '../utils/test-utils.js';
+import { easeOutCubic, useAnimatedValue } from './useAnimatedValue.js';
+
+describe('easeOutCubic', () => {
+ it('returns 0 at start', () => {
+ expect(easeOutCubic(0)).toBe(0);
+ });
+
+ it('returns 1 at end', () => {
+ expect(easeOutCubic(1)).toBe(1);
+ });
+
+ it('decelerates — first half covers more than 50%', () => {
+ expect(easeOutCubic(0.5)).toBeGreaterThan(0.5);
+ });
+
+ it('is monotonically increasing', () => {
+ let prev = 0;
+ for (let t = 0.1; t <= 1; t += 0.1) {
+ const val = easeOutCubic(t);
+ expect(val).toBeGreaterThan(prev);
+ prev = val;
+ }
+ });
+});
+
+describe('useAnimatedValue', () => {
+ let rafCallbacks;
+ let rafId;
+
+ beforeEach(() => {
+ rafCallbacks = [];
+ rafId = 0;
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+ rafCallbacks.push(cb);
+ return ++rafId;
+ });
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function flushFrames(timestamp) {
+ const pending = [...rafCallbacks];
+ rafCallbacks = [];
+ pending.forEach((cb) => cb(timestamp));
+ }
+
+ it('starts with the initial source value', () => {
+ const source = ref(100);
+ const { result } = withSetup(() => useAnimatedValue(source));
+ expect(result.value).toBe(100);
+ });
+
+ it('starts at 0 for null source', () => {
+ const source = ref(null);
+ const { result } = withSetup(() => useAnimatedValue(source));
+ expect(result.value).toBe(0);
+ });
+
+ it('schedules animation when source changes', async () => {
+ const source = ref(100);
+ withSetup(() => useAnimatedValue(source));
+
+ source.value = 200;
+ await nextTick();
+
+ expect(window.requestAnimationFrame).toHaveBeenCalled();
+ });
+
+ it('reaches target value after full duration', async () => {
+ const duration = 400;
+ const source = ref(100);
+ const { result } = withSetup(() => useAnimatedValue(source, duration));
+
+ source.value = 200;
+ await nextTick();
+
+ // First frame sets startTime
+ flushFrames(1000);
+ // Frame past duration
+ flushFrames(1000 + duration + 1);
+
+ expect(result.value).toBe(200);
+ });
+
+ it('shows intermediate value mid-animation', async () => {
+ const duration = 400;
+ const source = ref(0);
+ const { result } = withSetup(() => useAnimatedValue(source, duration));
+
+ source.value = 100;
+ await nextTick();
+
+ // First frame at t=0
+ flushFrames(1000);
+ // Frame at t=200 (50% duration)
+ flushFrames(1200);
+
+ expect(result.value).toBeGreaterThan(0);
+ expect(result.value).toBeLessThan(100);
+ });
+
+ it('resets to 0 when source becomes null', async () => {
+ const source = ref(100);
+ const { result } = withSetup(() => useAnimatedValue(source));
+
+ source.value = null;
+ await nextTick();
+
+ expect(result.value).toBe(0);
+ });
+
+ it('interrupts running animation on new value', async () => {
+ const duration = 400;
+ const source = ref(0);
+ const { result } = withSetup(() => useAnimatedValue(source, duration));
+
+ source.value = 100;
+ await nextTick();
+ flushFrames(1000);
+ flushFrames(1100); // partial animation
+
+ const midValue = result.value;
+ expect(midValue).toBeGreaterThan(0);
+ expect(midValue).toBeLessThan(100);
+
+ // Change target mid-animation
+ source.value = 50;
+ await nextTick();
+
+ expect(window.cancelAnimationFrame).toHaveBeenCalled();
+
+ // Complete the new animation
+ flushFrames(2000);
+ flushFrames(2000 + duration + 1);
+
+ expect(result.value).toBe(50);
+ });
+});
diff --git a/admin/ui/src/layouts/AppLayout.vue b/admin/ui/src/layouts/AppLayout.vue
index f616e5d..3d7f8f9 100644
--- a/admin/ui/src/layouts/AppLayout.vue
+++ b/admin/ui/src/layouts/AppLayout.vue
@@ -11,7 +11,11 @@
to="/"
:class="[
$style.navItem,
- { [$style.active]: route.name === 'dashboard' },
+ {
+ [$style.active]:
+ route.name === 'dashboard' ||
+ route.name === 'instance-detail',
+ },
]"
>
+
+