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:
+68
-2
@@ -6,12 +6,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"bufio"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
@@ -28,6 +30,9 @@ var (
|
|||||||
username string
|
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.
|
// init() é executado antes de main(). É o lugar ideal para configurar flags.
|
||||||
func init() {
|
func init() {
|
||||||
flag.StringVar(&port, "port", ":8080", "Porta para o servidor web (ex: :8080, 9090)")
|
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
|
// handleFiles lista os arquivos de log disponíveis
|
||||||
func handleFiles(w http.ResponseWriter, r *http.Request) {
|
func handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
// startTailing inicia o monitoramento dos arquivos
|
||||||
@@ -184,7 +249,7 @@ func requestAuth(w http.ResponseWriter) {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
logFiles := flag.Args()
|
logFiles = flag.Args()
|
||||||
if len(logFiles) < 1 {
|
if len(logFiles) < 1 {
|
||||||
fmt.Println("Uso: web-tail-pro [opções] <arquivo1.log> <arquivo2.log> ...")
|
fmt.Println("Uso: web-tail-pro [opções] <arquivo1.log> <arquivo2.log> ...")
|
||||||
fmt.Println("\nOpções:")
|
fmt.Println("\nOpções:")
|
||||||
@@ -212,6 +277,7 @@ func main() {
|
|||||||
mux.Handle("/", http.FileServer(http.FS(distFS)))
|
mux.Handle("/", http.FileServer(http.FS(distFS)))
|
||||||
mux.HandleFunc("/logs", handleSSE)
|
mux.HandleFunc("/logs", handleSSE)
|
||||||
mux.HandleFunc("/api/files", handleFiles)
|
mux.HandleFunc("/api/files", handleFiles)
|
||||||
|
mux.HandleFunc("/api/last", handleLastLines)
|
||||||
|
|
||||||
protectedHandler := authMiddleware(mux)
|
protectedHandler := authMiddleware(mux)
|
||||||
|
|
||||||
|
|||||||
+276
File diff suppressed because one or more lines are too long
+1
@@ -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}
|
||||||
Vendored
+14
@@ -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>
|
||||||
|
</html>
|
||||||
+16
-10
@@ -5,7 +5,7 @@ import { AppstoreOutlined, PlusOutlined } from '@ant-design/icons';
|
|||||||
import { useSSE, LogEntry } from './hooks/useSSE';
|
import { useSSE, LogEntry } from './hooks/useSSE';
|
||||||
import { LogPanel } from './components/LogPanel';
|
import { LogPanel } from './components/LogPanel';
|
||||||
import { MergedLogPanel } from './components/MergedLogPanel';
|
import { MergedLogPanel } from './components/MergedLogPanel';
|
||||||
import { fetchLogFiles } from './api';
|
import { fetchLogFiles, fetchLastLines } from './api';
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
|
|
||||||
@@ -17,20 +17,20 @@ interface TabItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { logs, isConnected } = useSSE('/logs');
|
const { logs, isConnected, addInitialLogs } = useSSE('/logs');
|
||||||
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
|
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
|
||||||
const [activeTabKey, setActiveTabKey] = useState('1');
|
const [activeTabKey, setActiveTabKey] = useState('1');
|
||||||
const [tabItems, setTabItems] = useState<TabItem[]>([]);
|
const [tabItems, setTabItems] = useState<TabItem[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogFiles().then(res => {
|
fetchLogFiles().then(async res => {
|
||||||
const files = res.data;
|
const files = res.data;
|
||||||
setAvailableFiles(files);
|
setAvailableFiles(files);
|
||||||
const initialTabs = files.map((file, index) => ({
|
const initialTabs = files.map((file, index) => ({
|
||||||
key: String(index + 1),
|
key: String(index + 1),
|
||||||
label: file,
|
label: file,
|
||||||
children: <LogPanel title={file} logs={logs} filename={file} />,
|
children: <LogPanel title={file} logs={logs} filename={file} />,
|
||||||
closable: files.length > 1,
|
closable: true,
|
||||||
}));
|
}));
|
||||||
initialTabs.push({
|
initialTabs.push({
|
||||||
key: 'merged',
|
key: 'merged',
|
||||||
@@ -39,7 +39,13 @@ const App: React.FC = () => {
|
|||||||
closable: false,
|
closable: false,
|
||||||
});
|
});
|
||||||
setTabItems(initialTabs);
|
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 = () => {
|
const addTab = () => {
|
||||||
@@ -55,13 +61,13 @@ const App: React.FC = () => {
|
|||||||
setActiveTabKey(newKey);
|
setActiveTabKey(newKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEdit: TabsProps['onEdit'] = (action, info) => {
|
const onEdit: TabsProps['onEdit'] = (key, action) => {
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
addTab();
|
addTab();
|
||||||
} else {
|
} else if (action === 'remove') {
|
||||||
const key = String((info as any).key);
|
const k = String(key);
|
||||||
const newTabs = tabItems.filter(item => item.key !== key);
|
const newTabs = tabItems.filter(item => item.key !== k);
|
||||||
if (newTabs.length && activeTabKey === key) {
|
if (newTabs.length && activeTabKey === k) {
|
||||||
setActiveTabKey(newTabs[newTabs.length - 1].key);
|
setActiveTabKey(newTabs[newTabs.length - 1].key);
|
||||||
}
|
}
|
||||||
setTabItems(newTabs);
|
setTabItems(newTabs);
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import type { LogEntry } from './hooks/useSSE';
|
||||||
|
|
||||||
export const apiClient = axios.create({});
|
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]);
|
}, [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