From b21c01a7df4154c5277631c4f07eed8faf9c2217 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Sun, 12 Feb 2023 17:34:09 +0100 Subject: [PATCH] feat(logs): make log files downloadable (#706) * feat(logs): show and download log files * feat(logs): make logs settings dropdown * feat(logs): minor cosmetic changes * fix(logs): send empty response when lohpath not configured * fix(logs): remove unused imports * feat(logs): check if logs dir exists * feat(logs): list log files in settings --- internal/http/logs.go | 141 ++++++++++++ internal/http/server.go | 1 + web/src/api/APIClient.ts | 8 +- web/src/screens/Logs.tsx | 267 ++++++++++++++++++----- web/src/screens/settings/Application.tsx | 4 +- web/src/screens/settings/Logs.tsx | 7 +- web/src/types/Config.d.ts | 45 ++-- 7 files changed, 394 insertions(+), 79 deletions(-) create mode 100644 internal/http/logs.go diff --git a/internal/http/logs.go b/internal/http/logs.go new file mode 100644 index 0000000..974b06e --- /dev/null +++ b/internal/http/logs.go @@ -0,0 +1,141 @@ +package http + +import ( + "io/fs" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/autobrr/autobrr/internal/config" + + "github.com/dustin/go-humanize" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type logsHandler struct { + cfg *config.AppConfig +} + +func newLogsHandler(cfg *config.AppConfig) *logsHandler { + return &logsHandler{cfg: cfg} +} + +func (h logsHandler) Routes(r chi.Router) { + r.Get("/files", h.files) + r.Get("/files/{logFile}", h.downloadFile) +} + +func (h logsHandler) files(w http.ResponseWriter, r *http.Request) { + response := LogfilesResponse{ + Files: []logFile{}, + Count: 0, + } + + if h.cfg.Config.LogPath == "" { + render.JSON(w, r, response) + return + } + + logsDir := path.Dir(h.cfg.Config.LogPath) + + // check if dir exists before walkDir + if _, err := os.Stat(logsDir); os.IsNotExist(err) { + render.JSON(w, r, response) + return + } + + var walk = func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + + if filepath.Ext(path) == ".log" { + i, err := d.Info() + if err != nil { + return err + } + + response.Files = append(response.Files, logFile{ + Name: d.Name(), + SizeBytes: i.Size(), + Size: humanize.Bytes(uint64(i.Size())), + UpdatedAt: i.ModTime(), + }) + } + + return nil + } + + if err := filepath.WalkDir(logsDir, walk); err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, errorResponse{ + Message: err.Error(), + Status: http.StatusInternalServerError, + }) + return + } + + response.Count = len(response.Files) + + render.JSON(w, r, response) +} + +func (h logsHandler) downloadFile(w http.ResponseWriter, r *http.Request) { + if h.cfg.Config.LogPath == "" { + render.Status(r, http.StatusNotFound) + return + } + + logsDir := path.Dir(h.cfg.Config.LogPath) + + // check if dir exists before walkDir + if _, err := os.Stat(logsDir); os.IsNotExist(err) { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, errorResponse{ + Message: "log directory not found or inaccessible", + Status: http.StatusNotFound, + }) + return + } + + logFile := chi.URLParam(r, "logFile") + if logFile == "" { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, errorResponse{ + Message: "empty log file", + Status: http.StatusBadRequest, + }) + return + } else if !strings.Contains(logFile, ".log") { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, errorResponse{ + Message: "invalid file", + Status: http.StatusBadRequest, + }) + return + } + + w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(logFile)) + w.Header().Set("Content-Type", "application/octet-stream") + + filePath := filepath.Join(logsDir, logFile) + + http.ServeFile(w, r, filePath) +} + +type logFile struct { + Name string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + Size string `json:"size"` + UpdatedAt time.Time `json:"updated_at"` +} + +type LogfilesResponse struct { + Files []logFile `json:"files"` + Count int `json:"count"` +} diff --git a/internal/http/server.go b/internal/http/server.go index e673369..788c71b 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -133,6 +133,7 @@ func (s Server) Handler() http.Handler { r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes) r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, s.ircService).Routes) r.Route("/keys", newAPIKeyHandler(encoder, s.apiService).Routes) + r.Route("/logs", newLogsHandler(s.config).Routes) r.Route("/notification", newNotificationHandler(encoder, s.notificationService).Routes) r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes) r.Route("/updates", newUpdateHandler(encoder, s.updateService).Routes) diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 8dd5955..0951926 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -42,7 +42,7 @@ export async function HttpClient( // Resolve immediately since 204 contains no data if (response.status === 204) return Promise.resolve(response); - + return await response.json(); }); } @@ -144,6 +144,10 @@ export const APIClient = { deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`), restartNetwork: (id: number) => appClient.Get(`api/irc/network/${id}/restart`) }, + logs: { + files: () => appClient.Get("api/logs/files"), + getFile: (file: string) => appClient.Get(`api/logs/files/${file}`) + }, events: { logs: () => new EventSource(`${sseBaseUrl()}api/events?stream=logs`, { withCredentials: true }) }, @@ -185,6 +189,6 @@ export const APIClient = { }, updates: { check: () => appClient.Get("api/updates/check"), - getLatestRelease: () => appClient.Get("api/updates/latest") + getLatestRelease: () => appClient.Get("api/updates/latest") } }; diff --git a/web/src/screens/Logs.tsx b/web/src/screens/Logs.tsx index 7297145..cee0a63 100644 --- a/web/src/screens/Logs.tsx +++ b/web/src/screens/Logs.tsx @@ -1,11 +1,19 @@ -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import format from "date-fns/format"; import { DebounceInput } from "react-debounce-input"; import { APIClient } from "../api/APIClient"; import { Checkbox } from "../components/Checkbox"; -import { classNames } from "../utils"; +import { baseUrl, classNames, simplifyDate } from "../utils"; import { SettingsContext } from "../utils/Context"; +import { EmptySimple } from "../components/emptystates"; +import { + Cog6ToothIcon, + DocumentArrowDownIcon +} from "@heroicons/react/24/outline"; +import { useQuery } from "react-query"; +import { Link } from "react-router-dom"; +import { Menu, Transition } from "@headlessui/react"; type LogEvent = { time: string; @@ -13,18 +21,19 @@ type LogEvent = { message: string; }; -type LogLevel = "TRACE" | "DEBUG" | "INFO" | "ERROR"; +type LogLevel = "TRACE" | "DEBUG" | "INFO" | "ERROR" | "WARN"; const LogColors: Record = { "TRACE": "text-purple-300", "DEBUG": "text-yellow-500", "INFO": "text-green-500", - "ERROR": "text-red-500" + "ERROR": "text-red-500", + "WARN": "text-yellow-500" }; export const Logs = () => { - const [settings, setSettings] = SettingsContext.use(); - + const [settings] = SettingsContext.use(); + const messagesEndRef = useRef(null); const [logs, setLogs] = useState([]); @@ -64,48 +73,43 @@ export const Logs = () => { setFilteredLogs(newLogs); }, [logs, searchFilter]); - const onSetValue = ( - key: "scrollOnNewLog" | "indentLogLines" | "hideWrappedText", - newValue: boolean - ) => setSettings((prevState) => ({ - ...prevState, - [key]: newValue - })); - return (

Logs

-
-
+ +
-
- setSearchFilter(event.target.value.toLowerCase().trim())} - id="filter" - type="text" - autoComplete="off" - className={classNames( - "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700", - "block w-full dark:bg-gray-800 shadow-sm dark:text-gray-100 sm:text-sm rounded-md" - )} - placeholder="Enter a string to filter logs by..." +
+
); }; + +export const LogFiles = () => { + const { isLoading, data } = useQuery( + ["log-files"], + () => APIClient.logs.files(), + { + retry: false, + refetchOnWindowFocus: false, + onError: err => console.log(err) + } + ); + + return ( +
+
+

Log files

+

+ Download old log files. +

+
+ + {data && data.files.length > 0 ? ( +
+
    +
  1. +
    + Name +
    +
    + Size +
    +
    + Last modified +
    +
  2. + + {data && data.files.map((f, idx) => )} +
+
+ ) : ( + + )} +
+ ); +}; + +interface LogFilesItemProps { + file: LogFile; +} + +const LogFilesItem = ({ file }: LogFilesItemProps) => { + return ( + +
  • +
    +
    +
    + {file.filename} +
    +
    +
    + {file.size} +
    + +
    + {simplifyDate(file.updated_at)} +
    + +
    + +
    +
    +
  • + ); +}; + +// interface LogsDropdownProps {} + +const LogsDropdown = () => { + const [settings, setSettings] = SettingsContext.use(); + + const onSetValue = ( + key: "scrollOnNewLog" | "indentLogLines" | "hideWrappedText", + newValue: boolean + ) => setSettings((prevState) => ({ + ...prevState, + [key]: newValue + })); + + return ( + + + + + +
    + + {({ active }) => ( + onSetValue("scrollOnNewLog", newValue)} + /> + )} + + + {({ active }) => ( + onSetValue("indentLogLines", newValue)} + /> + )} + + + {({ active }) => ( + onSetValue("hideWrappedText", newValue)} + /> + )} + +
    +
    +
    +
    + ); +}; diff --git a/web/src/screens/settings/Application.tsx b/web/src/screens/settings/Application.tsx index 1e2d91e..30e932d 100644 --- a/web/src/screens/settings/Application.tsx +++ b/web/src/screens/settings/Application.tsx @@ -20,7 +20,7 @@ const RowItem = ({ label, value, title, emptyText }: RowItemProps) => {
    {label}:
    - {value ? value : emptyText} + {value ? {value} : emptyText}
    ); @@ -55,7 +55,7 @@ const RowItemVersion = ({ label, value, title, newUpdate }: RowItemProps) => {
    {label}:
    - {value} + {value} {newUpdate && newUpdate.html_url && ( {newUpdate.name} available! diff --git a/web/src/screens/settings/Logs.tsx b/web/src/screens/settings/Logs.tsx index 5f2348e..5a27ac1 100644 --- a/web/src/screens/settings/Logs.tsx +++ b/web/src/screens/settings/Logs.tsx @@ -6,6 +6,7 @@ import Toast from "../../components/notifications/Toast"; import { queryClient } from "../../App"; import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select"; import { LogLevelOptions, SelectOption } from "../../domain/constants"; +import { LogFiles } from "../Logs"; interface RowItemProps { label: string; @@ -20,7 +21,7 @@ const RowItem = ({ label, value, title, emptyText }: RowItemProps) => {
    {label}:
    - {value ? value : emptyText} + {value ? value : emptyText}
    ); @@ -168,6 +169,10 @@ function LogSettings() { +
    + +
    + {/*
    */} {/*