feat(logs): add initial log loading and tab management improvements
- Implement fetchLastLines API to load initial log entries - Add addInitialLogs function to useSSE hook for prepending logs - Improve tab handling with better closable behavior and state management - Add backend support for fetching last N lines from log files
This commit is contained in:
@@ -6,12 +6,14 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"bufio"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
@@ -28,6 +30,9 @@ var (
|
||||
username string
|
||||
)
|
||||
|
||||
// Lista global de arquivos de log (apenas argumentos após flags)
|
||||
var logFiles []string
|
||||
|
||||
// init() é executado antes de main(). É o lugar ideal para configurar flags.
|
||||
func init() {
|
||||
flag.StringVar(&port, "port", ":8080", "Porta para o servidor web (ex: :8080, 9090)")
|
||||
@@ -118,7 +123,67 @@ func handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
// handleFiles lista os arquivos de log disponíveis
|
||||
func handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(os.Args[1:])
|
||||
json.NewEncoder(w).Encode(logFiles)
|
||||
}
|
||||
|
||||
func handleLastLines(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
file := r.URL.Query().Get("file")
|
||||
nParam := r.URL.Query().Get("n")
|
||||
n := 200
|
||||
if nParam != "" {
|
||||
fmt.Sscanf(nParam, "%d", &n)
|
||||
}
|
||||
allowed := false
|
||||
for _, f := range logFiles {
|
||||
if f == file {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("arquivo inválido"))
|
||||
return
|
||||
}
|
||||
entries, err := getLastLines(file, n)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("erro ao ler arquivo"))
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(entries)
|
||||
}
|
||||
|
||||
func getLastLines(filename string, n int) ([]LogEntry, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
buf := make([]string, 0, n)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if len(buf) == n {
|
||||
buf = buf[1:]
|
||||
}
|
||||
buf = append(buf, line)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tsRe1 := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
|
||||
tsRe2 := regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`)
|
||||
entries := make([]LogEntry, 0, len(buf))
|
||||
for _, line := range buf {
|
||||
t := tsRe1.FindString(line)
|
||||
if t == "" {
|
||||
t = tsRe2.FindString(line)
|
||||
}
|
||||
entries = append(entries, LogEntry{Filename: filename, Line: strings.TrimRight(line, "\r"), Timestamp: t})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// startTailing inicia o monitoramento dos arquivos
|
||||
@@ -184,7 +249,7 @@ func requestAuth(w http.ResponseWriter) {
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
logFiles := flag.Args()
|
||||
logFiles = flag.Args()
|
||||
if len(logFiles) < 1 {
|
||||
fmt.Println("Uso: web-tail-pro [opções] <arquivo1.log> <arquivo2.log> ...")
|
||||
fmt.Println("\nOpções:")
|
||||
@@ -212,6 +277,7 @@ func main() {
|
||||
mux.Handle("/", http.FileServer(http.FS(distFS)))
|
||||
mux.HandleFunc("/logs", handleSSE)
|
||||
mux.HandleFunc("/api/files", handleFiles)
|
||||
mux.HandleFunc("/api/last", handleLastLines)
|
||||
|
||||
protectedHandler := authMiddleware(mux)
|
||||
|
||||
|
||||
276
frontend/dist/assets/index-GvmfrMPc.js
vendored
Normal file
276
frontend/dist/assets/index-GvmfrMPc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-I4JcZQqc.css
vendored
Normal file
1
frontend/dist/assets/index-I4JcZQqc.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}@-ms-viewport{width:device-width}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}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:700}.log-level-warn{color:#ffb74d;font-weight:700}.log-level-error{color:#f48fb1;font-weight:700}.json-key{color:#9cdcfe}.json-value{color:#ce9178}.log-ip{color:#9cdcfe}.log-time{color:#ce9178}.log-method{color:#569cd6;font-weight:700}.log-path{color:#dcdcaa}.log-status-200,.log-status-201{color:#4ec9b0}.log-status-404,.log-status-500{color:#f48fb1;font-weight:700}
|
||||
14
frontend/dist/index.html
vendored
Normal file
14
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!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>
|
||||
<script type="module" crossorigin src="./assets/index-GvmfrMPc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-I4JcZQqc.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
@@ -5,7 +5,7 @@ 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';
|
||||
import { fetchLogFiles, fetchLastLines } from './api';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
@@ -17,20 +17,20 @@ interface TabItem {
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { logs, isConnected } = useSSE('/logs');
|
||||
const { logs, isConnected, addInitialLogs } = useSSE('/logs');
|
||||
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState('1');
|
||||
const [tabItems, setTabItems] = useState<TabItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogFiles().then(res => {
|
||||
fetchLogFiles().then(async 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,
|
||||
closable: true,
|
||||
}));
|
||||
initialTabs.push({
|
||||
key: 'merged',
|
||||
@@ -39,7 +39,13 @@ const App: React.FC = () => {
|
||||
closable: false,
|
||||
});
|
||||
setTabItems(initialTabs);
|
||||
}).catch(err => message.error("Falha ao buscar lista de arquivos de log."));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const resp = await fetchLastLines(file, 200);
|
||||
addInitialLogs(resp.data);
|
||||
} catch {}
|
||||
}
|
||||
}).catch(() => message.error("Falha ao buscar lista de arquivos de log."));
|
||||
}, []);
|
||||
|
||||
const addTab = () => {
|
||||
@@ -55,13 +61,13 @@ const App: React.FC = () => {
|
||||
setActiveTabKey(newKey);
|
||||
};
|
||||
|
||||
const onEdit: TabsProps['onEdit'] = (action, info) => {
|
||||
const onEdit: TabsProps['onEdit'] = (key, action) => {
|
||||
if (action === 'add') {
|
||||
addTab();
|
||||
} else {
|
||||
const key = String((info as any).key);
|
||||
const newTabs = tabItems.filter(item => item.key !== key);
|
||||
if (newTabs.length && activeTabKey === key) {
|
||||
} else if (action === 'remove') {
|
||||
const k = String(key);
|
||||
const newTabs = tabItems.filter(item => item.key !== k);
|
||||
if (newTabs.length && activeTabKey === k) {
|
||||
setActiveTabKey(newTabs[newTabs.length - 1].key);
|
||||
}
|
||||
setTabItems(newTabs);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import type { LogEntry } from './hooks/useSSE';
|
||||
|
||||
export const apiClient = axios.create({});
|
||||
|
||||
export const fetchLogFiles = () => apiClient.get<string[]>('/api/files');
|
||||
export const fetchLogFiles = () => apiClient.get<string[]>('/api/files');
|
||||
export const fetchLastLines = (file: string, n = 200) => apiClient.get<LogEntry[]>(`/api/last`, { params: { file, n } });
|
||||
@@ -59,5 +59,9 @@ export const useSSE = (url: string) => {
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return { logs, isConnected };
|
||||
const addInitialLogs = (entries: LogEntry[]) => {
|
||||
setLogs(prev => [...entries, ...prev].slice(-MAX_LOG_ENTRIES));
|
||||
};
|
||||
|
||||
return { logs, isConnected, addInitialLogs };
|
||||
};
|
||||
Reference in New Issue
Block a user