+ {lines.map((line, idx) => (
+
+ {line}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/Logs/LogViewer.module.css b/frontend/src/pages/Logs/LogViewer.module.css
new file mode 100644
index 000000000..09cb1c88c
--- /dev/null
+++ b/frontend/src/pages/Logs/LogViewer.module.css
@@ -0,0 +1,15 @@
+.logBox {
+ height: 65vh;
+ overflow-y: auto;
+ padding: 0.75rem 1rem;
+ margin: 0;
+ font-family: var(--tblr-font-monospace, ui-monospace, monospace);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.logLine {
+ display: block;
+}
diff --git a/frontend/src/pages/Logs/LogViewer.tsx b/frontend/src/pages/Logs/LogViewer.tsx
new file mode 100644
index 000000000..728420c12
--- /dev/null
+++ b/frontend/src/pages/Logs/LogViewer.tsx
@@ -0,0 +1,249 @@
+import { useEffect, useMemo, useState } from "react";
+import Alert from "react-bootstrap/Alert";
+import type { LogChannel, LogHostType, LogSourceType } from "src/api/backend";
+import { Loading } from "src/components";
+import { useLogSources, useLogTail } from "src/hooks";
+import { T, intl } from "src/locale";
+import LogLines from "./LogLines";
+
+const LINES_OPTIONS = [100, 200, 500, 1000];
+const LEVEL_OPTIONS = ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"];
+const SEARCH_DEBOUNCE_MS = 300;
+
+const HOST_TYPES: { type: LogHostType; labelId: string }[] = [
+ { type: "proxy", labelId: "proxy-hosts" },
+ { type: "redirection", labelId: "redirection-hosts" },
+ { type: "dead", labelId: "dead-hosts" },
+ { type: "stream", labelId: "streams" },
+];
+
+interface Selection {
+ type: LogSourceType;
+ hostType?: LogHostType;
+ hostId?: number;
+}
+
+const encodeSelection = (selection: Selection): string => {
+ if (selection.type === "host") {
+ return `host:${selection.hostType}:${selection.hostId}`;
+ }
+ return selection.type;
+};
+
+const decodeSelection = (value: string): Selection => {
+ if (value === "system" || value === "letsencrypt") {
+ return { type: value };
+ }
+ const [, hostType, hostId] = value.split(":");
+ return { type: "host", hostType: hostType as LogHostType, hostId: Number(hostId) };
+};
+
+const formatBytes = (bytes: number): string => {
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+ const units = ["KB", "MB", "GB"];
+ let value = bytes / 1024;
+ let unitIndex = 0;
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024;
+ unitIndex++;
+ }
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
+};
+
+export default function LogViewer() {
+ const sourcesQuery = useLogSources();
+
+ const [selection, setSelection] = useState