mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
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
This commit is contained in:
parent
21724f29f6
commit
b21c01a7df
7 changed files with 394 additions and 79 deletions
141
internal/http/logs.go
Normal file
141
internal/http/logs.go
Normal file
|
@ -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"`
|
||||||
|
}
|
|
@ -133,6 +133,7 @@ func (s Server) Handler() http.Handler {
|
||||||
r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes)
|
r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes)
|
||||||
r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, s.ircService).Routes)
|
r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, s.ircService).Routes)
|
||||||
r.Route("/keys", newAPIKeyHandler(encoder, s.apiService).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("/notification", newNotificationHandler(encoder, s.notificationService).Routes)
|
||||||
r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes)
|
r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes)
|
||||||
r.Route("/updates", newUpdateHandler(encoder, s.updateService).Routes)
|
r.Route("/updates", newUpdateHandler(encoder, s.updateService).Routes)
|
||||||
|
|
|
@ -42,7 +42,7 @@ export async function HttpClient<T>(
|
||||||
// Resolve immediately since 204 contains no data
|
// Resolve immediately since 204 contains no data
|
||||||
if (response.status === 204)
|
if (response.status === 204)
|
||||||
return Promise.resolve(response);
|
return Promise.resolve(response);
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -144,6 +144,10 @@ export const APIClient = {
|
||||||
deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`),
|
deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`),
|
||||||
restartNetwork: (id: number) => appClient.Get(`api/irc/network/${id}/restart`)
|
restartNetwork: (id: number) => appClient.Get(`api/irc/network/${id}/restart`)
|
||||||
},
|
},
|
||||||
|
logs: {
|
||||||
|
files: () => appClient.Get<LogFileResponse>("api/logs/files"),
|
||||||
|
getFile: (file: string) => appClient.Get(`api/logs/files/${file}`)
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
logs: () => new EventSource(`${sseBaseUrl()}api/events?stream=logs`, { withCredentials: true })
|
logs: () => new EventSource(`${sseBaseUrl()}api/events?stream=logs`, { withCredentials: true })
|
||||||
},
|
},
|
||||||
|
@ -185,6 +189,6 @@ export const APIClient = {
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
check: () => appClient.Get("api/updates/check"),
|
check: () => appClient.Get("api/updates/check"),
|
||||||
getLatestRelease: () => appClient.Get<GithubRelease|undefined>("api/updates/latest")
|
getLatestRelease: () => appClient.Get<GithubRelease | undefined>("api/updates/latest")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||||
import format from "date-fns/format";
|
import format from "date-fns/format";
|
||||||
import { DebounceInput } from "react-debounce-input";
|
import { DebounceInput } from "react-debounce-input";
|
||||||
import { APIClient } from "../api/APIClient";
|
import { APIClient } from "../api/APIClient";
|
||||||
import { Checkbox } from "../components/Checkbox";
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { classNames } from "../utils";
|
import { baseUrl, classNames, simplifyDate } from "../utils";
|
||||||
import { SettingsContext } from "../utils/Context";
|
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 = {
|
type LogEvent = {
|
||||||
time: string;
|
time: string;
|
||||||
|
@ -13,18 +21,19 @@ type LogEvent = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LogLevel = "TRACE" | "DEBUG" | "INFO" | "ERROR";
|
type LogLevel = "TRACE" | "DEBUG" | "INFO" | "ERROR" | "WARN";
|
||||||
|
|
||||||
const LogColors: Record<LogLevel, string> = {
|
const LogColors: Record<LogLevel, string> = {
|
||||||
"TRACE": "text-purple-300",
|
"TRACE": "text-purple-300",
|
||||||
"DEBUG": "text-yellow-500",
|
"DEBUG": "text-yellow-500",
|
||||||
"INFO": "text-green-500",
|
"INFO": "text-green-500",
|
||||||
"ERROR": "text-red-500"
|
"ERROR": "text-red-500",
|
||||||
|
"WARN": "text-yellow-500"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Logs = () => {
|
export const Logs = () => {
|
||||||
const [settings, setSettings] = SettingsContext.use();
|
const [settings] = SettingsContext.use();
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [logs, setLogs] = useState<LogEvent[]>([]);
|
const [logs, setLogs] = useState<LogEvent[]>([]);
|
||||||
|
@ -64,48 +73,43 @@ export const Logs = () => {
|
||||||
setFilteredLogs(newLogs);
|
setFilteredLogs(newLogs);
|
||||||
}, [logs, searchFilter]);
|
}, [logs, searchFilter]);
|
||||||
|
|
||||||
const onSetValue = (
|
|
||||||
key: "scrollOnNewLog" | "indentLogLines" | "hideWrappedText",
|
|
||||||
newValue: boolean
|
|
||||||
) => setSettings((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
[key]: newValue
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<header className="pt-10 pb-5">
|
<header className="pt-10 pb-5">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<h1 className="text-3xl font-bold text-black dark:text-white">Logs</h1>
|
<h1 className="text-3xl font-bold text-black dark:text-white">Logs</h1>
|
||||||
<div className="flex justify-center mt-1">
|
|
||||||
<ExclamationTriangleIcon
|
|
||||||
className="h-5 w-5 text-yellow-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<p className="ml-2 text-sm text-black dark:text-gray-400">This page shows only new logs, i.e. no history.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-4 lg:px-8">
|
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-4 lg:px-8">
|
||||||
<div
|
<div className="flex justify-center py-4">
|
||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg px-2 sm:px-4 pt-3 sm:pt-4"
|
<ExclamationTriangleIcon
|
||||||
>
|
className="h-5 w-5 text-yellow-400"
|
||||||
<DebounceInput
|
aria-hidden="true"
|
||||||
minLength={2}
|
|
||||||
debounceTimeout={200}
|
|
||||||
onChange={(event) => 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..."
|
|
||||||
/>
|
/>
|
||||||
<div
|
<p className="ml-2 text-sm text-black dark:text-gray-400">This page shows only new logs, i.e. no history.</p>
|
||||||
className="mt-2 overflow-y-auto p-2 rounded-lg h-[60vh] min-w-full bg-gray-100 dark:bg-gray-900 overflow-auto"
|
</div>
|
||||||
>
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg px-2 sm:px-4 pt-3 sm:pt-4 pb-3 sm:pb-4">
|
||||||
|
<div className="flex relative mb-3">
|
||||||
|
<DebounceInput
|
||||||
|
minLength={2}
|
||||||
|
debounceTimeout={200}
|
||||||
|
onChange={(event) => 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-900 shadow-sm dark:text-gray-100 sm:text-sm rounded-md"
|
||||||
|
)}
|
||||||
|
placeholder="Enter a string to filter logs by..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LogsDropdown />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto px-2 rounded-lg h-[60vh] min-w-full bg-gray-100 dark:bg-gray-900 overflow-auto">
|
||||||
{filteredLogs.map((entry, idx) => (
|
{filteredLogs.map((entry, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
|
@ -136,27 +140,176 @@ export const Logs = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<div className="mt-6" ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
|
||||||
label="Scroll to bottom on new message"
|
|
||||||
value={settings.scrollOnNewLog}
|
|
||||||
setValue={(newValue) => onSetValue("scrollOnNewLog", newValue)}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Indent log lines"
|
|
||||||
description="Indent each log line according to their respective starting position."
|
|
||||||
value={settings.indentLogLines}
|
|
||||||
setValue={(newValue) => onSetValue("indentLogLines", newValue)}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Hide wrapped text"
|
|
||||||
description="Hides text that is meant to be wrapped."
|
|
||||||
value={settings.hideWrappedText}
|
|
||||||
setValue={(newValue) => onSetValue("hideWrappedText", newValue)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-screen-xl mx-auto pb-10 px-2 sm:px-4 lg:px-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg px-2 sm:px-4 pt-3 sm:pt-4">
|
||||||
|
<LogFiles />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const LogFiles = () => {
|
||||||
|
const { isLoading, data } = useQuery(
|
||||||
|
["log-files"],
|
||||||
|
() => APIClient.logs.files(),
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
onError: err => console.log(err)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Log files</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Download old log files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && data.files.length > 0 ? (
|
||||||
|
<section className="py-3 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
|
||||||
|
<ol className="min-w-full relative">
|
||||||
|
<li className="hidden sm:grid grid-cols-12 gap-4 mb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="col-span-5 px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Size
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Last modified
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{data && data.files.map((f, idx) => <LogFilesItem key={idx} file={f} />)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<EmptySimple
|
||||||
|
title="No old log files"
|
||||||
|
subtitle=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LogFilesItemProps {
|
||||||
|
file: LogFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogFilesItem = ({ file }: LogFilesItemProps) => {
|
||||||
|
return (
|
||||||
|
|
||||||
|
<li className="text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="sm:grid grid-cols-12 gap-4 items-center py-2">
|
||||||
|
<div className="col-span-5 px-2 py-2 sm:py-0 truncate block sm:text-sm text-md font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{file.filename}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center text-sm font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
{file.size}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-4 flex items-center text-sm font-medium text-gray-900 dark:text-gray-200" title={file.updated_at}>
|
||||||
|
{simplifyDate(file.updated_at)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-1 hidden sm:flex items-center justify-center text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
<Link
|
||||||
|
className={classNames(
|
||||||
|
"text-gray-900 dark:text-gray-300",
|
||||||
|
"font-medium group flex rounded-md items-center px-2 py-2 text-sm"
|
||||||
|
)}
|
||||||
|
title="Download file"
|
||||||
|
to={`${baseUrl()}api/logs/files/${file.filename}`}
|
||||||
|
target="_blank"
|
||||||
|
download={true}
|
||||||
|
>
|
||||||
|
<DocumentArrowDownIcon className="text-blue-500 w-5 h-5" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// interface LogsDropdownProps {}
|
||||||
|
|
||||||
|
const LogsDropdown = () => {
|
||||||
|
const [settings, setSettings] = SettingsContext.use();
|
||||||
|
|
||||||
|
const onSetValue = (
|
||||||
|
key: "scrollOnNewLog" | "indentLogLines" | "hideWrappedText",
|
||||||
|
newValue: boolean
|
||||||
|
) => setSettings((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
[key]: newValue
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu as="div">
|
||||||
|
<Menu.Button className="px-4 py-2">
|
||||||
|
<Cog6ToothIcon
|
||||||
|
className="w-5 h-5 text-gray-700 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Menu.Button>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
className="absolute right-0 mt-1 origin-top-right bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<Checkbox
|
||||||
|
label="Scroll to bottom on new message"
|
||||||
|
value={settings.scrollOnNewLog}
|
||||||
|
setValue={(newValue) => onSetValue("scrollOnNewLog", newValue)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<Checkbox
|
||||||
|
label="Indent log lines"
|
||||||
|
description="Indent each log line according to their respective starting position."
|
||||||
|
value={settings.indentLogLines}
|
||||||
|
setValue={(newValue) => onSetValue("indentLogLines", newValue)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<Checkbox
|
||||||
|
label="Hide wrapped text"
|
||||||
|
description="Hides text that is meant to be wrapped."
|
||||||
|
value={settings.hideWrappedText}
|
||||||
|
setValue={(newValue) => onSetValue("hideWrappedText", newValue)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -20,7 +20,7 @@ const RowItem = ({ label, value, title, emptyText }: RowItemProps) => {
|
||||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
|
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
|
||||||
<dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
|
<dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
|
||||||
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
|
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
|
||||||
{value ? value : emptyText}
|
{value ? <span className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded shadow">{value}</span> : emptyText}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -55,7 +55,7 @@ const RowItemVersion = ({ label, value, title, newUpdate }: RowItemProps) => {
|
||||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
|
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
|
||||||
<dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
|
<dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
|
||||||
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
|
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
|
||||||
{value}
|
<span className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded shadow">{value}</span>
|
||||||
{newUpdate && newUpdate.html_url && (
|
{newUpdate && newUpdate.html_url && (
|
||||||
<span>
|
<span>
|
||||||
<a href={newUpdate.html_url} target="_blank"><span className="ml-2 inline-flex items-center rounded-md bg-green-100 px-2.5 py-0.5 text-sm font-medium text-green-800">{newUpdate.name} available!</span></a>
|
<a href={newUpdate.html_url} target="_blank"><span className="ml-2 inline-flex items-center rounded-md bg-green-100 px-2.5 py-0.5 text-sm font-medium text-green-800">{newUpdate.name} available!</span></a>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import Toast from "../../components/notifications/Toast";
|
||||||
import { queryClient } from "../../App";
|
import { queryClient } from "../../App";
|
||||||
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
|
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
|
||||||
import { LogLevelOptions, SelectOption } from "../../domain/constants";
|
import { LogLevelOptions, SelectOption } from "../../domain/constants";
|
||||||
|
import { LogFiles } from "../Logs";
|
||||||
|
|
||||||
interface RowItemProps {
|
interface RowItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -20,7 +21,7 @@ const RowItem = ({ label, value, title, emptyText }: RowItemProps) => {
|
||||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
|
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
|
||||||
<dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
|
<dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
|
||||||
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
|
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
|
||||||
{value ? value : emptyText}
|
<span className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded shadow">{value ? value : emptyText}</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -168,6 +169,10 @@ function LogSettings() {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col py-4 px-4 sm:px-6">
|
||||||
|
<LogFiles />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/*<div className="mt-4 flex justify-end py-4 px-4 sm:px-6">*/}
|
{/*<div className="mt-4 flex justify-end py-4 px-4 sm:px-6">*/}
|
||||||
{/* <button*/}
|
{/* <button*/}
|
||||||
{/* type="button"*/}
|
{/* type="button"*/}
|
||||||
|
|
45
web/src/types/Config.d.ts
vendored
45
web/src/types/Config.d.ts
vendored
|
@ -1,24 +1,35 @@
|
||||||
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" | "TRACE";
|
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" | "TRACE";
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
log_level: LogLevel;
|
log_level: LogLevel;
|
||||||
log_path: string;
|
log_path: string;
|
||||||
log_max_size: number;
|
log_max_size: number;
|
||||||
log_max_backups: number;
|
log_max_backups: number;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
check_for_updates: boolean;
|
check_for_updates: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
commit: string;
|
commit: string;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfigUpdate {
|
interface ConfigUpdate {
|
||||||
host?: string;
|
host?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
log_level?: string;
|
log_level?: string;
|
||||||
log_path?: string;
|
log_path?: string;
|
||||||
base_url?: string;
|
base_url?: string;
|
||||||
check_for_updates?: boolean;
|
check_for_updates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LogFile {
|
||||||
|
filename: string;
|
||||||
|
size: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogFileResponse {
|
||||||
|
files: LogFile[];
|
||||||
|
count: number;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue