feat: initialize web tail pro application with frontend and backend
- Add frontend React application with log viewing components - Implement backend Go server for log tailing and SSE streaming - Include build scripts for both frontend and backend - Set up basic authentication and file handling - Add styling and log formatting capabilities
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "go-react-web-tail-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.12.8",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Web Tail Pro</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Tabs, Button, message, Space } from 'antd';
|
||||
import { AppstoreOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useSSE, LogEntry } from './hooks/useSSE';
|
||||
import { LogPanel } from './components/LogPanel';
|
||||
import { MergedLogPanel } from './components/MergedLogPanel';
|
||||
import { fetchLogFiles } from './api';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { logs, isConnected } = useSSE('/logs');
|
||||
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState('1');
|
||||
const [tabItems, setTabItems] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogFiles().then(res => {
|
||||
const files = res.data;
|
||||
setAvailableFiles(files);
|
||||
const initialTabs = files.map((file, index) => ({
|
||||
key: String(index + 1),
|
||||
label: file,
|
||||
children: <LogPanel title={file} logs={logs} filename={file} />,
|
||||
closable: files.length > 1,
|
||||
}));
|
||||
initialTabs.push({
|
||||
key: 'merged',
|
||||
label: '🔀 Mesclado',
|
||||
children: <MergedLogPanel logs={logs} />,
|
||||
closable: false,
|
||||
});
|
||||
setTabItems(initialTabs);
|
||||
}).catch(err => message.error("Falha ao buscar lista de arquivos de log."));
|
||||
}, []);
|
||||
|
||||
const addTab = () => {
|
||||
const unusedFiles = availableFiles.filter(f => !tabItems.some(item => item.label === f));
|
||||
if (unusedFiles.length === 0) {
|
||||
message.info("Todos os arquivos de log já estão abertos.");
|
||||
return;
|
||||
}
|
||||
const newFile = unusedFiles[0];
|
||||
const newKey = String(Math.max(...tabItems.map(t => parseInt(t.key, 10))) + 1);
|
||||
const newTab = { key: newKey, label: newFile, children: <LogPanel title={newFile} logs={logs} filename={newFile} />, closable: true };
|
||||
setTabItems([...tabItems, newTab]);
|
||||
setActiveTabKey(newKey);
|
||||
};
|
||||
|
||||
const onEdit = (targetKey: string, action: 'add' | 'remove') => {
|
||||
if (action === 'add') {
|
||||
addTab();
|
||||
} else {
|
||||
const newTabs = tabItems.filter(item => item.key !== targetKey);
|
||||
if (newTabs.length && activeTabKey === targetKey) {
|
||||
setActiveTabKey(newTabs[newTabs.length - 1].key);
|
||||
}
|
||||
setTabItems(newTabs);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh' }}>
|
||||
<Header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1 style={{ color: 'white', margin: 0 }}>📋 Web Tail Pro</h1>
|
||||
<Space>
|
||||
<Button icon={<AppstoreOutlined />} disabled>Dividir Tela (Em breve)</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ padding: '0 16px' }}>
|
||||
<Tabs type="editable-card" onChange={setActiveTabKey} activeKey={activeTabKey} onEdit={onEdit} items={tabItems} style={{ marginTop: 16 }} />
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: 'http://localhost:8080', // Padrão, pode ser sobrescrito se necessário
|
||||
});
|
||||
|
||||
export const fetchLogFiles = () => apiClient.get<string[]>('/api/files');
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LogLineProps {
|
||||
line: string;
|
||||
format: 'generic' | 'json' | 'apache';
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string) => {
|
||||
const map: { [key: string]: string } = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
export const LogLine: React.FC<LogLineProps> = ({ line, format }) => {
|
||||
let parsedLine = escapeHtml(line);
|
||||
switch (format) {
|
||||
case 'json':
|
||||
try { const obj = JSON.parse(line); parsedLine = Object.keys(obj).map(k => `<span class="json-key">${k}:</span> <span class="json-value">${escapeHtml(String(obj[k]))}</span>`).join(' '); } catch (e) {}
|
||||
break;
|
||||
case 'apache':
|
||||
const re = /^(\S+) \S+ \S+ \[([\w:/]+\s[+\-]\d{4})\] "(\S+) (\S+) (\S+)" (\d{3}) (\d+|-)/;
|
||||
const matches = re.exec(line);
|
||||
if (matches) parsedLine = `<span class="log-ip">${matches[1]}</span> - - [<span class="log-time">${matches[2]}</span>] "<span class="log-method">${matches[3]}</span> <span class="log-path">${matches[4]}</span> <span class="log-proto">${matches[5]}</span>" <span class="log-status-${matches[6]}">${matches[6]}</span> <span class="log-size">${matches[7]}</span>`;
|
||||
break;
|
||||
default:
|
||||
const levelRe = /\b(INFO|WARN|ERROR|DEBUG|FATAL)\b/gi;
|
||||
parsedLine = parsedLine.replace(levelRe, s => `<span class="log-level-${s.toLowerCase()}">${s}</span>`);
|
||||
}
|
||||
return <div className="log-line" dangerouslySetInnerHTML={{ __html: parsedLine }} />;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Card, Input, Select, Space } from 'antd';
|
||||
import { LogLine, LogEntry } from '../hooks/useSSE';
|
||||
|
||||
interface LogPanelProps {
|
||||
title: string;
|
||||
logs: LogEntry[];
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export const LogPanel: React.FC<LogPanelProps> = ({ title, logs, filename }) => {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [format, setFormat] = useState<'generic' | 'json' | 'apache'>('generic');
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (!filename) return logs;
|
||||
return logs.filter(log => log.filename === filename);
|
||||
}, [logs, filename]);
|
||||
|
||||
const displayedLogs = useMemo(() => {
|
||||
if (!filter) return filteredLogs;
|
||||
return filteredLogs.filter(log => log.line.toLowerCase().includes(filter.toLowerCase()));
|
||||
}, [filteredLogs, filter]);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [displayedLogs]);
|
||||
|
||||
return (
|
||||
<Card title={title} size="small" style={{ height: '100%' }} bodyStyle={{ padding: 0, height: 'calc(100% - 57px)' }}>
|
||||
<Space style={{padding: '8px', borderBottom: '1px solid #333'}}>
|
||||
<Select value={format} onChange={setFormat} style={{ width: 120 }}>
|
||||
<Select.Option value="generic">Genérico</Select.Option>
|
||||
<Select.Option value="json">JSON</Select.Option>
|
||||
<Select.Option value="apache">Apache</Select.Option>
|
||||
</Select>
|
||||
<Input placeholder="Filtrar..." value={filter} onChange={(e) => setFilter(e.target.value)} style={{ width: 200 }} />
|
||||
</Space>
|
||||
<div className="log-output">
|
||||
{displayedLogs.map((log, index) => (
|
||||
<LogLine key={`${log.filename}-${index}`} line={log.line} format={format} />
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Card, Input } from 'antd';
|
||||
import { LogLine, LogEntry } from '../hooks/useSSE';
|
||||
|
||||
export const MergedLogPanel: React.FC<{ logs: LogEntry[] }> = ({ logs }) => {
|
||||
const [filter, setFilter] = useState('');
|
||||
const sortedAndFilteredLogs = useMemo(() => {
|
||||
const sorted = [...logs].sort((a, b) => {
|
||||
if (!a.timestamp) return 1;
|
||||
if (!b.timestamp) return -1;
|
||||
return a.timestamp.localeCompare(b.timestamp);
|
||||
});
|
||||
if (!filter) return sorted;
|
||||
return sorted.filter(log => log.line.toLowerCase().includes(filter.toLowerCase()));
|
||||
}, [logs, filter]);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [sortedAndFilteredLogs]);
|
||||
|
||||
return (
|
||||
<Card title="🔀 Mesclado" size="small" style={{ height: '100%' }} bodyStyle={{ padding: 0, height: 'calc(100% - 57px)' }}>
|
||||
<Input placeholder="Filtrar logs mesclados..." value={filter} onChange={(e) => setFilter(e.target.value)} style={{ margin: '8px', width: 'calc(100% - 16px)' }} />
|
||||
<div className="log-output">
|
||||
{sortedAndFilteredLogs.map((log, index) => (
|
||||
<LogLine key={`merged-${log.filename}-${index}`} line={log.line} format="generic" />
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface LogEntry {
|
||||
filename: string;
|
||||
line: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const useSSE = (url: string) => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(url);
|
||||
setIsConnected(true);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const newLog: LogEntry = JSON.parse(event.data);
|
||||
setLogs(prevLogs => [...prevLogs, newLog].slice(-2000));
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error("SSE Error:", err);
|
||||
setIsConnected(false);
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
eventSource.close();
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return { logs, isConnected };
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #141414;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
height: calc(100vh - 180px);
|
||||
overflow-y: auto;
|
||||
background-color: #1e1e1e;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.log-level-info { color: #4fc3f7; font-weight: bold; }
|
||||
.log-level-warn { color: #ffb74d; font-weight: bold; }
|
||||
.log-level-error { color: #f48fb1; font-weight: bold; }
|
||||
.json-key { color: #9cdcfe; }
|
||||
.json-value { color: #ce9178; }
|
||||
.log-ip { color: #9cdcfe; }
|
||||
.log-time { color: #ce9178; }
|
||||
.log-method { color: #569cd6; font-weight: bold; }
|
||||
.log-path { color: #dcdcaa; }
|
||||
.log-status-200, .log-status-201 { color: #4ec9b0; }
|
||||
.log-status-404, .log-status-500 { color: #f48fb1; font-weight: bold; }
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import 'antd/dist/reset.css'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Vendored
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||
'/logs': { target: 'http://localhost:8080', changeOrigin: true },
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user