diff --git a/ui/app/assets/css/tailwind.css b/ui/app/assets/css/tailwind.css
index 786d1cdf..db327c5a 100644
--- a/ui/app/assets/css/tailwind.css
+++ b/ui/app/assets/css/tailwind.css
@@ -65,6 +65,23 @@
backdrop-filter: blur(16px);
}
+ .ytp-terminal {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 1rem;
+ background-color: var(--ui-bg-elevated);
+ color: var(--ui-text);
+ line-height: 1.7;
+ border-radius: 0.5rem;
+ border: 1px solid var(--ui-border);
+ }
+
+ .ytp-terminal > code {
+ display: block;
+ font-family: var(--font-mono, monospace);
+ }
+
.play-overlay {
position: relative;
display: block;
diff --git a/ui/app/components/GetInfo.vue b/ui/app/components/GetInfo.vue
index e0f1238e..388adc7a 100644
--- a/ui/app/components/GetInfo.vue
+++ b/ui/app/components/GetInfo.vue
@@ -35,13 +35,7 @@
variant="soft"
icon="i-lucide-filter"
title="No matching lines"
- >
-
-
- No lines match this filter: {{ query }}
-
-
-
+ />
-
-
-
+
+
+ Wrap
+
+
diff --git a/ui/app/components/StatCard.vue b/ui/app/components/StatCard.vue
new file mode 100644
index 00000000..16a6acc8
--- /dev/null
+++ b/ui/app/components/StatCard.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
{{ label }}
+
+
+ {{ value }}
+ {{ hint }}
+
+
+
+ {{ value }}
+ {{ hint }}
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/app/pages/logs.vue b/ui/app/pages/logs.vue
index f590ccec..c0d4f665 100644
--- a/ui/app/pages/logs.vue
+++ b/ui/app/pages/logs.vue
@@ -1,3 +1,20 @@
+
+
@@ -111,7 +128,7 @@
-
-
-
-
- {{ logTimeLabel(entry.log.datetime) }}
-
-
-
-
-
- {{ getLogLevel(entry.log.level) }}
+
+
+
+ {{ logTimeLabel(entry.log.datetime) }}
- [{{ entry.log.logger }}]
+
+
+
+
+ {{ getLogLevel(entry.log.level) }}
- {{ entry.log.message }}
-
- : {{ exceptionSummary(entry.log) }}
-
-
+
[{{ entry.log.logger }}]
+
+
+
@@ -284,7 +301,7 @@ const route = useRoute();
const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef
('logContainer');
-const textWrap = useStorage('logs_wrap', true);
+const textWrap = useStorage('logs_text_wrap', false);
const selectedLevels = useStorage('logs_level_filter', [...LOG_LEVELS]);
const sseController = ref(null);
const runtimeLogLevel = ref(null);
@@ -297,6 +314,7 @@ const loading = ref(false);
const autoScroll = ref(true);
const reachedEnd = ref(false);
const detailsOpen = ref(false);
+const expandedRows = ref>(new Set());
const pageCardUi = {
root: 'w-full min-w-0 max-w-full bg-transparent',
@@ -765,14 +783,23 @@ const logLevelBadgeClass = (level: LogLevel): string[] => [
level === 'error' ? 'bg-error/10 text-error' : '',
];
-const logLineClass = (): string[] => [
- 'flex-1',
- textWrap.value
- ? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
- : 'min-w-max whitespace-pre',
- 'text-default',
+const messageClass = (id: string): string[] => [
+ 'block text-sm text-default',
+ expandedRows.value.has(id) || textWrap.value
+ ? 'whitespace-pre-wrap wrap-break-word'
+ : 'message-collapsed md:block md:truncate',
];
+const toggleRow = (id: string): void => {
+ const next = new Set(expandedRows.value);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ expandedRows.value = next;
+};
+
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
const classes = [LOG_ROW_CLASS];
diff --git a/ui/app/utils/logs.ts b/ui/app/utils/logs.ts
new file mode 100644
index 00000000..586fec0a
--- /dev/null
+++ b/ui/app/utils/logs.ts
@@ -0,0 +1,59 @@
+const formatPathValue = (value: unknown): string => {
+ if (typeof value === 'string') {
+ return value;
+ }
+
+ if (typeof value === 'number' || typeof value === 'boolean' || null === value) {
+ return String(value);
+ }
+
+ return JSON.stringify(value);
+};
+
+const flattenJsonPaths = (value: unknown, prefix = ''): Array => {
+ if (Array.isArray(value)) {
+ if (0 === value.length) {
+ return prefix ? [`${prefix}: []`] : [];
+ }
+
+ return value.flatMap((item, index) =>
+ flattenJsonPaths(item, prefix ? `${prefix}.${index}` : String(index)),
+ );
+ }
+
+ if (value && typeof value === 'object') {
+ const entries = Object.entries(value as Record);
+ if (0 === entries.length) {
+ return prefix ? [`${prefix}: {}`] : [];
+ }
+
+ return entries.flatMap(([key, item]) =>
+ flattenJsonPaths(item, prefix ? `${prefix}.${key}` : key),
+ );
+ }
+
+ return prefix ? [`${prefix}: ${formatPathValue(value)}`] : [];
+};
+
+export const filterLogTextLines = (value: string, filter: string): Array => {
+ const query = filter.trim();
+ if (!query) {
+ return value.split('\n');
+ }
+
+ const needle = query.toLowerCase();
+
+ if (query.includes('.')) {
+ try {
+ const flattened = flattenJsonPaths(JSON.parse(value) as unknown);
+ const matches = flattened.filter((line) => line.toLowerCase().includes(needle));
+ if (matches.length > 0) {
+ return matches;
+ }
+ } catch {
+ // Fall back to plain line filtering for non-JSON content.
+ }
+ }
+
+ return value.split('\n').filter((line) => line.toLowerCase().includes(needle));
+};