Skip to content

Commit 3c6d133

Browse files
authored
style: redesign log viewer for extension execution and refactor (#544)
1 parent bcfa124 commit 3c6d133

File tree

18 files changed

+819
-176
lines changed

18 files changed

+819
-176
lines changed

api/api/versions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"version": "v1",
55
"status": "active",
6-
"release_date": "2025-10-29T21:35:21.229737201+05:30",
6+
"release_date": "2025-10-29T22:13:12.29304044+05:30",
77
"end_of_life": "0001-01-01T00:00:00Z",
88
"changes": [
99
"Initial API version"

api/doc/openapi.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

api/internal/features/extension/controller/run_extension.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ func (c *ExtensionsController) CancelExecution(ctx fuego.ContextNoBody) (*types.
5252
}
5353

5454
type ListLogsResponse struct {
55-
Logs []types.ExtensionLog `json:"logs"`
56-
NextAfter int64 `json:"next_after"`
55+
Logs []types.ExtensionLog `json:"logs"`
56+
NextAfter int64 `json:"next_after"`
57+
ExecutionStatus *types.ExecutionStatus `json:"execution_status,omitempty"`
5758
}
5859

5960
func (c *ExtensionsController) ListExecutionLogs(ctx fuego.ContextNoBody) (*ListLogsResponse, error) {
@@ -73,7 +74,7 @@ func (c *ExtensionsController) ListExecutionLogs(ctx fuego.ContextNoBody) (*List
7374
limit = parsed
7475
}
7576
}
76-
logs, err := c.service.ListExecutionLogs(execID, afterSeq, limit)
77+
logs, execStatus, err := c.service.ListExecutionLogs(execID, afterSeq, limit)
7778
if err != nil {
7879
c.logger.Log(logger.Error, err.Error(), "")
7980
return nil, fuego.HTTPError{Err: err, Status: http.StatusInternalServerError}
@@ -82,5 +83,9 @@ func (c *ExtensionsController) ListExecutionLogs(ctx fuego.ContextNoBody) (*List
8283
if len(logs) > 0 {
8384
next = logs[len(logs)-1].Sequence
8485
}
85-
return &ListLogsResponse{Logs: logs, NextAfter: next}, nil
86+
return &ListLogsResponse{
87+
Logs: logs,
88+
NextAfter: next,
89+
ExecutionStatus: execStatus,
90+
}, nil
8691
}

api/internal/features/extension/service/service.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,18 @@ func (s *ExtensionService) appendLog(executionID uuid.UUID, stepID *uuid.UUID, l
275275
_ = s.storage.CreateExtensionLog(log)
276276
}
277277

278-
func (s *ExtensionService) ListExecutionLogs(executionID string, afterSeq int64, limit int) ([]types.ExtensionLog, error) {
278+
func (s *ExtensionService) ListExecutionLogs(executionID string, afterSeq int64, limit int) ([]types.ExtensionLog, *types.ExecutionStatus, error) {
279279
logs, err := s.storage.ListExtensionLogs(executionID, afterSeq, limit)
280280
if err != nil {
281281
s.logger.Log(logger.Error, err.Error(), "")
282-
return nil, err
282+
return nil, nil, err
283+
}
284+
285+
exec, err := s.storage.GetExecutionByID(executionID)
286+
if err != nil {
287+
s.logger.Log(logger.Error, err.Error(), "")
288+
return nil, nil, err
283289
}
284-
return logs, nil
290+
291+
return logs, &exec.Status, nil
285292
}

api/internal/features/extension/storage/storage.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ func (s *ExtensionStorage) ListExtensionLogs(executionID string, afterSeq int64,
356356
if afterSeq > 0 {
357357
q = q.Where("sequence > ?", afterSeq)
358358
}
359-
err := q.Order("sequence ASC").Limit(limit).Scan(s.Ctx)
359+
err := q.Order("created_at ASC").Order("sequence ASC").Limit(limit).Scan(s.Ctx)
360360
if err != nil {
361361
return nil, err
362362
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client';
2+
3+
import { Skeleton } from '@/components/ui/skeleton';
4+
import type { Extension } from '@/redux/types/extension';
5+
6+
interface ExtensionHeaderProps {
7+
extension?: Extension;
8+
isLoading: boolean;
9+
}
10+
11+
export function ExtensionHeader({ extension, isLoading }: ExtensionHeaderProps) {
12+
if (isLoading) {
13+
return <Skeleton className="h-6 w-48" />;
14+
}
15+
16+
return (
17+
<div className="flex items-center gap-3">
18+
<div className="h-10 w-10 rounded bg-accent flex items-center justify-center text-lg">
19+
{extension?.icon}
20+
</div>
21+
<div>
22+
<div className="text-xl font-semibold">{extension?.name}</div>
23+
<div className="text-sm text-muted-foreground">{extension?.author}</div>
24+
</div>
25+
</div>
26+
);
27+
}
28+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client';
2+
3+
import ExtensionInput from '@/app/extensions/components/extension-input';
4+
import type { Extension } from '@/redux/types/extension';
5+
6+
interface ExtensionModalProps {
7+
open: boolean;
8+
onOpenChange: (open: boolean) => void;
9+
extension?: Extension;
10+
onSubmit: (values: Record<string, unknown>) => Promise<void>;
11+
}
12+
13+
export function ExtensionModal({
14+
open,
15+
onOpenChange,
16+
extension,
17+
onSubmit
18+
}: ExtensionModalProps) {
19+
return (
20+
<ExtensionInput
21+
open={open}
22+
onOpenChange={onOpenChange}
23+
extension={extension}
24+
onSubmit={onSubmit}
25+
/>
26+
);
27+
}
28+
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client';
2+
3+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
4+
import { Info, Terminal } from 'lucide-react';
5+
import { useTranslation } from '@/hooks/use-translation';
6+
import OverviewTab from './OverviewTab';
7+
import ExecutionsTab from './LogsTab';
8+
import type { Extension } from '@/redux/types/extension';
9+
import { useParams } from 'next/navigation';
10+
import { useListExecutionsQuery } from '@/redux/services/extensions/extensionsApi';
11+
import { useEffect, useMemo } from 'react';
12+
13+
interface ExtensionTabsProps {
14+
tab: string;
15+
onTabChange: (value: string) => void;
16+
extension?: Extension;
17+
isLoading: boolean;
18+
}
19+
20+
export function ExtensionTabs({
21+
tab,
22+
onTabChange,
23+
extension,
24+
isLoading
25+
}: ExtensionTabsProps) {
26+
const { t } = useTranslation();
27+
const params = useParams();
28+
const id = (params?.id as string) || '';
29+
30+
const { data: executions, isLoading: isExecsLoading } = useListExecutionsQuery(
31+
{ extensionId: id },
32+
{ skip: !id }
33+
);
34+
35+
const hasExecutions = useMemo(() => (executions || []).length > 0, [executions]);
36+
37+
useEffect(() => {
38+
if (tab === 'executions' && !isExecsLoading && !hasExecutions) {
39+
onTabChange('overview');
40+
}
41+
}, [tab, hasExecutions, isExecsLoading, onTabChange]);
42+
43+
if (!isExecsLoading && !hasExecutions) {
44+
return (
45+
<div className="mt-6">
46+
<OverviewTab extension={extension} isLoading={isLoading} />
47+
</div>
48+
);
49+
}
50+
51+
return (
52+
<div className="mt-6">
53+
<Tabs value={tab} onValueChange={onTabChange} className="w-full">
54+
<TabsList>
55+
<TabsTrigger value="overview">
56+
<Info className="mr-2 h-4 w-4" />
57+
{t('extensions.overview') || 'Overview'}
58+
</TabsTrigger>
59+
<TabsTrigger value="executions">
60+
<Terminal className="mr-2 h-4 w-4" />
61+
{t('extensions.executions') || 'Executions'}
62+
</TabsTrigger>
63+
</TabsList>
64+
65+
<TabsContent value="overview" className="mt-6">
66+
<OverviewTab extension={extension} isLoading={isLoading} />
67+
</TabsContent>
68+
69+
<TabsContent value="executions" className="mt-6">
70+
<ExecutionsTab />
71+
</TabsContent>
72+
</Tabs>
73+
</div>
74+
);
75+
}
76+
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
'use client';
2+
3+
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
4+
import { Badge } from '@/components/ui/badge';
5+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
6+
import { cn } from '@/lib/utils';
7+
import { formatLogMessage, formatDataPreview, formatVerboseData, type FormattedLog } from './utils/log-formatter';
8+
9+
interface LogEntryProps {
10+
log: FormattedLog;
11+
isCollapsed: boolean;
12+
onToggleCollapse: () => void;
13+
}
14+
15+
export function LogEntry({ log, isCollapsed, onToggleCollapse }: LogEntryProps) {
16+
const hasVerboseData = log.isVerbose && log.data != null;
17+
18+
return (
19+
<div
20+
className={cn(
21+
'text-sm border-l-2 pl-3 py-2 transition-colors',
22+
log.color,
23+
'bg-muted/30 dark:bg-muted/10 hover:bg-muted/50'
24+
)}
25+
>
26+
<div className="flex items-start gap-2">
27+
<div className="flex-shrink-0 mt-0.5">{log.icon}</div>
28+
<div className="flex-1 min-w-0">
29+
<div className="flex items-center gap-2 flex-wrap">
30+
<span className="text-xs text-muted-foreground font-mono">
31+
{log.timestamp}
32+
</span>
33+
<Badge variant="outline" className="text-xs px-1.5 py-0">
34+
{log.level}
35+
</Badge>
36+
<span className="font-medium">{formatLogMessage(log.message, log.data)}</span>
37+
</div>
38+
39+
{log.progressInfo && (
40+
<ProgressInfo progressInfo={log.progressInfo} />
41+
)}
42+
43+
{log.data != null && !hasVerboseData && (
44+
<div className="mt-2 text-xs font-mono bg-muted/50 p-2 rounded border break-all">
45+
{formatDataPreview(log.data)}
46+
</div>
47+
)}
48+
49+
{hasVerboseData && log.data != null && (
50+
<VerboseDataSection
51+
data={log.data}
52+
isCollapsed={isCollapsed}
53+
onToggle={onToggleCollapse}
54+
/>
55+
)}
56+
</div>
57+
</div>
58+
</div>
59+
);
60+
}
61+
62+
interface ProgressInfoProps {
63+
progressInfo: FormattedLog['progressInfo'];
64+
}
65+
66+
function ProgressInfo({ progressInfo }: ProgressInfoProps) {
67+
if (!progressInfo) return null;
68+
69+
return (
70+
<div className="mt-2 flex items-center gap-2 text-xs bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800">
71+
<Download className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
72+
<div className="flex-1">
73+
<div className="font-medium text-blue-900 dark:text-blue-300">
74+
{progressInfo.status}
75+
</div>
76+
{progressInfo.progress && (
77+
<div className="text-blue-700 dark:text-blue-400 mt-0.5">
78+
{progressInfo.progress}
79+
</div>
80+
)}
81+
</div>
82+
</div>
83+
);
84+
}
85+
86+
interface VerboseDataSectionProps {
87+
data: unknown;
88+
isCollapsed: boolean;
89+
onToggle: () => void;
90+
}
91+
92+
function VerboseDataSection({ data, isCollapsed, onToggle }: VerboseDataSectionProps) {
93+
return (
94+
<Collapsible open={!isCollapsed} onOpenChange={onToggle}>
95+
<CollapsibleTrigger className="mt-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
96+
{isCollapsed ? (
97+
<ChevronRight className="h-3 w-3" />
98+
) : (
99+
<ChevronDown className="h-3 w-3" />
100+
)}
101+
<span>
102+
{isCollapsed
103+
? 'Show verbose output'
104+
: 'Hide verbose output'}
105+
</span>
106+
</CollapsibleTrigger>
107+
<CollapsibleContent className="mt-2">
108+
<div className="text-xs font-mono bg-muted/50 p-3 rounded border overflow-x-auto max-h-96 overflow-y-auto">
109+
<pre className="whitespace-pre-wrap break-all">
110+
{formatVerboseData(data)}
111+
</pre>
112+
</div>
113+
</CollapsibleContent>
114+
</Collapsible>
115+
);
116+
}
117+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use client';
2+
3+
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
4+
import { useTranslation } from '@/hooks/use-translation';
5+
import { LogEntry } from './LogEntry';
6+
import type { ExtensionLog } from '@/redux/types/extension';
7+
import { useLogViewer } from '../hooks/use-log-viewer';
8+
9+
interface LogViewerProps {
10+
open: boolean;
11+
onOpenChange: (open: boolean) => void;
12+
executionId: string | null;
13+
logs: ExtensionLog[];
14+
}
15+
16+
export function LogViewer({ open, onOpenChange, executionId, logs }: LogViewerProps) {
17+
const { t } = useTranslation();
18+
19+
const { formattedLogs, collapsedLogs, toggleCollapse, logsEndRef, isEmpty } = useLogViewer({
20+
open,
21+
executionId,
22+
logs
23+
});
24+
25+
return (
26+
<Sheet open={open} onOpenChange={onOpenChange}>
27+
<SheetContent side="right" className="sm:max-w-3xl">
28+
<SheetHeader>
29+
<SheetTitle>{t('extensions.logs') || 'Execution Logs'}</SheetTitle>
30+
</SheetHeader>
31+
<div className="flex flex-col h-[calc(100vh-120px)] mt-4">
32+
<div className="mb-3 text-xs text-muted-foreground px-1">
33+
{t('extensions.executionId') || 'Execution ID'}: <span className="font-mono">{executionId}</span>
34+
</div>
35+
<div className="flex-1 overflow-y-auto space-y-1 pr-2 min-h-0">
36+
{isEmpty ? (
37+
<div className="text-sm text-muted-foreground text-center py-8">
38+
No logs yet...
39+
</div>
40+
) : (
41+
formattedLogs.map((log) => (
42+
<LogEntry
43+
key={log.id}
44+
log={log}
45+
isCollapsed={collapsedLogs.has(log.id)}
46+
onToggleCollapse={() => toggleCollapse(log.id)}
47+
/>
48+
))
49+
)}
50+
<div ref={logsEndRef} />
51+
</div>
52+
</div>
53+
</SheetContent>
54+
</Sheet>
55+
);
56+
}
57+

0 commit comments

Comments
 (0)