Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions desktop/frontend/src/components/AdminPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ const stubs = {
};

describe("AdminPanel", () => {
test("keeps the constrained panel and scroll body in the same layout tree", () => {
const w = mount(AdminPanel, { global: { stubs } });
const body = w.get(".admin-body").element;

expect(w.element.classList.contains("admin-panel")).toBe(true);
expect(body.parentElement).toBe(w.element);
expect(w.find(".n-config-provider").exists()).toBe(false);
});

test("defaults to the invitations tab", () => {
const w = mount(AdminPanel, { global: { stubs } });
expect(w.findComponent(Invitations).exists()).toBe(true);
Expand Down
26 changes: 15 additions & 11 deletions desktop/frontend/src/components/AdminPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,15 @@ const overrides = getNaiveOverrides();
</script>

<template>
<n-config-provider
:theme="darkTheme"
:theme-overrides="overrides"
:locale="naiveLocaleValue"
:date-locale="naiveDateLocaleValue"
>
<n-message-provider>
<div class="admin-panel">
<div class="admin-panel">
<n-config-provider
abstract
:theme="darkTheme"
:theme-overrides="overrides"
:locale="naiveLocaleValue"
:date-locale="naiveDateLocaleValue"
>
<n-message-provider>
<div class="admin-tabs">
<button
v-for="tab in tabs"
Expand All @@ -72,17 +73,20 @@ const overrides = getNaiveOverrides();
<FeishuConfig v-if="active === 'feishu'" />
<Traffic v-if="active === 'traffic'" />
</div>
</div>
</n-message-provider>
</n-config-provider>
</n-message-provider>
</n-config-provider>
</div>
</template>

<style scoped>
.admin-panel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.admin-tabs {
display: flex;
Expand Down
193 changes: 128 additions & 65 deletions desktop/frontend/src/components/admin/Traffic.test.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,139 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { NMessageProvider } from "naive-ui";
import { h } from "vue";
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { NMessageProvider, NSelect } from 'naive-ui'
import { h } from 'vue'
import type { AdminTrafficRow } from '@shared/api/types'
import { trafficRange } from './trafficModel'

vi.mock("@shared/api/admin", () => ({
vi.mock('@shared/api/admin', () => ({
getTrafficStats: vi.fn(),
}));
getAdminHealth: vi.fn(),
}))

import Traffic from "./Traffic.vue";
import { getTrafficStats } from "@shared/api/admin";
import Traffic from './Traffic.vue'
import { getAdminHealth, getTrafficStats } from '@shared/api/admin'

const getTrafficStatsMock = getTrafficStats as unknown as ReturnType<typeof vi.fn>;
const getTrafficStatsMock = getTrafficStats as unknown as ReturnType<typeof vi.fn>
const getAdminHealthMock = getAdminHealth as unknown as ReturnType<typeof vi.fn>

// Traffic calls useMessage() which needs an <n-message-provider> ancestor;
// wrap it the same way AdminPanel does in production.
function mountTraffic() {
return mount({
render: () => h(NMessageProvider, null, { default: () => h(Traffic) }),
});
})
}

describe("Traffic", () => {
function currentRows(): AdminTrafficRow[] {
const range = trafficRange(7)
return [
{ user_id: 'u1', email: '[email protected]', day: range.days[0], frame_type: 3, frame_type_name: 'OUT', category: 'terminal', direction: 1, bytes: 2048, frames: 5 },
{ user_id: 'u1', email: '[email protected]', day: range.previousDays[0], frame_type: 3, frame_type_name: 'OUT', category: 'terminal', direction: 1, bytes: 1024, frames: 3 },
{ user_id: 'u2', email: '[email protected]', day: range.days[1], frame_type: 5, frame_type_name: 'META', category: 'state', direction: 1, bytes: 1024, frames: 8 },
]
}

describe('Traffic', () => {
beforeEach(() => {
getTrafficStatsMock.mockReset();
});

test("loads group view on mount and renders rows", async () => {
getTrafficStatsMock.mockResolvedValue({
view: "group",
from: "2026-09-15",
to: "2026-09-15",
rows: [
{ user_id: "u1", email: "[email protected]", category: "terminal", direction: 1, bytes: 2048, frames: 5 },
],
});

const w = mountTraffic();
await flushPromises();

expect(getTrafficStatsMock).toHaveBeenCalledWith({ view: "group" });
// Table rendered (data present) rather than the empty state.
expect(w.find('[data-test="traffic-empty"]').exists()).toBe(false);
expect(w.find('[data-test="traffic-table"]').exists()).toBe(true);
});

test("shows the empty state when no rows come back", async () => {
getTrafficStatsMock.mockResolvedValue({
view: "group",
from: "2026-09-15",
to: "2026-09-15",
rows: [],
});

const w = mountTraffic();
await flushPromises();

expect(w.find('[data-test="traffic-empty"]').exists()).toBe(true);
expect(w.find('[data-test="traffic-table"]').exists()).toBe(false);
});

test("switching the view re-fetches with the new view", async () => {
getTrafficStatsMock.mockResolvedValue({ view: "group", from: "", to: "", rows: [] });
const w = mountTraffic();
await flushPromises();
getTrafficStatsMock.mockClear();

// Click the "Detail" button (first in the group). Buttons render i18n
// labels; find by role/text is brittle, so click the first n-button.
const buttons = w.findAll("button");
await buttons[0].trigger("click");
await flushPromises();

expect(getTrafficStatsMock).toHaveBeenCalledWith({ view: "detail" });
});
});
getTrafficStatsMock.mockReset()
getAdminHealthMock.mockReset()
getTrafficStatsMock.mockResolvedValue({ view: 'detail', bucket: 'day', from: '', to: '', rows: currentRows() })
getAdminHealthMock.mockResolvedValue({
version: 'test',
uptime_seconds: 10,
https: true,
active_uplinks: 2,
active_sessions: 3,
relay_instances: 1,
traffic_flush_interval_seconds: 60,
generated_at: new Date().toISOString(),
})
})

test('loads one daily detail dataset and renders platform and user scopes', async () => {
const wrapper = mountTraffic()
await flushPromises()

expect(getTrafficStatsMock).toHaveBeenCalledTimes(1)
expect(getTrafficStatsMock).toHaveBeenCalledWith(expect.objectContaining({ view: 'detail', bucket: 'day' }))
expect(wrapper.find('[data-test="platform-status"]').exists()).toBe(true)
expect(wrapper.find('[data-test="matrix-chart"]').exists()).toBe(true)
expect(wrapper.find('[data-test="traffic-user-table"]').exists()).toBe(true)
})

test('supports clearing and restoring the complete user selection', async () => {
const wrapper = mountTraffic()
await flushPromises()

await wrapper.find('[data-test="user-filter-trigger"]').trigger('click')
await wrapper.find('[data-test="toggle-all-users"]').trigger('click')
expect(wrapper.find('[data-test="traffic-selection-empty"]').exists()).toBe(true)

await wrapper.find('[data-test="restore-users-empty"]').trigger('click')
expect(wrapper.find('[data-test="traffic-selection-empty"]').exists()).toBe(false)
expect(wrapper.find('[data-test="matrix-chart"]').exists()).toBe(true)
})

test('only-user selection changes the chart without another request', async () => {
const wrapper = mountTraffic()
await flushPromises()
getTrafficStatsMock.mockClear()

await wrapper.find('[data-test="user-filter-trigger"]').trigger('click')
await wrapper.find('[data-test="only-u1"]').trigger('click')

expect(getTrafficStatsMock).not.toHaveBeenCalled()
expect(wrapper.find('[data-test="stacked-chart"]').exists()).toBe(true)
})

test('switches timeline views and metrics without redundant refetching', async () => {
const wrapper = mountTraffic()
await flushPromises()
getTrafficStatsMock.mockClear()

await wrapper.find('[data-test="view-frame"]').trigger('click')
await wrapper.find('[data-test="view-direction"]').trigger('click')
await wrapper.find('[data-test="metric-frames"]').trigger('click')

expect(wrapper.find('[data-test="direction-chart"]').exists()).toBe(true)
expect(getTrafficStatsMock).not.toHaveBeenCalled()
})

test('refetches when the date range changes', async () => {
const wrapper = mountTraffic()
await flushPromises()
getTrafficStatsMock.mockClear()

await wrapper.find('[data-test="range-30"]').trigger('click')
await flushPromises()

expect(getTrafficStatsMock).toHaveBeenCalledTimes(1)
expect(getTrafficStatsMock).toHaveBeenCalledWith(expect.objectContaining({ view: 'detail', bucket: 'day' }))
})

test('opens a real user drilldown drawer', async () => {
const wrapper = mountTraffic()
await flushPromises()

await wrapper.find('[data-test="open-user-u1"]').trigger('click')
expect(wrapper.find('[data-test="traffic-user-drawer"]').exists()).toBe(true)
expect(wrapper.find('[data-test="traffic-user-drawer"]').text()).toContain('[email protected]')
})

test('filters the user detail table with a searchable dropdown', async () => {
const wrapper = mountTraffic()
await flushPromises()

const select = wrapper.findComponent(NSelect)
expect(select.attributes('data-test')).toBe('user-detail-filter')
expect(select.props('filterable')).toBe(true)
expect(select.props('clearable')).toBe(true)

select.vm.$emit('update:value', 'u1')
await flushPromises()
expect(wrapper.get('[data-test="traffic-user-table"]').text()).toContain('[email protected]')
expect(wrapper.get('[data-test="traffic-user-table"]').text()).not.toContain('[email protected]')

select.vm.$emit('update:value', null)
await flushPromises()
expect(wrapper.get('[data-test="traffic-user-table"]').text()).toContain('[email protected]')
})
})
Loading
Loading