mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-23 14:04:19 +00:00
feat(plugins): show plugin logs on detail page via Docs/Logs tablist
Add a Logs tab beside Documentation on the plugin detail page, showing the output a plugin prints through the standard Python logger (per the wiki style guide). Logs are captured from the plugin's stderr by the plugin runtime and fetched on demand. - Bump langbot-plugin pin to 0.4.4 (adds GET_PLUGIN_LOGS action) - plugin_connector/handler: get_plugin_logs RPC client - HTTP route GET /api/v1/plugins/<author>/<name>/logs (limit + level) - Frontend: wrap detail right panel in Docs/Logs Tabs; PluginLogs component with level filter, manual + 3s auto refresh, bottom-follow - i18n: 7 new keys across all 8 locales
This commit is contained in:
@@ -2,10 +2,12 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
||||
import PluginLogs from '@/app/home/plugins/components/plugin-installed/plugin-logs/PluginLogs';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
@@ -217,8 +219,35 @@ export default function PluginDetailContent({ id }: { id: string }) {
|
||||
{dangerZone}
|
||||
</div>
|
||||
<div className="hidden w-px shrink-0 bg-border md:block" />
|
||||
<div className="min-w-0 flex-1 pb-6 md:min-h-0 md:overflow-y-auto md:overflow-x-hidden">
|
||||
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
|
||||
<div className="flex min-w-0 flex-1 flex-col pb-6 md:min-h-0 md:overflow-hidden">
|
||||
<Tabs defaultValue="docs" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mb-2 shrink-0">
|
||||
<TabsTrigger value="docs" className="flex-none px-4">
|
||||
{t('plugins.tabDocs')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="flex-none px-4">
|
||||
{t('plugins.tabLogs')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="docs"
|
||||
className="min-h-0 flex-1 md:overflow-y-auto md:overflow-x-hidden"
|
||||
>
|
||||
<PluginReadme
|
||||
pluginAuthor={pluginAuthor}
|
||||
pluginName={pluginName}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="min-h-0 flex-1 md:overflow-hidden"
|
||||
>
|
||||
<PluginLogs
|
||||
pluginAuthor={pluginAuthor}
|
||||
pluginName={pluginName}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
|
||||
|
||||
function levelClassName(level: string): string {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
case 'CRITICAL':
|
||||
return 'text-red-500';
|
||||
case 'WARNING':
|
||||
return 'text-amber-500';
|
||||
case 'DEBUG':
|
||||
return 'text-gray-400 dark:text-gray-500';
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
export default function PluginLogs({
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
}: {
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [level, setLevel] = useState<string>('ALL');
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const atBottomRef = useRef(true);
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
httpClient
|
||||
.getPluginLogs(
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
500,
|
||||
level === 'ALL' ? undefined : level,
|
||||
)
|
||||
.then((res) => {
|
||||
setLogs(res.logs ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
setLogs([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [pluginAuthor, pluginName, level]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-refresh poll loop.
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchLogs, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
// Keep view pinned to bottom when the user is already at the bottom.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && atBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
function handleScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 px-6 pb-3">
|
||||
<Select value={level} onValueChange={setLevel}>
|
||||
<SelectTrigger className="h-8 w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LEVEL_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt === 'ALL' ? t('plugins.logsLevelAll') : opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('plugins.logsRefresh')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={autoRefresh ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
>
|
||||
{autoRefresh
|
||||
? t('plugins.logsAutoRefreshOn')
|
||||
: t('plugins.logsAutoRefreshOff')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-6 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.logsEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
logs.map((entry, idx) => (
|
||||
<div
|
||||
key={`${entry.ts}-${idx}`}
|
||||
className={`whitespace-pre-wrap break-all ${levelClassName(
|
||||
entry.level,
|
||||
)}`}
|
||||
>
|
||||
{entry.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,13 @@ export interface PluginComponent {
|
||||
};
|
||||
}
|
||||
|
||||
// A single log line captured from a running plugin's stderr.
|
||||
export interface PluginLogEntry {
|
||||
ts: number;
|
||||
level: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// marketplace plugin v4
|
||||
export enum PluginV4Status {
|
||||
Any = 'any',
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
ApiRespSkill,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
@@ -604,6 +605,22 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public getPluginLogs(
|
||||
author: string,
|
||||
name: string,
|
||||
limit: number = 200,
|
||||
level?: string,
|
||||
): Promise<{ logs: PluginLogEntry[] }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (level) {
|
||||
params.set('level', level);
|
||||
}
|
||||
return this.get(
|
||||
`/api/v1/plugins/${author}/${name}/logs?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getPluginAssetURL(
|
||||
author: string,
|
||||
name: string,
|
||||
|
||||
Reference in New Issue
Block a user