Compare commits

...

6 Commits

Author SHA1 Message Date
hsiegeln
2ffc268b44 feat: add attribute search category to CommandPalette
All checks were successful
Build & Publish / publish (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:03:57 +01:00
hsiegeln
99ae66315b feat: add trace log level to LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:04:50 +01:00
hsiegeln
26de5ec58f feat: add click-to-close backdrop behind DetailPanel
All checks were successful
Build & Publish / publish (push) Successful in 58s
Adds a subtle semi-transparent backdrop (rgba(0,0,0,0.15)) behind the
overlay panel. Clicking the backdrop closes the panel. Fades in with
the panel animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:57:34 +01:00
hsiegeln
d26dc6a8a5 fix: make DetailPanel overlay instead of taking flex space
All checks were successful
Build & Publish / publish (push) Successful in 1m1s
DetailPanel now uses position: fixed to overlay the content area rather
than participating in the AppShell flex row. This prevents the TopBar
from being compressed when the panel opens. Added box-shadow for depth
separation and z-index: 100 for stacking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:55:02 +01:00
hsiegeln
c0b1cbdc5b fix: remove inset background from LogViewer container
All checks were successful
Build & Publish / publish (push) Successful in 1m30s
LogViewer was using --bg-inset which created a visual mismatch with the
EventFeed timeline panel. Now inherits the parent card's background
(--bg-surface) for consistent appearance in side-by-side layouts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:24:35 +01:00
hsiegeln
d101d883a9 fix: auto-scroll to top for EventFeed and LogViewer
All checks were successful
Build & Publish / publish (push) Successful in 52s
Newest entries appear at the top in descending sort, so auto-scroll
should snap to top instead of bottom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:45:30 +01:00
9 changed files with 90 additions and 58 deletions

View File

@@ -19,6 +19,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All', all: 'All',
application: 'Applications', application: 'Applications',
exchange: 'Exchanges', exchange: 'Exchanges',
attribute: 'Attributes',
route: 'Routes', route: 'Routes',
agent: 'Agents', agent: 'Agents',
} }
@@ -27,6 +28,7 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all', 'all',
'application', 'application',
'exchange', 'exchange',
'attribute',
'route', 'route',
'agent', 'agent',
] ]

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent' export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
export interface SearchResult { export interface SearchResult {
id: string id: string

View File

@@ -1,4 +1,21 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.15);
z-index: 99;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel { .panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 0; width: 0;
overflow: hidden; overflow: hidden;
transition: width 0.25s ease, opacity 0.2s ease; transition: width 0.25s ease, opacity 0.2s ease;
@@ -7,13 +24,15 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-surface); background: var(--bg-surface);
flex-shrink: 0; z-index: 100;
box-shadow: none;
} }
.panel.open { .panel.open {
width: 400px; width: 400px;
opacity: 1; opacity: 1;
border-left-color: var(--border); border-left-color: var(--border);
box-shadow: var(--shadow-lg);
animation: slideInRight 0.25s ease-out both; animation: slideInRight 0.25s ease-out both;
} }

View File

@@ -23,51 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
const activeContent = tabs?.find((t) => t.value === activeTab)?.content const activeContent = tabs?.find((t) => t.value === activeTab)?.content
const panel = ( const content = (
<aside <>
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`} {open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
aria-hidden={!open} <aside
> className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
<div className={styles.header}> aria-hidden={!open}
<span className={styles.title}>{title}</span> >
<button <div className={styles.header}>
className={styles.closeBtn} <span className={styles.title}>{title}</span>
onClick={onClose} <button
aria-label="Close panel" className={styles.closeBtn}
type="button" onClick={onClose}
> aria-label="Close panel"
&times; type="button"
</button> >
</div> &times;
</button>
{tabs && tabs.length > 0 && (
<div className={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.value}
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
onClick={() => setActiveTab(tab.value)}
type="button"
>
{tab.label}
</button>
))}
</div> </div>
)}
<div className={styles.body}> {tabs && tabs.length > 0 && (
{children ?? activeContent} <div className={styles.tabs}>
</div> {tabs.map((tab) => (
<button
key={tab.value}
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
onClick={() => setActiveTab(tab.value)}
type="button"
>
{tab.label}
</button>
))}
</div>
)}
{actions && ( <div className={styles.body}>
<div className={styles.actions}> {children ?? activeContent}
{actions}
</div> </div>
)}
</aside> {actions && (
<div className={styles.actions}>
{actions}
</div>
)}
</aside>
</>
) )
// Portal to AppShell level if target exists, otherwise render in place // Portal to AppShell level if target exists, otherwise render in place
const portalTarget = document.getElementById('cameleer-detail-panel-root') const portalTarget = document.getElementById('cameleer-detail-panel-root')
return portalTarget ? createPortal(panel, portalTarget) : panel return portalTarget ? createPortal(content, portalTarget) : content
} }

View File

@@ -81,25 +81,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity)) .filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower)) .filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
// Auto-scroll to bottom // Auto-scroll to top (newest entries are at top in desc sort)
const scrollToBottom = useCallback(() => { const scrollToTop = useCallback(() => {
const el = scrollRef.current const el = scrollRef.current
if (el) { if (el) {
el.scrollTop = el.scrollHeight el.scrollTop = 0
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!isPaused) { if (!isPaused) {
scrollToBottom() scrollToTop()
} }
}, [events, isPaused, scrollToBottom]) }, [events, isPaused, scrollToTop])
function handleScroll() { function handleScroll() {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8 const atTop = el.scrollTop < 8
setIsPaused(!atBottom) setIsPaused(!atTop)
} }
function toggleFilter(severity: SeverityFilter) { function toggleFilter(severity: SeverityFilter) {
@@ -196,10 +196,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
className={styles.resumeBtn} className={styles.resumeBtn}
onClick={() => { onClick={() => {
setIsPaused(false) setIsPaused(false)
scrollToBottom() scrollToTop()
}} }}
> >
Resume auto-scroll &uarr; Scroll to latest
</button> </button>
)} )}
</div> </div>

View File

@@ -1,7 +1,5 @@
.container { .container {
overflow-y: auto; overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0; padding: 8px 0;
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -58,6 +56,11 @@
background: color-mix(in srgb, var(--text-muted) 10%, transparent); background: color-mix(in srgb, var(--text-muted) 10%, transparent);
} }
.levelTrace {
color: var(--text-faint);
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
}
.message { .message {
font-size: 12px; font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);

View File

@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' }, { timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' }, { timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' }, { timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
] ]
describe('LogViewer', () => { describe('LogViewer', () => {
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
expect(screen.getByText('High memory usage')).toBeInTheDocument() expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument() expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument() expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
}) })
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => { it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
render(<LogViewer entries={entries} />) render(<LogViewer entries={entries} />)
expect(screen.getByText('INFO')).toBeInTheDocument() expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument() expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument() expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument() expect(screen.getByText('DEBUG')).toBeInTheDocument()
expect(screen.getByText('TRACE')).toBeInTheDocument()
}) })
it('renders with custom maxHeight (number)', () => { it('renders with custom maxHeight (number)', () => {

View File

@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
export interface LogEntry { export interface LogEntry {
timestamp: string timestamp: string
level: 'info' | 'warn' | 'error' | 'debug' level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
message: string message: string
} }
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
warn: styles.levelWarn, warn: styles.levelWarn,
error: styles.levelError, error: styles.levelError,
debug: styles.levelDebug, debug: styles.levelDebug,
trace: styles.levelTrace,
} }
function formatTime(iso: string): string { function formatTime(iso: string): string {
@@ -35,18 +36,18 @@ function formatTime(iso: string): string {
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) { export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) const isAtTopRef = useRef(true)
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20 isAtTopRef.current = el.scrollTop < 20
}, []) }, [])
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (el && isAtBottomRef.current) { if (el && isAtTopRef.current) {
el.scrollTop = el.scrollHeight el.scrollTop = 0
} }
}, [entries]) }, [entries])

View File

@@ -2,6 +2,7 @@
display: flex; display: flex;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
position: relative;
} }
.main { .main {