diff --git a/cmd/run.go b/cmd/run.go index 8df668dee..b690e59b6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -25,6 +25,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/samber/lo" + "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/spf13/cobra" @@ -87,6 +88,9 @@ var runCmd = &cobra.Command{ tui.CheckErr(err) defer logWriter.Close() + // Initialize the system service log logger + system.InitializeServiceLogger(proj.Directory) + teaOptions := []tea.ProgramOption{} if isNonInteractive() { teaOptions = append(teaOptions, tea.WithoutRenderer(), tea.WithInput(nil)) @@ -207,10 +211,20 @@ var runCmd = &cobra.Command{ close(stopChan) }() + logger := system.GetServiceLogger() + for { select { case update := <-allUpdates: fmt.Printf("%s [%s]: %s", update.ServiceName, update.Status, update.Message) + // Write log to file + level := logrus.InfoLevel + + if update.Status == project.ServiceRunStatus_Error { + level = logrus.ErrorLevel + } + + logger.WriteLog(level, update.Message, update.Label) case <-stopChan: fmt.Println("Shutting down services - exiting") return nil diff --git a/cmd/start.go b/cmd/start.go index b1d6c781b..9de383df8 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -34,6 +34,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/samber/lo" + "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/spf13/cobra" @@ -167,6 +168,9 @@ var startCmd = &cobra.Command{ tui.CheckErr(err) defer logWriter.Close() + // Initialize the system service log logger + system.InitializeServiceLogger(proj.Directory) + teaOptions := []tea.ProgramOption{} if isNonInteractive() { teaOptions = append(teaOptions, tea.WithoutRenderer(), tea.WithInput(nil)) @@ -265,10 +269,20 @@ var startCmd = &cobra.Command{ close(stopChan) }() + logger := system.GetServiceLogger() + for { select { case update := <-allUpdates: fmt.Printf("%s [%s]: %s", update.ServiceName, update.Status, update.Message) + // Write log to file + level := logrus.InfoLevel + + if update.Status == project.ServiceRunStatus_Error { + level = logrus.ErrorLevel + } + + logger.WriteLog(level, update.Message, update.Label) case <-stopChan: fmt.Println("Shutting down services - exiting") return nil diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index f79f5c3a1..0f6061435 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -738,6 +738,8 @@ func (d *Dashboard) Start() error { http.HandleFunc("/api/ws-clear-messages", d.handleWebsocketMessagesClear()) + http.HandleFunc("/api/logs", d.createServiceLogsHandler(d.project)) + d.wsWebSocket.HandleConnect(func(s *melody.Session) { // Send a welcome message to the client err := d.sendWebsocketsUpdate() diff --git a/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts b/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts index 4eb936038..2a67282e7 100644 --- a/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts @@ -7,7 +7,9 @@ describe('a11y test suite', () => { '/storage', '/secrets', '/topics', + '/jobs', '/websockets', + '/logs', '/not-found', ] diff --git a/pkg/dashboard/frontend/cypress/e2e/logs.cy.ts b/pkg/dashboard/frontend/cypress/e2e/logs.cy.ts new file mode 100644 index 000000000..02ce66624 --- /dev/null +++ b/pkg/dashboard/frontend/cypress/e2e/logs.cy.ts @@ -0,0 +1,91 @@ +describe('logs test suite', () => { + beforeEach(() => { + cy.viewport('macbook-16') + + cy.visit('/logs') + + cy.wait(500) + }) + + it(`Should create logs`, () => { + cy.getTestEl('logs').children().should('have.length.above', 2) + + const expectedMessages = [ + 'started service services/my-test-secret.ts', + 'started service services/my-test-service.ts', + 'started service services/my-test-db.ts', + ] + + expectedMessages.forEach((message) => { + cy.getTestEl('logs').should('contain.text', message) + }) + }) + + it(`Should search with correct results`, () => { + cy.getTestEl('logs').children().should('have.length.above', 2) + + cy.getTestEl('log-search').type( + 'started service services/my-test-secret.ts', + ) + + cy.getTestEl('logs').children().should('have.length', 1) + + cy.getTestEl('log-search').clear() + + cy.getTestEl('logs').children().should('have.length.above', 2) + }) + + it(`Should filter origin with correct results`, () => { + cy.getTestEl('logs').children().should('have.length.above', 2) + + cy.getTestEl('filter-logs-btn').click() + + cy.getTestEl('filter-origin-collapsible').click() + + cy.getTestEl('origin-select').click() + + cy.get('div[data-value="nitric"]').click() + + cy.getTestEl('logs').children().should('have.length', 3) + + cy.getTestEl('filter-logs-reset-btn').click() + + cy.getTestEl('logs').children().should('have.length.above', 2) + }) + + it(`Should pre-populate filters via url param`, () => { + cy.visit( + '/logs?origin=nitric%2Cservices/my-test-db.ts&level=info&timeline=pastHour', + ) + + cy.getTestEl('filter-logs-btn').click() + + cy.getTestEl('filter-timeline-collapsible').click() + cy.getTestEl('filter-contains-level-collapsible').click() + cy.getTestEl('filter-origin-collapsible').click() + + cy.getTestEl('timeline-select-trigger').should('contain.text', 'Past Hour') + cy.getTestEl('level-select').should('contain.text', 'Info') + cy.getTestEl('origin-select').should( + 'contain.text', + 'nitricservices/my-test-db.ts', + ) + }) + + it(`Should purge logs`, () => { + cy.getTestEl('logs').children().should('have.length.above', 2) + + cy.intercept('DELETE', '/api/logs').as('purge') + + cy.getTestEl('log-options-btn').click() + + cy.getTestEl('purge-logs-btn').click() + + cy.wait('@purge') + + cy.getTestEl('logs') + .children() + .first() + .should('have.text', 'No logs available') + }) +}) diff --git a/pkg/dashboard/frontend/package.json b/pkg/dashboard/frontend/package.json index 81d36e770..d9d458b93 100644 --- a/pkg/dashboard/frontend/package.json +++ b/pkg/dashboard/frontend/package.json @@ -23,25 +23,28 @@ "@codemirror/lint": "^6.8.1", "@dagrejs/dagre": "^1.0.4", "@fontsource/archivo": "^4.5.11", + "@fontsource/jetbrains-mono": "^5.1.2", "@fontsource/sora": "^4.5.12", "@heroicons/react": "^2.1.1", "@prantlf/jsonlint": "^14.0.3", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-context-menu": "^2.2.1", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^1.2.2", - "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", - "@radix-ui/react-tooltip": "^1.0.7", + "@radix-ui/react-tooltip": "^1.1.6", "@tailwindcss/forms": "^0.5.7", "@tanstack/react-table": "^8.20.1", + "@tanstack/react-virtual": "^3.11.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@uiw/codemirror-extensions-langs": "^4.23.0", @@ -50,13 +53,15 @@ "astro-fathom": "^2.0.0", "chonky": "^2.3.2", "chonky-icon-fontawesome": "^2.3.2", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "1.0.0", "cronstrue": "^2.50.0", "date-fns": "^3.6.0", + "fancy-ansi": "^0.1.3", "html-to-image": "^1.11.11", "lottie-react": "^2.4.0", - "lucide-react": "^0.261.0", + "lucide-react": "^0.471.1", "radash": "^12.1.0", "react": "^18.3.1", "react-complex-tree": "^2.4.4", diff --git a/pkg/dashboard/frontend/src/components/layout/AppLayout/NavigationBar.tsx b/pkg/dashboard/frontend/src/components/layout/AppLayout/NavigationBar.tsx index cc35b751e..395a7afdf 100644 --- a/pkg/dashboard/frontend/src/components/layout/AppLayout/NavigationBar.tsx +++ b/pkg/dashboard/frontend/src/components/layout/AppLayout/NavigationBar.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react' import type { NavigationItemProps } from './NavigationItem' import NavigationItem from './NavigationItem' import { Separator } from '@/components/ui/separator' -import { HeartIcon, MapIcon } from '@heroicons/react/24/outline' +import { ListBulletIcon, HeartIcon, MapIcon } from '@heroicons/react/24/outline' interface NavigationBarProps { navigation: Omit[] @@ -46,6 +46,13 @@ const NavigationBar: React.FC = ({ href="/architecture" onClick={closeNavigation} /> +
    diff --git a/pkg/dashboard/frontend/src/components/logs/FilterSidebar.tsx b/pkg/dashboard/frontend/src/components/logs/FilterSidebar.tsx new file mode 100644 index 000000000..f84a665d0 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/logs/FilterSidebar.tsx @@ -0,0 +1,185 @@ +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, +} from '@/components/ui/sidebar' +import { Button } from '../ui/button' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '../ui/collapsible' +import { ChevronRightIcon } from '@heroicons/react/24/outline' +import { useMemo, type PropsWithChildren } from 'react' +import { MultiSelect } from '../shared/MultiSelect' +import { useParams } from '@/hooks/use-params' +import { useWebSocket } from '@/lib/hooks/use-web-socket' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select' + +interface CollapsibleGroupProps extends PropsWithChildren { + title: string + defaultOpen?: boolean +} + +const CollapsibleGroup = ({ + title, + children, + defaultOpen, +}: CollapsibleGroupProps) => { + return ( + + + + + + {title} + + + + {children} + + + + ) +} + +const levelsList = [ + { value: 'error', label: 'Error' }, + { value: 'warning', label: 'Warning' }, + { value: 'info', label: 'Info' }, +] + +export function FilterSidebar() { + const { data } = useWebSocket() + const { searchParams, setParams } = useParams() + + const levels = + searchParams + .get('level') + ?.split(',') + .filter((l) => levelsList.some((o) => o.value === l)) ?? [] + const origins = + searchParams + .get('origin') + ?.split(',') + .filter((o) => { + return ( + o === 'nitric' || + data?.services.some((service) => service.name === o) || + data?.batchServices.some((service) => service.name === o) + ) + }) ?? [] + const timeline = searchParams.get('timeline') + + const originsList = useMemo(() => { + return [ + { + label: 'nitric', + value: 'nitric', + }, + ...(data?.services.map((service) => ({ + value: service.name, + label: service.name, + })) ?? []), + ...(data?.batchServices.map((service) => ({ + value: service.name, + label: service.name, + })) ?? []), + ] + }, [data?.services]) + + const handleResetFilters = () => { + setParams('level', null) + setParams('origin', null) + setParams('timeline', null) + } + + return ( + + + + + Filters + + + + + + + + + + setParams('level', value.join(','))} + value={levels} + placeholder="Select severity levels" + variant="inverted" + disableSelectAll + data-testid="level-select" + maxCount={3} + /> + + + setParams('origin', value.join(','))} + value={origins} + placeholder="Select origins" + variant="inverted" + data-testid="origin-select" + disableSelectAll + maxCount={3} + /> + + + + + + ) +} diff --git a/pkg/dashboard/frontend/src/components/logs/FilterTrigger.tsx b/pkg/dashboard/frontend/src/components/logs/FilterTrigger.tsx new file mode 100644 index 000000000..be67c183e --- /dev/null +++ b/pkg/dashboard/frontend/src/components/logs/FilterTrigger.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { useSidebar } from '../ui/sidebar' +import { Button } from '../ui/button' +import { FunnelIcon } from '@heroicons/react/24/outline' + +const FilterTrigger: React.FC = () => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +export default FilterTrigger diff --git a/pkg/dashboard/frontend/src/components/logs/LogsExplorer.tsx b/pkg/dashboard/frontend/src/components/logs/LogsExplorer.tsx new file mode 100644 index 000000000..e39a67112 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/logs/LogsExplorer.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useMemo, useRef } from 'react' +import AppLayout from '../layout/AppLayout' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { useLogs } from '@/lib/hooks/use-logs' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { format } from 'date-fns/format' +import { formatDistanceToNow } from 'date-fns/formatDistanceToNow' +import { + ArrowDownOnSquareIcon, + EllipsisVerticalIcon, + MagnifyingGlassIcon, + TrashIcon, +} from '@heroicons/react/24/outline' + +import TextField from '../shared/TextField' +import { debounce } from 'radash' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu' +import type { LogEntry } from '@/types' +import { SidebarInset, SidebarProvider } from '../ui/sidebar' +import { FilterSidebar } from './FilterSidebar' +import FilterTrigger from './FilterTrigger' +import { ParamsProvider, useParams } from '@/hooks/use-params' +import { AnsiHtml } from 'fancy-ansi/react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { ScrollArea } from '../ui/scroll-area' +import { Portal as TooltipPortal } from '@radix-ui/react-tooltip' + +const exportJSON = async (logs: LogEntry[]) => { + const json = JSON.stringify(logs, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `logs-${new Date().toISOString()}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +const Logs: React.FC = () => { + const parentRef = useRef(null) + + const { searchParams, setParams } = useParams() + + const { + data: logs, + purgeLogs, + mutate, + } = useLogs({ + search: searchParams.get('search') ?? undefined, + origin: searchParams.get('origin') ?? undefined, + level: (searchParams.get('level') as LogEntry['level']) ?? undefined, + timeline: searchParams.get('timeline') ?? undefined, + }) + + const debouncedSearch = debounce({ delay: 500 }, (search: string) => { + setParams('search', search) + }) + + useEffect(() => { + mutate() + }, [searchParams]) + + const formattedLogs = useMemo( + () => + logs.map((log) => ({ + ...log, + formattedTime: format(new Date(log.time), 'MMM dd, HH:mm:ss.SS'), + relativeTime: formatDistanceToNow(new Date(log.time), { + addSuffix: true, + }), + timestamp: new Date(log.time).getTime(), + })), + [logs.length], + ) + + const virtualizer = useVirtualizer({ + count: formattedLogs.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 21.5, // estimated height of each row + overscan: 50, // number of rows to render outside of the viewport + }) + + const items = virtualizer.getVirtualItems() + + return ( + + + + +
    +
    + + debouncedSearch(event.target.value)} + /> + + + + + + + exportJSON(logs)}> + + Export as JSON + + + + Clear Logs + + + + +
    +
    + Time + Origin + Message +
    +
    + +
    +
    + {formattedLogs.length > 0 ? ( + items.map(({ index: i }) => { + const { + msg, + level, + formattedTime, + relativeTime, + origin, + timestamp, + } = formattedLogs[i] + const formattedLine = msg.trim() + return ( +
    + + + + {formattedTime} + + + + + + { + Intl.DateTimeFormat().resolvedOptions() + .timeZone + } + :{' '} + + {formattedTime} + Relative: + {relativeTime} + Timestamp: + {timestamp} + + + + + + + + {origin} + + + + +

    {origin}

    +
    +
    +
    + +
    + ) + }) + ) : ( +
    + No logs available +
    + )} +
    +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default function LogsExplorer() { + return ( + + + + ) +} diff --git a/pkg/dashboard/frontend/src/components/shared/MultiSelect.tsx b/pkg/dashboard/frontend/src/components/shared/MultiSelect.tsx new file mode 100644 index 000000000..85b25f373 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/shared/MultiSelect.tsx @@ -0,0 +1,379 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { CheckIcon, XCircle, ChevronDown, WandSparkles } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { Separator } from '@/components/ui/separator' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command' + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva( + 'm-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300', + { + variants: { + variant: { + default: + 'border-foreground/10 text-foreground bg-card hover:bg-card/80', + secondary: + 'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + inverted: 'inverted', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string + /** The unique value associated with the option. */ + value: string + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }> + }[] + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void + + /** The default selected values when the component mounts. */ + defaultValue?: string[] + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string + + /** + * If true, the select all option will be disabled. + * Optional, defaults to false. + */ + disableSelectAll?: boolean +} + +export const MultiSelect = React.forwardRef< + HTMLButtonElement, + MultiSelectProps +>( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = 'Select options', + animation = 0, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + disableSelectAll = false, + value, + ...props + }, + ref, + ) => { + const [selectedValues, setSelectedValues] = + React.useState(defaultValue) + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) + const [isAnimating, setIsAnimating] = React.useState(false) + + React.useEffect(() => { + setSelectedValues((value as string[]) ?? []) + }, [value]) + + const handleInputKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key === 'Enter') { + setIsPopoverOpen(true) + } else if (event.key === 'Backspace' && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues] + newSelectedValues.pop() + setSelectedValues(newSelectedValues) + onValueChange(newSelectedValues) + } + } + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option] + setSelectedValues(newSelectedValues) + onValueChange(newSelectedValues) + } + + const handleClear = () => { + setSelectedValues([]) + onValueChange([]) + } + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev) + } + + const clearExtraOptions = () => { + const newSelectedValues = selectedValues.slice(0, maxCount) + setSelectedValues(newSelectedValues) + onValueChange(newSelectedValues) + } + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear() + } else { + const allValues = options.map((option) => option.value) + setSelectedValues(allValues) + onValueChange(allValues) + } + } + + return ( + + + + + setIsPopoverOpen(false)} + > + + + + No results found. + + {!disableSelectAll && ( + +
    + +
    + (Select All) +
    + )} + + {options.map((option) => { + const isSelected = selectedValues.includes(option.value) + return ( + toggleOption(option.value)} + className="cursor-pointer" + > +
    + +
    + {option.icon && ( + + )} + {option.label} +
    + ) + })} +
    + + +
    + {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className="max-w-full flex-1 cursor-pointer justify-center" + > + Close + +
    +
    +
    +
    +
    + {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
    + ) + }, +) + +MultiSelect.displayName = 'MultiSelect' diff --git a/pkg/dashboard/frontend/src/components/shared/TextField.tsx b/pkg/dashboard/frontend/src/components/shared/TextField.tsx index dd9479e46..c23d70727 100644 --- a/pkg/dashboard/frontend/src/components/shared/TextField.tsx +++ b/pkg/dashboard/frontend/src/components/shared/TextField.tsx @@ -4,6 +4,8 @@ import type { InputHTMLAttributes, SVGProps, } from 'react' +import { Input } from '../ui/input' +import { Label } from '../ui/label' interface Props extends InputHTMLAttributes { label: string @@ -20,30 +22,22 @@ const TextField = ({ ...inputProps }: Props) => { return ( -
    -