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:
Luiz Costa
2025-11-18 08:27:21 -03:00
commit 3a5cbe12ef
18 changed files with 670 additions and 0 deletions
+30
View File
@@ -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"
}
}
+13
View File
@@ -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>
+77
View File
@@ -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;
+7
View File
@@ -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');
+29
View File
@@ -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 } = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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 }} />;
};
+48
View File
@@ -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>
);
};
+38
View File
@@ -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 };
};
+37
View File
@@ -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; }
+11
View File
@@ -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>,
)
View File
+13
View File
@@ -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 },
}
}
})