e.stopPropagation()}
>
-
{title}
+
{title}
diff --git a/webui/src/components/CopyButton.tsx b/webui/src/components/CopyButton.tsx
index ee6fe4ea0..4bfde1d32 100644
--- a/webui/src/components/CopyButton.tsx
+++ b/webui/src/components/CopyButton.tsx
@@ -1,4 +1,4 @@
-import { useState, type JSX } from "react";
+import { useEffect, useRef, useState, type JSX } from "react";
import { copyToClipboard } from "../utils/clipboard";
interface CopyButtonProps {
@@ -15,13 +15,19 @@ export function CopyButton({
onCopy,
}: CopyButtonProps): JSX.Element {
const [copied, setCopied] = useState(false);
+ const timerRef = useRef
>(undefined);
+
+ useEffect(() => {
+ return () => { clearTimeout(timerRef.current); };
+ }, []);
const handleCopy = async () => {
const success = await copyToClipboard(text);
if (success) {
setCopied(true);
onCopy?.();
- setTimeout(() => setCopied(false), 2000);
+ clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => setCopied(false), 2000);
}
};
diff --git a/webui/src/components/TagInput.tsx b/webui/src/components/TagInput.tsx
index b37698825..494f7b5ee 100644
--- a/webui/src/components/TagInput.tsx
+++ b/webui/src/components/TagInput.tsx
@@ -33,7 +33,7 @@ export function TagInput({ value, onChange, placeholder, disabled }: TagInputPro
{value.map((tag, index) => (
-
+
{tag}
{!disabled && (
@@ -2564,7 +2721,7 @@ function FieldGroup({
localStorage.setItem("theme", theme);
}
}}
- styles={getSelectStyles()}
+ styles={selectStyles}
/>
{description &&
{description}
}
{isThemeField &&
Theme changes apply immediately
}
@@ -2584,6 +2741,21 @@ function FieldGroup({
);
}
+ if (field.type === "duration") {
+ return (
+
+
+
onChange(path, field, v)}
+ placeholder={field.placeholder}
+ nativeUnit={field.nativeUnit ?? "seconds"}
+ allowNegative={field.allowNegative ?? false}
+ />
+ {description && {description}
}
+
+ );
+ }
if (field.type === "password") {
return (
diff --git a/webui/src/pages/LidarrView.tsx b/webui/src/pages/LidarrView.tsx
index 4a24d6ab1..de483abeb 100644
--- a/webui/src/pages/LidarrView.tsx
+++ b/webui/src/pages/LidarrView.tsx
@@ -32,7 +32,6 @@ import { useDebounce } from "../hooks/useDebounce";
import { useDataSync } from "../hooks/useDataSync";
import { IconImage } from "../components/IconImage";
import RefreshIcon from "../icons/refresh-arrow.svg";
-import RestartIcon from "../icons/refresh-arrow.svg";
interface LidarrAggRow extends LidarrAlbumEntry {
__instance: string;
@@ -573,26 +572,6 @@ function LidarrInstanceView({
const isFiltered = reasonFilter !== "all" || onlyMissing;
const filteredCount = reasonFilteredAlbums.length;
- // Count total tracks for instance view
- // Track counts (for potential future use)
- // const totalTracks = useMemo(() => {
- // let count = 0;
- // allAlbums.forEach(entry => {
- // const tracks = entry.tracks || [];
- // count += tracks.length;
- // });
- // return count;
- // }, [allAlbums]);
-
- // const filteredTracks = useMemo(() => {
- // let count = 0;
- // reasonFilteredAlbums.forEach(entry => {
- // const tracks = entry.tracks || [];
- // count += tracks.length;
- // });
- // return count;
- // }, [reasonFilteredAlbums]);
-
// Reset flat page when filters change
useEffect(() => {
setFlatPage(0);
@@ -777,7 +756,7 @@ function LidarrInstanceView({
{lastUpdated ? ` (updated ${lastUpdated})` : ""}
@@ -1365,7 +1344,7 @@ export function LidarrView({ active }: { active: boolean }): JSX.Element {
} finally {
setAggLoading(false);
}
- }, [instances, globalSearch, push, aggFilter, groupLidarr]);
+ }, [instances, globalSearch, push, aggFilter, groupLidarr, aggSummary]);
// LiveArr is now loaded via WebUIContext, no need to load config here
diff --git a/webui/src/pages/LogsView.tsx b/webui/src/pages/LogsView.tsx
index d75259730..418dfd3ea 100644
--- a/webui/src/pages/LogsView.tsx
+++ b/webui/src/pages/LogsView.tsx
@@ -1,7 +1,8 @@
-import { useCallback, useEffect, useRef, useState, type JSX } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
import { LazyLog } from "@melloware/react-logviewer";
-import { getConfig, getLogDownloadUrl, getLogs } from "../api/client";
+import { getLogDownloadUrl, getLogTail, getLogs } from "../api/client";
import { useToast } from "../context/ToastContext";
+import { useWebUI } from "../context/WebUIContext";
import { useInterval } from "../hooks/useInterval";
import { IconImage } from "../components/IconImage";
import { CopyButton } from "../components/CopyButton";
@@ -12,9 +13,7 @@ interface LogOption {
label: string;
}
-// Helper function for react-select theme-aware styles
-const getSelectStyles = (): StylesConfig
=> {
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
+const getSelectStyles = (isDark: boolean): StylesConfig => {
return {
control: (base: CSSObjectWithLabel) => ({
...base,
@@ -70,27 +69,37 @@ interface LogsViewProps {
active: boolean;
}
+function describeError(reason: unknown, context: string): string {
+ if (reason instanceof Error && reason.message) {
+ return `${context}: ${reason.message}`;
+ }
+ if (typeof reason === "string" && reason.trim().length) {
+ return `${context}: ${reason}`;
+ }
+ return context;
+}
+
+/** Number of lines to fetch per chunk for the log viewer; keeps load fast. */
+const DEFAULT_LOG_TAIL_LINES = 2000;
+
export function LogsView({ active }: LogsViewProps): JSX.Element {
const [files, setFiles] = useState([]);
const [selected, setSelected] = useState("All.log");
const [logContent, setLogContent] = useState("");
const [follow, setFollow] = useState(true);
+ /** When true, poll for new log content every second; when false, only refresh on manual Refresh. */
+ const [liveUpdates, setLiveUpdates] = useState(true);
+ /** Number of lines we've loaded "above" the tail (for load-more). When 0, we only have the tail. */
+ const [offsetFromEnd, setOffsetFromEnd] = useState(0);
const [loadingList, setLoadingList] = useState(false);
const [loadingContent, setLoadingContent] = useState(false);
- const [tokenReady, setTokenReady] = useState(false);
- const tokenRef = useRef("");
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMoreAbove, setHasMoreAbove] = useState(true);
const lastLinesCountRef = useRef(0);
const { push } = useToast();
-
- const describeError = useCallback((reason: unknown, context: string): string => {
- if (reason instanceof Error && reason.message) {
- return `${context}: ${reason.message}`;
- }
- if (typeof reason === "string" && reason.trim().length) {
- return `${context}: ${reason}`;
- }
- return context;
- }, []);
+ const { theme } = useWebUI();
+ const isDark = theme === 'dark';
+ const selectStyles = useMemo(() => getSelectStyles(isDark), [isDark]);
const loadList = useCallback(async () => {
setLoadingList(true);
@@ -115,55 +124,30 @@ export function LogsView({ active }: LogsViewProps): JSX.Element {
} finally {
setLoadingList(false);
}
- }, [describeError, push]);
-
- // Fetch and cache the WebUI token from config on mount
- useEffect(() => {
- const fetchToken = async () => {
- try {
- const config = await getConfig();
- // Extract token from WebUI.Token field
- const webui = config?.WebUI as { Token?: string } | undefined;
- tokenRef.current = webui?.Token || "";
- } catch {
- // token fetch failed, will proceed without token
- } finally {
- setTokenReady(true);
- }
- };
- void fetchToken();
- }, []);
+ }, [push]);
useEffect(() => {
void loadList();
}, [loadList]);
const fetchLogContent = useCallback(
- async (showLoading: boolean = false) => {
+ async (showLoading: boolean = false, onlyIfTail: boolean = false) => {
if (!selected) return;
+ if (onlyIfTail && offsetFromEnd > 0) return;
if (showLoading) setLoadingContent(true);
try {
- const params = new URLSearchParams();
- if (tokenRef.current) {
- params.set("token", tokenRef.current);
- }
-
- const response = await fetch(
- `/web/logs/${encodeURIComponent(selected)}?${params}`
- );
- if (!response.ok) {
- throw new Error(`${response.status} ${response.statusText}`);
- }
-
- const newContent = await response.text();
- const newLines = newContent.split('\n');
+ const newContent = await getLogTail(selected, DEFAULT_LOG_TAIL_LINES, 0);
+ const newLines = newContent.split("\n");
const currentLinesCount = newLines.length;
- // Only update if content has changed (new lines added)
- if (currentLinesCount !== lastLinesCountRef.current) {
+ if (currentLinesCount !== lastLinesCountRef.current || !onlyIfTail) {
setLogContent(newContent);
lastLinesCountRef.current = currentLinesCount;
+ if (!onlyIfTail) {
+ setOffsetFromEnd(0);
+ setHasMoreAbove(true);
+ }
}
} catch (error) {
push(describeError(error, `Failed to read ${selected}`), "error");
@@ -171,24 +155,55 @@ export function LogsView({ active }: LogsViewProps): JSX.Element {
if (showLoading) setLoadingContent(false);
}
},
- [selected, push, describeError]
+ [selected, push, offsetFromEnd]
);
+ const loadMoreAbove = useCallback(async () => {
+ if (!selected || loadingMore || !hasMoreAbove) return;
+ const nextOffset = offsetFromEnd + DEFAULT_LOG_TAIL_LINES;
+ setLoadingMore(true);
+ try {
+ const olderContent = await getLogTail(
+ selected,
+ DEFAULT_LOG_TAIL_LINES,
+ nextOffset
+ );
+ if (olderContent.length === 0) {
+ setHasMoreAbove(false);
+ } else {
+ setLogContent((prev) =>
+ prev ? `${olderContent}\n${prev}` : olderContent
+ );
+ setOffsetFromEnd(nextOffset);
+ }
+ } catch (error) {
+ push(
+ describeError(error, `Failed to load older logs for ${selected}`),
+ "error"
+ );
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [selected, offsetFromEnd, loadingMore, hasMoreAbove, push]);
+
+ const handleRefreshLogs = useCallback(() => {
+ void fetchLogContent(true, false);
+ }, [fetchLogContent]);
+
useEffect(() => {
- if (selected && tokenReady) {
+ if (selected) {
lastLinesCountRef.current = 0;
+ setOffsetFromEnd(0);
+ setHasMoreAbove(true);
void fetchLogContent(true);
}
- }, [selected, tokenReady, fetchLogContent]);
+ }, [selected, fetchLogContent]);
- // Refresh content periodically when tab is active
useInterval(
() => {
- if (tokenReady) {
- void fetchLogContent(false);
- }
+ void fetchLogContent(false, true);
},
- active ? 1000 : null
+ active && liveUpdates ? 1000 : null
);
@@ -206,7 +221,7 @@ export function LogsView({ active }: LogsViewProps): JSX.Element {
value={selected ? { value: selected, label: selected } : null}
onChange={(option) => setSelected(option?.value || "")}
isDisabled={!files.length}
- styles={getSelectStyles()}
+ styles={selectStyles}
/>
@@ -217,6 +232,14 @@ export function LogsView({ active }: LogsViewProps): JSX.Element {