Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
This commit is contained in:
+224
-3
@@ -1,6 +1,8 @@
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: var(--ctp-text);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
}
|
||||
@@ -86,6 +88,18 @@
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.sidebar-footer-profile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-footer-profile-row .profile-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -119,13 +133,51 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-count-badge {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.35rem;
|
||||
min-height: 1.35rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.2rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-count-badge-alert {
|
||||
border-color: color-mix(in srgb, var(--ctp-red) 45%, var(--ctp-surface1));
|
||||
background: color-mix(in srgb, var(--ctp-red) 14%, transparent);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.config-item,
|
||||
.profile-item {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer-profile-row .logout-item {
|
||||
flex: 0 0 auto;
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
padding: 0.6rem 0.35rem;
|
||||
}
|
||||
|
||||
.cog-icon,
|
||||
.user-icon {
|
||||
.user-icon,
|
||||
.logout-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -237,6 +289,14 @@
|
||||
flex: 1;
|
||||
padding: 2rem 2.5rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content:has(.logs-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
@@ -244,6 +304,7 @@
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ctp-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content p {
|
||||
@@ -1106,7 +1167,101 @@
|
||||
.logs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.logs-filters-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
appearance: none;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
padding: 0.45rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.logs-filters-toggle:hover {
|
||||
border-color: var(--ctp-overlay0);
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.logs-filters-toggle-open {
|
||||
border-color: var(--ctp-overlay0);
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.logs-filters-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 160ms ease;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.logs-filters-chevron-expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.logs-filters-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, var(--ctp-mantle) 70%, var(--ctp-base));
|
||||
}
|
||||
|
||||
.logs-search-label,
|
||||
.logs-page-size-label,
|
||||
.logs-date-custom label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.logs-search-input {
|
||||
min-width: min(100%, 18rem);
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.logs-page-size-label select,
|
||||
.logs-date-custom input {
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.logs-date-custom {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.logs-filters {
|
||||
@@ -1115,6 +1270,14 @@
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.logs-table-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -1129,12 +1292,70 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
.logs-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--ctp-mantle);
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logs-sort-button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.logs-sort-button:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.logs-table code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.logs-table tr.logs-row-failure {
|
||||
background: color-mix(in srgb, var(--ctp-red) 18%, var(--ctp-base));
|
||||
}
|
||||
|
||||
.logs-table tr.logs-row-success {
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.logs-table tr.logs-row-self {
|
||||
color: color-mix(in srgb, var(--ctp-blue) 70%, var(--ctp-text));
|
||||
}
|
||||
|
||||
.logs-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logs-pagination-label,
|
||||
.logs-pagination-page {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.logs-pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.logs-pagination-controls button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
+415
-6
@@ -1,13 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from './App'
|
||||
import { THEME_STORAGE_KEY } from './theme'
|
||||
|
||||
function stubFetchOk() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
@@ -17,6 +15,13 @@ function stubFetchOk() {
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth/logout')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -39,6 +44,7 @@ function stubFetchOk() {
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
'logs.read',
|
||||
],
|
||||
}),
|
||||
})
|
||||
@@ -61,6 +67,13 @@ function stubFetchOk() {
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/node-logs') || url.includes('/api/v1/auth-logs')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ events: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/groups')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -83,6 +96,7 @@ function stubFetchOk() {
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
'logs.read',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -194,8 +208,10 @@ function stubFetchOk() {
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
function stubMatchMedia(prefersDark: boolean) {
|
||||
@@ -226,6 +242,7 @@ describe('App', () => {
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
window.history.replaceState(null, '', '/')
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -248,6 +265,7 @@ describe('App', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Containers' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Configuration' }),
|
||||
).toBeInTheDocument()
|
||||
@@ -259,6 +277,370 @@ describe('App', () => {
|
||||
expect(screen.getByText('Coming soon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows node count badges on resource nav items', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
totp_confirmed: false,
|
||||
totp_enabled: false,
|
||||
permissions: [
|
||||
'nodes.read',
|
||||
'logs.read',
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
service: 'clustercanvas',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth-logs')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ events: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nodes: [
|
||||
{ id: 'c1', kind: 'container', name: 'alpha' },
|
||||
{ id: 'c2', kind: 'container', name: 'beta' },
|
||||
{ id: 'd1', kind: 'docker', name: 'dock' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<App />)
|
||||
|
||||
const containersButton = await screen.findByRole('button', {
|
||||
name: 'Containers',
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
containersButton.querySelector('.nav-count-badge'),
|
||||
).toHaveTextContent('2')
|
||||
})
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'Docker' })
|
||||
.querySelector('.nav-count-badge'),
|
||||
).toHaveTextContent('1')
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'Virtual Machines' })
|
||||
.querySelector('.nav-count-badge'),
|
||||
).toHaveTextContent('0')
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'Activity' })
|
||||
.querySelector('.nav-count-badge-alert'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a red Activity badge for auth failures today', async () => {
|
||||
const todayIso = new Date(
|
||||
new Date().getFullYear(),
|
||||
new Date().getMonth(),
|
||||
new Date().getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
).toISOString()
|
||||
const yesterdayIso = new Date(
|
||||
new Date().getFullYear(),
|
||||
new Date().getMonth(),
|
||||
new Date().getDate() - 1,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
).toISOString()
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
totp_confirmed: false,
|
||||
totp_enabled: false,
|
||||
permissions: ['nodes.read', 'logs.read'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
service: 'clustercanvas',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth-logs')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
events: [
|
||||
{
|
||||
id: 'a1',
|
||||
at: todayIso,
|
||||
action: 'login',
|
||||
outcome: 'failure',
|
||||
category: 'auth',
|
||||
actor: 'eve',
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
at: todayIso,
|
||||
action: 'login',
|
||||
outcome: 'failure',
|
||||
category: 'auth',
|
||||
actor: 'mallory',
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
at: yesterdayIso,
|
||||
action: 'login',
|
||||
outcome: 'failure',
|
||||
category: 'auth',
|
||||
actor: 'old',
|
||||
},
|
||||
{
|
||||
id: 'a4',
|
||||
at: todayIso,
|
||||
action: 'login',
|
||||
outcome: 'success',
|
||||
category: 'auth',
|
||||
actor: 'Admin',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<App />)
|
||||
|
||||
const activityButton = await screen.findByRole('button', {
|
||||
name: 'Activity',
|
||||
})
|
||||
const alertBadge = await waitFor(() => {
|
||||
const badge = activityButton.querySelector('.nav-count-badge-alert')
|
||||
expect(badge).not.toBeNull()
|
||||
return badge as Element
|
||||
})
|
||||
expect(alertBadge).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('polls sidebar counts every 30s with idle-exempt header', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
totp_confirmed: false,
|
||||
totp_enabled: false,
|
||||
permissions: ['nodes.read', 'logs.read'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
service: 'clustercanvas',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth-logs')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ events: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(<App />)
|
||||
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' })
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).includes('/api/v1/nodes'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
const nodesCallsBeforePoll = fetchMock.mock.calls.filter(([input]) =>
|
||||
String(input).includes('/api/v1/nodes'),
|
||||
).length
|
||||
const authCallsBeforePoll = fetchMock.mock.calls.filter(([input]) =>
|
||||
String(input).includes('/api/v1/auth-logs'),
|
||||
).length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
await waitFor(() => {
|
||||
const nodesCalls = fetchMock.mock.calls.filter(([input]) =>
|
||||
String(input).includes('/api/v1/nodes'),
|
||||
)
|
||||
expect(nodesCalls.length).toBeGreaterThan(nodesCallsBeforePoll)
|
||||
})
|
||||
|
||||
const pollNodesCall = [...fetchMock.mock.calls]
|
||||
.reverse()
|
||||
.find(([input]) => String(input).includes('/api/v1/nodes'))
|
||||
expect(pollNodesCall).toBeDefined()
|
||||
const pollNodesHeaders = (pollNodesCall?.[1] as { headers: Headers }).headers
|
||||
expect(pollNodesHeaders.get('X-Session-Idle-Exempt')).toBe('1')
|
||||
|
||||
const pollAuthCall = [...fetchMock.mock.calls]
|
||||
.reverse()
|
||||
.find(([input]) => String(input).includes('/api/v1/auth-logs'))
|
||||
expect(pollAuthCall).toBeDefined()
|
||||
const pollAuthHeaders = (pollAuthCall?.[1] as { headers: Headers }).headers
|
||||
expect(pollAuthHeaders.get('X-Session-Idle-Exempt')).toBe('1')
|
||||
|
||||
expect(authCallsBeforePoll).toBeGreaterThan(0)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('hides Activity logs when logs.read is missing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
totp_confirmed: false,
|
||||
totp_enabled: false,
|
||||
permissions: ['nodes.read', 'users.manage'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url.includes('/health') || url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ok', service: 'clustercanvas', version: '0.1.0' }),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) })
|
||||
}),
|
||||
)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Activity' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens Profile from the sidebar and updates the URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
@@ -281,6 +663,32 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('logs out from the sidebar and shows a signed-out notice', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = stubFetchOk()
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Log out' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Log out' }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Sign in' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/You have been logged out/i)).toBeInTheDocument()
|
||||
expect(window.location.pathname).toBe('/login')
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) =>
|
||||
String(input).includes('/api/v1/auth/logout') &&
|
||||
(init as RequestInit | undefined)?.method === 'POST',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('restores Profile from the URL on load', async () => {
|
||||
stubFetchOk()
|
||||
window.history.replaceState(null, '', '/profile')
|
||||
@@ -423,6 +831,7 @@ describe('App', () => {
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
'logs.read',
|
||||
]) {
|
||||
expect(screen.getByText(permission)).toBeInTheDocument()
|
||||
}
|
||||
|
||||
+781
-100
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,15 @@ describe('LoginPage', () => {
|
||||
expect(window.location.search).toBe('')
|
||||
})
|
||||
|
||||
it('shows a logged-out notice from the query string', () => {
|
||||
window.history.replaceState(null, '', '/login?reason=logout')
|
||||
|
||||
render(<LoginPage onLoggedIn={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/You have been logged out/i)).toBeInTheDocument()
|
||||
expect(window.location.search).toBe('')
|
||||
})
|
||||
|
||||
it('does not show an idle notice without the query reason', () => {
|
||||
window.history.replaceState(null, '', '/login')
|
||||
|
||||
@@ -28,4 +37,25 @@ describe('LoginPage', () => {
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the setup hint only when arriving from setup', () => {
|
||||
window.history.replaceState(null, '', '/login?from=setup')
|
||||
|
||||
render(<LoginPage onLoggedIn={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByText(/Use the administrator account created during setup/i),
|
||||
).toBeInTheDocument()
|
||||
expect(window.location.search).toBe('')
|
||||
})
|
||||
|
||||
it('hides the setup hint on a normal login visit', () => {
|
||||
window.history.replaceState(null, '', '/login')
|
||||
|
||||
render(<LoginPage onLoggedIn={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.queryByText(/Use the administrator account created during setup/i),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+39
-9
@@ -3,24 +3,49 @@ import { login } from './api/client'
|
||||
|
||||
const IDLE_SIGNED_OUT_MESSAGE =
|
||||
'You have been signed out because your idle time limit was reached.'
|
||||
const LOGOUT_SIGNED_OUT_MESSAGE = 'You have been logged out.'
|
||||
const SETUP_HINT_MESSAGE =
|
||||
'Use the administrator account created during setup.'
|
||||
|
||||
type LoginPageProps = {
|
||||
onLoggedIn: (username: string) => void
|
||||
}
|
||||
|
||||
function readIdleSignedOutMessage(): string | null {
|
||||
function consumeLoginQueryFlag(flagName: string, expectedValue: string): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get(flagName) !== expectedValue) {
|
||||
return false
|
||||
}
|
||||
params.delete(flagName)
|
||||
const nextSearch = params.toString()
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
function readSignedOutMessage(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('reason') !== 'idle') {
|
||||
const reason = params.get('reason')
|
||||
let message: string | null = null
|
||||
if (reason === 'idle') {
|
||||
message = IDLE_SIGNED_OUT_MESSAGE
|
||||
} else if (reason === 'logout') {
|
||||
message = LOGOUT_SIGNED_OUT_MESSAGE
|
||||
}
|
||||
if (message === null) {
|
||||
return null
|
||||
}
|
||||
params.delete('reason')
|
||||
const nextSearch = params.toString()
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
return IDLE_SIGNED_OUT_MESSAGE
|
||||
return message
|
||||
}
|
||||
|
||||
export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
@@ -28,7 +53,12 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [idleMessage] = useState<string | null>(() => readIdleSignedOutMessage())
|
||||
const [signedOutMessage] = useState<string | null>(() =>
|
||||
readSignedOutMessage(),
|
||||
)
|
||||
const [showSetupHint] = useState(() =>
|
||||
consumeLoginQueryFlag('from', 'setup'),
|
||||
)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
@@ -50,13 +80,13 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<p className="wizard-brand">Cluster Canvas</p>
|
||||
<h1 className="wizard-title">Sign in</h1>
|
||||
<p className="config-hint">
|
||||
Use the administrator account created during setup.
|
||||
</p>
|
||||
{showSetupHint ? (
|
||||
<p className="config-hint">{SETUP_HINT_MESSAGE}</p>
|
||||
) : null}
|
||||
|
||||
{idleMessage ? (
|
||||
{signedOutMessage ? (
|
||||
<p className="wizard-notice" role="status">
|
||||
{idleMessage}
|
||||
{signedOutMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fetchGroups,
|
||||
fetchHealth,
|
||||
fetchNetwork,
|
||||
fetchAuthLogs,
|
||||
fetchNodeLogs,
|
||||
fetchSecurity,
|
||||
fetchStatus,
|
||||
@@ -396,6 +397,51 @@ describe('fetchNodeLogs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchAuthLogs', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed auth logs payload', async () => {
|
||||
const events = [
|
||||
{
|
||||
id: 'a1',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
action: 'login',
|
||||
outcome: 'success',
|
||||
category: 'auth',
|
||||
actor: 'Admin',
|
||||
target: 'Admin',
|
||||
},
|
||||
]
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ events }),
|
||||
})
|
||||
|
||||
const payload = await fetchAuthLogs(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/auth-logs',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.events).toEqual(events)
|
||||
})
|
||||
|
||||
it('sends idle-exempt header when requested', async () => {
|
||||
const { SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ events: [] }),
|
||||
})
|
||||
|
||||
await fetchAuthLogs(fetchMock, { idleExempt: true })
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
|
||||
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSecurity', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
@@ -565,6 +611,39 @@ describe('nodes API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sends idle-exempt header when requested', async () => {
|
||||
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
|
||||
await fetchNodes(undefined, fetchMock, { idleExempt: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes',
|
||||
expect.objectContaining({
|
||||
credentials: 'include',
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
)
|
||||
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
|
||||
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1')
|
||||
})
|
||||
|
||||
it('omits idle-exempt header by default', async () => {
|
||||
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
|
||||
await fetchNodes(undefined, fetchMock)
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
|
||||
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a node with generate options', async () => {
|
||||
const { createNode } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
|
||||
+41
-1
@@ -156,6 +156,11 @@ async function readErrorMessage(response: Response): Promise<string> {
|
||||
|
||||
const SESSION_IDLE_ERROR = 'session_idle'
|
||||
const IDLE_LOGIN_PATH = '/login?reason=idle'
|
||||
export const SESSION_IDLE_EXEMPT_HEADER = 'X-Session-Idle-Exempt'
|
||||
|
||||
export type ApiFetchOptions = {
|
||||
idleExempt?: boolean
|
||||
}
|
||||
|
||||
function shouldRedirectOnSessionIdle(path: string): boolean {
|
||||
if (path === '/api/v1/auth/login') {
|
||||
@@ -188,11 +193,15 @@ async function apiFetch(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
options: ApiFetchOptions = {},
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (options.idleExempt) {
|
||||
headers.set(SESSION_IDLE_EXEMPT_HEADER, '1')
|
||||
}
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
@@ -680,9 +689,10 @@ export type CreateNodeRequest = {
|
||||
export async function fetchNodes(
|
||||
kind?: NodeKind,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
options: ApiFetchOptions = {},
|
||||
): Promise<NodesResponse> {
|
||||
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
|
||||
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl)
|
||||
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
@@ -793,6 +803,36 @@ export async function fetchNodeLogs(
|
||||
return (await response.json()) as NodeLogsResponse
|
||||
}
|
||||
|
||||
export type AuthAuditOutcome = 'success' | 'failure'
|
||||
|
||||
export type AuthAuditCategory = 'auth' | 'user' | 'group' | 'self'
|
||||
|
||||
export type AuthAuditEvent = {
|
||||
id: string
|
||||
at: string
|
||||
action: string
|
||||
outcome: AuthAuditOutcome
|
||||
category: AuthAuditCategory
|
||||
actor: string
|
||||
target?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export type AuthLogsResponse = {
|
||||
events: ReadonlyArray<AuthAuditEvent>
|
||||
}
|
||||
|
||||
export async function fetchAuthLogs(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
options: ApiFetchOptions = {},
|
||||
): Promise<AuthLogsResponse> {
|
||||
const response = await apiFetch('/api/v1/auth-logs', {}, fetchImpl, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as AuthLogsResponse
|
||||
}
|
||||
|
||||
export type ActionKind = 'shell' | 'script'
|
||||
|
||||
export type ActionEnvVar = {
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
--ctp-subtext0: #6c6f85;
|
||||
--ctp-subtext1: #5c5f77;
|
||||
--ctp-mauve: #8839ef;
|
||||
--ctp-red: #d20f39;
|
||||
--ctp-green: #40a02b;
|
||||
--ctp-blue: #1e66f5;
|
||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
|
||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
|
||||
color-scheme: light;
|
||||
@@ -30,6 +33,9 @@
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
|
||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
|
||||
color-scheme: dark;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AuthAuditEvent, NodeAuditEvent } from './api/client'
|
||||
import {
|
||||
eventMatchesSearch,
|
||||
filterAuthEvents,
|
||||
filterNodeEvents,
|
||||
isWithinDateRange,
|
||||
pageRangeLabel,
|
||||
paginateItems,
|
||||
resolveDateRangeBounds,
|
||||
sortAuthEvents,
|
||||
sortNodeEvents,
|
||||
} from './logsFiltering'
|
||||
|
||||
const nodeEvents: NodeAuditEvent[] = [
|
||||
{
|
||||
id: '1',
|
||||
at: '2026-07-18T12:00:00.000Z',
|
||||
action: 'create',
|
||||
actor: 'Admin',
|
||||
node_id: 'n1',
|
||||
node_name: 'alpha',
|
||||
node_kind: 'container',
|
||||
detail: 'created',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
at: '2026-07-17T12:00:00.000Z',
|
||||
action: 'delete',
|
||||
actor: 'bob',
|
||||
node_id: 'n2',
|
||||
node_name: 'beta-vm',
|
||||
node_kind: 'vm',
|
||||
detail: 'removed',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
at: '2026-07-10T12:00:00.000Z',
|
||||
action: 'test_ssh',
|
||||
actor: 'Admin',
|
||||
node_id: 'n3',
|
||||
node_name: 'gamma',
|
||||
node_kind: 'docker',
|
||||
},
|
||||
]
|
||||
|
||||
const authEvents: AuthAuditEvent[] = [
|
||||
{
|
||||
id: 'a1',
|
||||
at: '2026-07-18T15:00:00.000Z',
|
||||
action: 'login',
|
||||
outcome: 'failure',
|
||||
category: 'auth',
|
||||
actor: 'eve',
|
||||
detail: 'invalid credentials',
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
at: '2026-07-18T14:00:00.000Z',
|
||||
action: 'login',
|
||||
outcome: 'success',
|
||||
category: 'auth',
|
||||
actor: 'Admin',
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
at: '2026-07-17T14:00:00.000Z',
|
||||
action: 'user_create',
|
||||
outcome: 'success',
|
||||
category: 'user',
|
||||
actor: 'Admin',
|
||||
target: 'alice',
|
||||
},
|
||||
]
|
||||
|
||||
describe('eventMatchesSearch', () => {
|
||||
it('matches any field case-insensitively', () => {
|
||||
expect(eventMatchesSearch(['Admin', 'alpha'], 'ADM')).toBe(true)
|
||||
expect(eventMatchesSearch(['Admin', 'alpha'], 'zzz')).toBe(false)
|
||||
expect(eventMatchesSearch(['Admin'], ' ')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDateRangeBounds', () => {
|
||||
const now = new Date('2026-07-18T16:00:00.000Z')
|
||||
|
||||
it('returns null bounds for all', () => {
|
||||
expect(resolveDateRangeBounds('all', '', '', now)).toEqual({
|
||||
from: null,
|
||||
to: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves today to local midnight', () => {
|
||||
const { from, to } = resolveDateRangeBounds('today', '', '', now)
|
||||
expect(to).toBeNull()
|
||||
expect(from).not.toBeNull()
|
||||
expect(from!.getHours()).toBe(0)
|
||||
expect(from!.getMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('parses custom range', () => {
|
||||
const { from, to } = resolveDateRangeBounds(
|
||||
'custom',
|
||||
'2026-07-01T00:00',
|
||||
'2026-07-20T23:59',
|
||||
now,
|
||||
)
|
||||
expect(from).not.toBeNull()
|
||||
expect(to).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWithinDateRange', () => {
|
||||
it('filters by from and to', () => {
|
||||
const from = new Date('2026-07-17T00:00:00.000Z')
|
||||
const to = new Date('2026-07-18T00:00:00.000Z')
|
||||
expect(isWithinDateRange('2026-07-17T12:00:00.000Z', from, to)).toBe(true)
|
||||
expect(isWithinDateRange('2026-07-16T12:00:00.000Z', from, to)).toBe(false)
|
||||
expect(isWithinDateRange('2026-07-19T12:00:00.000Z', from, to)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterNodeEvents', () => {
|
||||
it('filters by kind, action, and search', () => {
|
||||
const filtered = filterNodeEvents(nodeEvents, {
|
||||
search: 'beta',
|
||||
kind: 'vm',
|
||||
action: 'delete',
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
})
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0]?.id).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortNodeEvents', () => {
|
||||
it('sorts by when descending by default key', () => {
|
||||
const sorted = sortNodeEvents(nodeEvents, 'at', 'desc')
|
||||
expect(sorted.map((event) => event.id)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
|
||||
it('sorts by actor ascending', () => {
|
||||
const sorted = sortNodeEvents(nodeEvents, 'actor', 'asc')
|
||||
expect(sorted[0]?.actor).toBe('Admin')
|
||||
expect(sorted[sorted.length - 1]?.actor).toBe('bob')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterAuthEvents', () => {
|
||||
it('filters login failures', () => {
|
||||
const filtered = filterAuthEvents(authEvents, {
|
||||
search: '',
|
||||
outcome: 'all',
|
||||
category: 'all',
|
||||
actionFilter: 'login_failures',
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
})
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0]?.id).toBe('a1')
|
||||
})
|
||||
|
||||
it('filters by outcome and category', () => {
|
||||
const filtered = filterAuthEvents(authEvents, {
|
||||
search: 'alice',
|
||||
outcome: 'success',
|
||||
category: 'user',
|
||||
actionFilter: 'all',
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
})
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0]?.target).toBe('alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortAuthEvents', () => {
|
||||
it('sorts newest first by at', () => {
|
||||
const sorted = sortAuthEvents(authEvents, 'at', 'desc')
|
||||
expect(sorted.map((event) => event.id)).toEqual(['a1', 'a2', 'a3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('paginateItems', () => {
|
||||
it('slices pages and clamps page number', () => {
|
||||
const items = [1, 2, 3, 4, 5]
|
||||
expect(paginateItems(items, 1, 2)).toEqual({
|
||||
pageItems: [1, 2],
|
||||
totalPages: 3,
|
||||
safePage: 1,
|
||||
})
|
||||
expect(paginateItems(items, 99, 2).safePage).toBe(3)
|
||||
expect(paginateItems(items, 99, 2).pageItems).toEqual([5])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageRangeLabel', () => {
|
||||
it('formats the visible range', () => {
|
||||
expect(pageRangeLabel(0, 1, 25)).toBe('Showing 0 of 0')
|
||||
expect(pageRangeLabel(40, 2, 25)).toBe('Showing 26–40 of 40')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
import type {
|
||||
AuthAuditEvent,
|
||||
NodeAuditAction,
|
||||
NodeAuditEvent,
|
||||
NodeKind,
|
||||
} from './api/client'
|
||||
|
||||
export type DateRangePreset = 'all' | 'today' | '7d' | '30d' | 'custom'
|
||||
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export type NodeLogsSortKey =
|
||||
| 'at'
|
||||
| 'action'
|
||||
| 'actor'
|
||||
| 'node'
|
||||
| 'kind'
|
||||
| 'detail'
|
||||
|
||||
export type AuthLogsSortKey =
|
||||
| 'at'
|
||||
| 'action'
|
||||
| 'actor'
|
||||
| 'target'
|
||||
| 'outcome'
|
||||
| 'category'
|
||||
| 'detail'
|
||||
|
||||
export type AuthOutcomeFilter = 'all' | 'failure' | 'success'
|
||||
|
||||
export type AuthCategoryFilter = 'all' | AuthAuditEvent['category']
|
||||
|
||||
export type AuthActionQuickFilter = 'all' | 'login_failures' | string
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function eventMatchesSearch(
|
||||
fields: ReadonlyArray<string | undefined | null>,
|
||||
query: string,
|
||||
): boolean {
|
||||
const normalizedQuery = normalizeSearchText(query)
|
||||
if (normalizedQuery === '') {
|
||||
return true
|
||||
}
|
||||
return fields.some((field) =>
|
||||
normalizeSearchText(field ?? '').includes(normalizedQuery),
|
||||
)
|
||||
}
|
||||
|
||||
export function startOfLocalDay(reference: Date = new Date()): Date {
|
||||
return new Date(
|
||||
reference.getFullYear(),
|
||||
reference.getMonth(),
|
||||
reference.getDate(),
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveDateRangeBounds(
|
||||
preset: DateRangePreset,
|
||||
customFrom: string,
|
||||
customTo: string,
|
||||
now: Date = new Date(),
|
||||
): { from: Date | null; to: Date | null } {
|
||||
if (preset === 'all') {
|
||||
return { from: null, to: null }
|
||||
}
|
||||
if (preset === 'today') {
|
||||
return { from: startOfLocalDay(now), to: null }
|
||||
}
|
||||
if (preset === '7d') {
|
||||
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
return { from, to: null }
|
||||
}
|
||||
if (preset === '30d') {
|
||||
const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
return { from, to: null }
|
||||
}
|
||||
|
||||
const fromParsed = customFrom ? Date.parse(customFrom) : Number.NaN
|
||||
const toParsed = customTo ? Date.parse(customTo) : Number.NaN
|
||||
return {
|
||||
from: Number.isNaN(fromParsed) ? null : new Date(fromParsed),
|
||||
to: Number.isNaN(toParsed) ? null : new Date(toParsed),
|
||||
}
|
||||
}
|
||||
|
||||
export function isWithinDateRange(
|
||||
at: string,
|
||||
from: Date | null,
|
||||
to: Date | null,
|
||||
): boolean {
|
||||
const timestamp = Date.parse(at)
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return false
|
||||
}
|
||||
if (from !== null && timestamp < from.getTime()) {
|
||||
return false
|
||||
}
|
||||
if (to !== null && timestamp > to.getTime()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function compareStrings(left: string, right: string): number {
|
||||
return left.localeCompare(right, undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function compareTimestamps(left: string, right: string): number {
|
||||
const leftTime = Date.parse(left)
|
||||
const rightTime = Date.parse(right)
|
||||
if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) {
|
||||
return 0
|
||||
}
|
||||
if (Number.isNaN(leftTime)) {
|
||||
return 1
|
||||
}
|
||||
if (Number.isNaN(rightTime)) {
|
||||
return -1
|
||||
}
|
||||
return leftTime - rightTime
|
||||
}
|
||||
|
||||
function applySortDirection(comparison: number, direction: SortDirection): number {
|
||||
return direction === 'asc' ? comparison : -comparison
|
||||
}
|
||||
|
||||
export function filterNodeEvents(
|
||||
events: ReadonlyArray<NodeAuditEvent>,
|
||||
options: {
|
||||
search: string
|
||||
kind: 'all' | NodeKind
|
||||
action: 'all' | NodeAuditAction
|
||||
dateFrom: Date | null
|
||||
dateTo: Date | null
|
||||
},
|
||||
): NodeAuditEvent[] {
|
||||
return events.filter((event) => {
|
||||
if (options.kind !== 'all' && event.node_kind !== options.kind) {
|
||||
return false
|
||||
}
|
||||
if (options.action !== 'all' && event.action !== options.action) {
|
||||
return false
|
||||
}
|
||||
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
|
||||
return false
|
||||
}
|
||||
return eventMatchesSearch(
|
||||
[
|
||||
event.at,
|
||||
event.action,
|
||||
event.actor,
|
||||
event.node_name,
|
||||
event.node_id,
|
||||
event.node_kind,
|
||||
event.detail,
|
||||
],
|
||||
options.search,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function sortNodeEvents(
|
||||
events: ReadonlyArray<NodeAuditEvent>,
|
||||
sortKey: NodeLogsSortKey,
|
||||
direction: SortDirection,
|
||||
): NodeAuditEvent[] {
|
||||
const sorted = [...events]
|
||||
sorted.sort((left, right) => {
|
||||
let comparison = 0
|
||||
switch (sortKey) {
|
||||
case 'at':
|
||||
comparison = compareTimestamps(left.at, right.at)
|
||||
break
|
||||
case 'action':
|
||||
comparison = compareStrings(left.action, right.action)
|
||||
break
|
||||
case 'actor':
|
||||
comparison = compareStrings(left.actor, right.actor)
|
||||
break
|
||||
case 'node':
|
||||
comparison = compareStrings(left.node_name, right.node_name)
|
||||
break
|
||||
case 'kind':
|
||||
comparison = compareStrings(left.node_kind, right.node_kind)
|
||||
break
|
||||
case 'detail':
|
||||
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
|
||||
break
|
||||
}
|
||||
return applySortDirection(comparison, direction)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
export function filterAuthEvents(
|
||||
events: ReadonlyArray<AuthAuditEvent>,
|
||||
options: {
|
||||
search: string
|
||||
outcome: AuthOutcomeFilter
|
||||
category: AuthCategoryFilter
|
||||
actionFilter: AuthActionQuickFilter
|
||||
dateFrom: Date | null
|
||||
dateTo: Date | null
|
||||
},
|
||||
): AuthAuditEvent[] {
|
||||
return events.filter((event) => {
|
||||
if (options.outcome !== 'all' && event.outcome !== options.outcome) {
|
||||
return false
|
||||
}
|
||||
if (options.category !== 'all' && event.category !== options.category) {
|
||||
return false
|
||||
}
|
||||
if (options.actionFilter === 'login_failures') {
|
||||
if (event.action !== 'login' || event.outcome !== 'failure') {
|
||||
return false
|
||||
}
|
||||
} else if (
|
||||
options.actionFilter !== 'all' &&
|
||||
event.action !== options.actionFilter
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
|
||||
return false
|
||||
}
|
||||
return eventMatchesSearch(
|
||||
[
|
||||
event.at,
|
||||
event.action,
|
||||
event.outcome,
|
||||
event.category,
|
||||
event.actor,
|
||||
event.target,
|
||||
event.detail,
|
||||
],
|
||||
options.search,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function sortAuthEvents(
|
||||
events: ReadonlyArray<AuthAuditEvent>,
|
||||
sortKey: AuthLogsSortKey,
|
||||
direction: SortDirection,
|
||||
): AuthAuditEvent[] {
|
||||
const sorted = [...events]
|
||||
sorted.sort((left, right) => {
|
||||
let comparison = 0
|
||||
switch (sortKey) {
|
||||
case 'at':
|
||||
comparison = compareTimestamps(left.at, right.at)
|
||||
break
|
||||
case 'action':
|
||||
comparison = compareStrings(left.action, right.action)
|
||||
break
|
||||
case 'actor':
|
||||
comparison = compareStrings(left.actor, right.actor)
|
||||
break
|
||||
case 'target':
|
||||
comparison = compareStrings(left.target ?? '', right.target ?? '')
|
||||
break
|
||||
case 'outcome':
|
||||
comparison = compareStrings(left.outcome, right.outcome)
|
||||
break
|
||||
case 'category':
|
||||
comparison = compareStrings(left.category, right.category)
|
||||
break
|
||||
case 'detail':
|
||||
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
|
||||
break
|
||||
}
|
||||
return applySortDirection(comparison, direction)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
export function paginateItems<T>(
|
||||
items: ReadonlyArray<T>,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): { pageItems: T[]; totalPages: number; safePage: number } {
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize))
|
||||
const safePage = Math.min(Math.max(1, page), totalPages)
|
||||
const startIndex = (safePage - 1) * pageSize
|
||||
return {
|
||||
pageItems: items.slice(startIndex, startIndex + pageSize),
|
||||
totalPages,
|
||||
safePage,
|
||||
}
|
||||
}
|
||||
|
||||
export function pageRangeLabel(
|
||||
totalCount: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): string {
|
||||
if (totalCount === 0) {
|
||||
return 'Showing 0 of 0'
|
||||
}
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, totalCount)
|
||||
return `Showing ${start}–${end} of ${totalCount}`
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_LOGS_PAGE_SIZE,
|
||||
isLogsPageSize,
|
||||
LOGS_PAGE_SIZE_STORAGE_KEY,
|
||||
readLogsPageSize,
|
||||
writeLogsPageSize,
|
||||
} from './logsPreferences'
|
||||
|
||||
describe('logsPreferences', () => {
|
||||
it('defaults to 25', () => {
|
||||
const storage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
}
|
||||
expect(readLogsPageSize(storage)).toBe(DEFAULT_LOGS_PAGE_SIZE)
|
||||
})
|
||||
|
||||
it('reads and writes a valid page size', () => {
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value)
|
||||
},
|
||||
}
|
||||
writeLogsPageSize(100, storage)
|
||||
expect(values.get(LOGS_PAGE_SIZE_STORAGE_KEY)).toBe('100')
|
||||
expect(readLogsPageSize(storage)).toBe(100)
|
||||
})
|
||||
|
||||
it('rejects invalid sizes', () => {
|
||||
expect(isLogsPageSize(25)).toBe(true)
|
||||
expect(isLogsPageSize(30)).toBe(false)
|
||||
const storage = {
|
||||
getItem: () => '9999',
|
||||
setItem: () => undefined,
|
||||
}
|
||||
expect(readLogsPageSize(storage)).toBe(DEFAULT_LOGS_PAGE_SIZE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
export const LOGS_PAGE_SIZE_STORAGE_KEY = 'clustercanvas.logs.pageSize'
|
||||
|
||||
export const LOGS_PAGE_SIZE_OPTIONS = [
|
||||
25, 50, 100, 250, 500, 1000,
|
||||
] as const
|
||||
|
||||
export type LogsPageSize = (typeof LOGS_PAGE_SIZE_OPTIONS)[number]
|
||||
|
||||
export const DEFAULT_LOGS_PAGE_SIZE: LogsPageSize = 25
|
||||
|
||||
const VALID_PAGE_SIZES: ReadonlySet<number> = new Set(LOGS_PAGE_SIZE_OPTIONS)
|
||||
|
||||
export function isLogsPageSize(value: number): value is LogsPageSize {
|
||||
return VALID_PAGE_SIZES.has(value)
|
||||
}
|
||||
|
||||
export function readLogsPageSize(
|
||||
storage: Pick<Storage, 'getItem'> = localStorage,
|
||||
): LogsPageSize {
|
||||
const storedValue = storage.getItem(LOGS_PAGE_SIZE_STORAGE_KEY)
|
||||
if (storedValue === null) {
|
||||
return DEFAULT_LOGS_PAGE_SIZE
|
||||
}
|
||||
const parsed = Number.parseInt(storedValue, 10)
|
||||
if (Number.isFinite(parsed) && isLogsPageSize(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
return DEFAULT_LOGS_PAGE_SIZE
|
||||
}
|
||||
|
||||
export function writeLogsPageSize(
|
||||
pageSize: LogsPageSize,
|
||||
storage: Pick<Storage, 'setItem'> = localStorage,
|
||||
): void {
|
||||
storage.setItem(LOGS_PAGE_SIZE_STORAGE_KEY, String(pageSize))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AuthAuditEvent, Node } from './api/client'
|
||||
import {
|
||||
countAuthFailuresToday,
|
||||
countNodesBySection,
|
||||
} from './sidebarCounts'
|
||||
|
||||
function makeNode(kind: Node['kind'], id: string): Pick<Node, 'kind' | 'id'> {
|
||||
return { kind, id }
|
||||
}
|
||||
|
||||
describe('countNodesBySection', () => {
|
||||
it('returns zeros for an empty list', () => {
|
||||
expect(countNodesBySection([])).toEqual({
|
||||
containers: 0,
|
||||
docker: 0,
|
||||
vms: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts nodes by kind into section ids', () => {
|
||||
expect(
|
||||
countNodesBySection([
|
||||
makeNode('container', 'c1'),
|
||||
makeNode('container', 'c2'),
|
||||
makeNode('docker', 'd1'),
|
||||
makeNode('vm', 'v1'),
|
||||
makeNode('vm', 'v2'),
|
||||
makeNode('vm', 'v3'),
|
||||
]),
|
||||
).toEqual({
|
||||
containers: 2,
|
||||
docker: 1,
|
||||
vms: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAuthFailuresToday', () => {
|
||||
const now = new Date(2026, 6, 18, 15, 30, 0)
|
||||
|
||||
function makeEvent(
|
||||
at: string,
|
||||
outcome: AuthAuditEvent['outcome'],
|
||||
action = 'login',
|
||||
): Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'> {
|
||||
return { at, action, outcome }
|
||||
}
|
||||
|
||||
it('counts only login failures within the local day', () => {
|
||||
expect(
|
||||
countAuthFailuresToday(
|
||||
[
|
||||
makeEvent('2026-07-18T08:00:00.000Z', 'failure'),
|
||||
makeEvent('2026-07-18T10:00:00.000Z', 'success'),
|
||||
makeEvent('2026-07-18T12:00:00.000Z', 'failure'),
|
||||
makeEvent('2026-07-16T12:00:00.000Z', 'failure'),
|
||||
makeEvent('2026-07-18T14:00:00.000Z', 'failure', 'logout'),
|
||||
makeEvent('2026-07-18T15:00:00.000Z', 'failure', 'user_create'),
|
||||
],
|
||||
now,
|
||||
),
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zero when there are no login failures today', () => {
|
||||
expect(
|
||||
countAuthFailuresToday(
|
||||
[
|
||||
makeEvent('2026-07-18T10:00:00.000Z', 'success'),
|
||||
makeEvent('2026-07-17T10:00:00.000Z', 'failure'),
|
||||
makeEvent('2026-07-18T11:00:00.000Z', 'failure', 'reauth'),
|
||||
],
|
||||
now,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AuthAuditEvent, Node, NodeKind } from './api/client'
|
||||
import type { ResourceSectionId } from './navigation'
|
||||
import { isWithinDateRange, startOfLocalDay } from './logsFiltering'
|
||||
|
||||
const KIND_TO_SECTION: Record<NodeKind, ResourceSectionId> = {
|
||||
container: 'containers',
|
||||
docker: 'docker',
|
||||
vm: 'vms',
|
||||
}
|
||||
|
||||
export type NodeSectionCounts = Record<ResourceSectionId, number>
|
||||
|
||||
export function countNodesBySection(
|
||||
nodes: ReadonlyArray<Pick<Node, 'kind'>>,
|
||||
): NodeSectionCounts {
|
||||
const counts: NodeSectionCounts = {
|
||||
containers: 0,
|
||||
docker: 0,
|
||||
vms: 0,
|
||||
}
|
||||
for (const node of nodes) {
|
||||
counts[KIND_TO_SECTION[node.kind]] += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function countAuthFailuresToday(
|
||||
events: ReadonlyArray<Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'>>,
|
||||
now: Date = new Date(),
|
||||
): number {
|
||||
const from = startOfLocalDay(now)
|
||||
let failureCount = 0
|
||||
for (const event of events) {
|
||||
if (event.action !== 'login' || event.outcome !== 'failure') {
|
||||
continue
|
||||
}
|
||||
if (!isWithinDateRange(event.at, from, null)) {
|
||||
continue
|
||||
}
|
||||
failureCount += 1
|
||||
}
|
||||
return failureCount
|
||||
}
|
||||
Reference in New Issue
Block a user