@aiaiai-pt/design-system 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Alert.svelte +3 -3
- package/components/BottomNavItem.svelte +2 -2
- package/components/Button.svelte +2 -2
- package/components/CardGrid.svelte +104 -0
- package/components/Checkbox.svelte +2 -2
- package/components/Combobox.svelte +1 -1
- package/components/CommandPalette.svelte +605 -0
- package/components/ConditionTable.svelte +12 -1
- package/components/FileUploadItem.svelte +3 -3
- package/components/FilterBar.svelte +255 -0
- package/components/Input.svelte +3 -3
- package/components/LogViewer.svelte +410 -0
- package/components/Modal.svelte +2 -2
- package/components/PageContainer.svelte +74 -0
- package/components/SearchInput.svelte +473 -0
- package/components/Select.svelte +14 -2
- package/components/SidebarItem.svelte +2 -2
- package/components/Stepper.svelte +2 -2
- package/components/index.js +7 -0
- package/package.json +1 -1
- package/tokens/base.css +8 -0
- package/tokens/components.css +131 -9
- package/tokens/semantic.css +17 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component FilterBar
|
|
3
|
+
|
|
4
|
+
Horizontal bar of filter chips with active-filter state and clear-all action.
|
|
5
|
+
Accepts a declarative config of filters or renders children directly.
|
|
6
|
+
Consumes --filter-chip-* and --filter-bar-* tokens from components.css.
|
|
7
|
+
|
|
8
|
+
@example Declarative (common case)
|
|
9
|
+
<FilterBar
|
|
10
|
+
filters={[
|
|
11
|
+
{ key: 'status', label: 'Status', type: 'toggle', options: [
|
|
12
|
+
{ value: 'active', label: 'Active' },
|
|
13
|
+
{ value: 'draft', label: 'Draft' },
|
|
14
|
+
{ value: 'error', label: 'Error' },
|
|
15
|
+
]},
|
|
16
|
+
]}
|
|
17
|
+
bind:value={activeFilters}
|
|
18
|
+
onchange={handleFilterChange}
|
|
19
|
+
/>
|
|
20
|
+
|
|
21
|
+
@example Composable (custom rendering)
|
|
22
|
+
<FilterBar bind:value={activeFilters} onchange={handleFilterChange}>
|
|
23
|
+
{#snippet children(toggle, isActive)}
|
|
24
|
+
<FilterBar.Chip active={isActive('status', 'active')} onclick={() => toggle('status', 'active')}>
|
|
25
|
+
Active
|
|
26
|
+
</FilterBar.Chip>
|
|
27
|
+
{/snippet}
|
|
28
|
+
</FilterBar>
|
|
29
|
+
-->
|
|
30
|
+
<script module>
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {{ value: string, label: string, icon?: import('svelte').Component }} FilterOption
|
|
33
|
+
* @typedef {{ key: string, label: string, type: 'toggle' | 'select' | 'multi-select', options?: FilterOption[], defaultValue?: any }} FilterDef
|
|
34
|
+
*/
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
let {
|
|
39
|
+
/** @type {FilterDef[]} Declarative filter definitions */
|
|
40
|
+
filters = [],
|
|
41
|
+
/** @type {Record<string, any>} Active filter values (bindable) */
|
|
42
|
+
value = $bindable({}),
|
|
43
|
+
/** @type {((value: Record<string, any>) => void) | undefined} Fires when any filter changes */
|
|
44
|
+
onchange = undefined,
|
|
45
|
+
/** @type {(() => void) | undefined} Fires when "Clear all" is clicked */
|
|
46
|
+
onclear = undefined,
|
|
47
|
+
/** @type {string} */
|
|
48
|
+
class: className = '',
|
|
49
|
+
/** @type {import('svelte').Snippet | undefined} */
|
|
50
|
+
children = undefined,
|
|
51
|
+
...rest
|
|
52
|
+
} = $props();
|
|
53
|
+
|
|
54
|
+
const activeCount = $derived(
|
|
55
|
+
Object.values(value).filter(v => {
|
|
56
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
57
|
+
return v !== undefined && v !== null && v !== '';
|
|
58
|
+
}).length
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const showClearAll = $derived(activeCount >= 2);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Toggle a filter value
|
|
65
|
+
* @param {string} key
|
|
66
|
+
* @param {string} optionValue
|
|
67
|
+
*/
|
|
68
|
+
function toggle(key, optionValue) {
|
|
69
|
+
const filter = filters.find(f => f.key === key);
|
|
70
|
+
if (!filter) return;
|
|
71
|
+
|
|
72
|
+
if (filter.type === 'multi-select') {
|
|
73
|
+
const current = Array.isArray(value[key]) ? [...value[key]] : [];
|
|
74
|
+
const idx = current.indexOf(optionValue);
|
|
75
|
+
if (idx >= 0) {
|
|
76
|
+
current.splice(idx, 1);
|
|
77
|
+
} else {
|
|
78
|
+
current.push(optionValue);
|
|
79
|
+
}
|
|
80
|
+
value = { ...value, [key]: current.length > 0 ? current : undefined };
|
|
81
|
+
} else {
|
|
82
|
+
// toggle: same value = deactivate
|
|
83
|
+
value = {
|
|
84
|
+
...value,
|
|
85
|
+
[key]: value[key] === optionValue ? undefined : optionValue,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onchange?.(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if a filter option is active
|
|
94
|
+
* @param {string} key
|
|
95
|
+
* @param {string} optionValue
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
function isActive(key, optionValue) {
|
|
99
|
+
const v = value[key];
|
|
100
|
+
if (Array.isArray(v)) return v.includes(optionValue);
|
|
101
|
+
return v === optionValue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clearAll() {
|
|
105
|
+
value = {};
|
|
106
|
+
onchange?.({});
|
|
107
|
+
onclear?.();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<div
|
|
113
|
+
class="filter-bar {className}"
|
|
114
|
+
role="group"
|
|
115
|
+
aria-label="Filters"
|
|
116
|
+
{...rest}
|
|
117
|
+
>
|
|
118
|
+
{#if children}
|
|
119
|
+
{@render children(toggle, isActive)}
|
|
120
|
+
{:else}
|
|
121
|
+
<div class="filter-bar-chips">
|
|
122
|
+
{#each filters as filter}
|
|
123
|
+
{#if filter.options}
|
|
124
|
+
{#each filter.options as option}
|
|
125
|
+
{@const active = isActive(filter.key, option.value)}
|
|
126
|
+
<button
|
|
127
|
+
class="filter-chip"
|
|
128
|
+
class:filter-chip-active={active}
|
|
129
|
+
onclick={() => toggle(filter.key, option.value)}
|
|
130
|
+
aria-pressed={active}
|
|
131
|
+
type="button"
|
|
132
|
+
>
|
|
133
|
+
{option.label}
|
|
134
|
+
</button>
|
|
135
|
+
{/each}
|
|
136
|
+
{/if}
|
|
137
|
+
{/each}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{#if showClearAll}
|
|
141
|
+
<button
|
|
142
|
+
class="filter-bar-clear"
|
|
143
|
+
onclick={clearAll}
|
|
144
|
+
type="button"
|
|
145
|
+
>
|
|
146
|
+
Clear all
|
|
147
|
+
</button>
|
|
148
|
+
{/if}
|
|
149
|
+
{/if}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<style>
|
|
153
|
+
.filter-bar {
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: var(--filter-bar-gap);
|
|
157
|
+
flex-wrap: wrap;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.filter-bar-chips {
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: center;
|
|
163
|
+
gap: var(--filter-chip-gap);
|
|
164
|
+
flex-wrap: wrap;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ─── Chip ─── */
|
|
168
|
+
.filter-chip {
|
|
169
|
+
all: unset;
|
|
170
|
+
box-sizing: border-box;
|
|
171
|
+
display: inline-flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
gap: var(--space-xs);
|
|
174
|
+
height: var(--filter-chip-height);
|
|
175
|
+
padding: var(--filter-chip-padding);
|
|
176
|
+
font-family: var(--filter-chip-font);
|
|
177
|
+
font-size: var(--filter-chip-size);
|
|
178
|
+
letter-spacing: var(--filter-chip-tracking);
|
|
179
|
+
color: var(--filter-chip-color);
|
|
180
|
+
background: var(--filter-chip-bg);
|
|
181
|
+
border: var(--filter-chip-border);
|
|
182
|
+
border-radius: var(--filter-chip-radius);
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
white-space: nowrap;
|
|
185
|
+
transition: all var(--filter-chip-transition);
|
|
186
|
+
user-select: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.filter-chip:hover {
|
|
190
|
+
background: var(--filter-chip-bg-hover);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.filter-chip:focus-visible {
|
|
194
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
195
|
+
outline-offset: var(--focus-ring-offset);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.filter-chip-active {
|
|
199
|
+
background: var(--filter-chip-bg-active);
|
|
200
|
+
color: var(--filter-chip-color-active);
|
|
201
|
+
border: var(--filter-chip-border-active);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.filter-chip-active:hover {
|
|
205
|
+
background: var(--filter-chip-bg-active);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ─── Clear all ─── */
|
|
209
|
+
.filter-bar-clear {
|
|
210
|
+
all: unset;
|
|
211
|
+
box-sizing: border-box;
|
|
212
|
+
font-family: var(--filter-chip-font);
|
|
213
|
+
font-size: var(--filter-chip-size);
|
|
214
|
+
letter-spacing: var(--filter-chip-tracking);
|
|
215
|
+
color: var(--filter-bar-clear-color);
|
|
216
|
+
cursor: pointer;
|
|
217
|
+
white-space: nowrap;
|
|
218
|
+
transition: color var(--filter-chip-transition);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.filter-bar-clear:hover {
|
|
222
|
+
color: var(--filter-bar-clear-color-hover);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.filter-bar-clear:focus-visible {
|
|
226
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
227
|
+
outline-offset: var(--focus-ring-offset);
|
|
228
|
+
border-radius: var(--radius-sm);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ─── Mobile: horizontal scroll ─── */
|
|
232
|
+
@media (max-width: 640px) {
|
|
233
|
+
.filter-bar {
|
|
234
|
+
flex-wrap: nowrap;
|
|
235
|
+
overflow-x: auto;
|
|
236
|
+
-webkit-overflow-scrolling: touch;
|
|
237
|
+
scrollbar-width: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.filter-bar::-webkit-scrollbar {
|
|
241
|
+
display: none;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.filter-bar-chips {
|
|
245
|
+
flex-wrap: nowrap;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@media (prefers-reduced-motion: reduce) {
|
|
250
|
+
.filter-chip,
|
|
251
|
+
.filter-bar-clear {
|
|
252
|
+
transition: none;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
</style>
|
package/components/Input.svelte
CHANGED
|
@@ -205,8 +205,8 @@
|
|
|
205
205
|
top: 50%;
|
|
206
206
|
transform: translateY(-50%);
|
|
207
207
|
display: flex;
|
|
208
|
-
width:
|
|
209
|
-
height:
|
|
208
|
+
width: var(--icon-size-sm);
|
|
209
|
+
height: var(--icon-size-sm);
|
|
210
210
|
color: var(--input-placeholder);
|
|
211
211
|
pointer-events: none;
|
|
212
212
|
}
|
|
@@ -217,6 +217,6 @@
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
.input-with-icon {
|
|
220
|
-
padding-left: calc(var(--input-md-padding-x) +
|
|
220
|
+
padding-left: calc(var(--input-md-padding-x) + var(--icon-size-sm) + var(--space-xs));
|
|
221
221
|
}
|
|
222
222
|
</style>
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component LogViewer
|
|
3
|
+
|
|
4
|
+
Structured log display for viewing timestamped, level-coded entries.
|
|
5
|
+
Optimized for scanning many entries at compact density.
|
|
6
|
+
Composes Badge, Input, Checkbox, Toggle, Alert, EmptyState, and Skeleton.
|
|
7
|
+
Consumes --log-viewer-* tokens from components.css.
|
|
8
|
+
|
|
9
|
+
@example Basic
|
|
10
|
+
<LogViewer entries={logEntries} />
|
|
11
|
+
|
|
12
|
+
@example With all states
|
|
13
|
+
<LogViewer
|
|
14
|
+
entries={logEntries}
|
|
15
|
+
available={true}
|
|
16
|
+
truncated={false}
|
|
17
|
+
fallbackUrl="https://temporal.example.com/..."
|
|
18
|
+
loading={false}
|
|
19
|
+
/>
|
|
20
|
+
|
|
21
|
+
@example Unavailable (show fallback)
|
|
22
|
+
<LogViewer available={false} fallbackUrl="https://temporal.example.com/..." />
|
|
23
|
+
-->
|
|
24
|
+
<script>
|
|
25
|
+
import Badge from './Badge.svelte';
|
|
26
|
+
import Input from './Input.svelte';
|
|
27
|
+
import Checkbox from './Checkbox.svelte';
|
|
28
|
+
import Toggle from './Toggle.svelte';
|
|
29
|
+
import Alert from './Alert.svelte';
|
|
30
|
+
import EmptyState from './EmptyState.svelte';
|
|
31
|
+
import Skeleton from './Skeleton.svelte';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {{ timestamp: string, level: string, message: string, context?: Record<string, unknown> }} LogEntry
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
let {
|
|
38
|
+
/** @type {LogEntry[]} */
|
|
39
|
+
entries = [],
|
|
40
|
+
/** @type {boolean} */
|
|
41
|
+
available = true,
|
|
42
|
+
/** @type {boolean} */
|
|
43
|
+
truncated = false,
|
|
44
|
+
/** @type {string | undefined} */
|
|
45
|
+
fallbackUrl = undefined,
|
|
46
|
+
/** @type {boolean} */
|
|
47
|
+
loading = false,
|
|
48
|
+
/** @type {string} */
|
|
49
|
+
emptyHeading = 'No log entries',
|
|
50
|
+
/** @type {string} */
|
|
51
|
+
emptyBody = 'Logs will appear here once the run produces output.',
|
|
52
|
+
/** @type {string} */
|
|
53
|
+
unavailableHeading = 'Logs unavailable',
|
|
54
|
+
/** @type {string} */
|
|
55
|
+
unavailableBody = 'The orchestrator is unreachable. View logs in the external UI.',
|
|
56
|
+
/** @type {string} */
|
|
57
|
+
class: className = '',
|
|
58
|
+
/** @type {import('svelte').Snippet | undefined} */
|
|
59
|
+
emptyIcon = undefined,
|
|
60
|
+
/** @type {import('svelte').Snippet | undefined} */
|
|
61
|
+
unavailableIcon = undefined,
|
|
62
|
+
...rest
|
|
63
|
+
} = $props();
|
|
64
|
+
|
|
65
|
+
/* ─── Local state ─── */
|
|
66
|
+
let search = $state('');
|
|
67
|
+
let showInfo = $state(true);
|
|
68
|
+
let showWarning = $state(true);
|
|
69
|
+
let showError = $state(true);
|
|
70
|
+
let relativeTime = $state(true);
|
|
71
|
+
|
|
72
|
+
/* ─── Level normalization ─── */
|
|
73
|
+
/** @param {string} level */
|
|
74
|
+
function normalizeLevel(level) {
|
|
75
|
+
const upper = level.toUpperCase();
|
|
76
|
+
if (upper === 'WARN' || upper === 'WARNING') return 'WARNING';
|
|
77
|
+
if (upper === 'ERR' || upper === 'ERROR') return 'ERROR';
|
|
78
|
+
return 'INFO';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ─── Derived data ─── */
|
|
82
|
+
const normalizedEntries = $derived(
|
|
83
|
+
entries.map((e) => ({ ...e, _level: normalizeLevel(e.level) }))
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const counts = $derived({
|
|
87
|
+
INFO: normalizedEntries.filter((e) => e._level === 'INFO').length,
|
|
88
|
+
WARNING: normalizedEntries.filter((e) => e._level === 'WARNING').length,
|
|
89
|
+
ERROR: normalizedEntries.filter((e) => e._level === 'ERROR').length,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const searchLower = $derived(search.toLowerCase());
|
|
93
|
+
|
|
94
|
+
const filteredEntries = $derived(
|
|
95
|
+
normalizedEntries.filter((e) => {
|
|
96
|
+
// Level filter
|
|
97
|
+
if (e._level === 'INFO' && !showInfo) return false;
|
|
98
|
+
if (e._level === 'WARNING' && !showWarning) return false;
|
|
99
|
+
if (e._level === 'ERROR' && !showError) return false;
|
|
100
|
+
// Search filter
|
|
101
|
+
if (searchLower && !e.message.toLowerCase().includes(searchLower)) return false;
|
|
102
|
+
return true;
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const hasActiveFilters = $derived(
|
|
107
|
+
!showInfo || !showWarning || !showError || search.length > 0
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/* ─── Level → Badge variant mapping ─── */
|
|
111
|
+
/** @type {Record<string, 'neutral' | 'warning' | 'error'>} */
|
|
112
|
+
const levelVariant = {
|
|
113
|
+
INFO: 'neutral',
|
|
114
|
+
WARNING: 'warning',
|
|
115
|
+
ERROR: 'error',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/* ─── Timestamp formatting ─── */
|
|
119
|
+
/** @param {string} timestamp */
|
|
120
|
+
function formatTimestamp(timestamp) {
|
|
121
|
+
if (relativeTime) return formatRelative(timestamp);
|
|
122
|
+
return formatAbsolute(timestamp);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {string} timestamp */
|
|
126
|
+
function formatAbsolute(timestamp) {
|
|
127
|
+
try {
|
|
128
|
+
const d = new Date(timestamp);
|
|
129
|
+
const h = String(d.getHours()).padStart(2, '0');
|
|
130
|
+
const m = String(d.getMinutes()).padStart(2, '0');
|
|
131
|
+
const s = String(d.getSeconds()).padStart(2, '0');
|
|
132
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
133
|
+
return `${h}:${m}:${s}.${ms}`;
|
|
134
|
+
} catch {
|
|
135
|
+
return timestamp;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} timestamp */
|
|
140
|
+
function formatRelative(timestamp) {
|
|
141
|
+
try {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const then = new Date(timestamp).getTime();
|
|
144
|
+
const diff = now - then;
|
|
145
|
+
if (diff < 1000) return 'just now';
|
|
146
|
+
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
|
|
147
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
148
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
149
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
150
|
+
} catch {
|
|
151
|
+
return timestamp;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<div
|
|
159
|
+
class="log-viewer {className}"
|
|
160
|
+
class:log-viewer--loading={loading}
|
|
161
|
+
{...rest}
|
|
162
|
+
>
|
|
163
|
+
{#if loading}
|
|
164
|
+
<!-- Loading state -->
|
|
165
|
+
<div class="log-viewer-loading">
|
|
166
|
+
<Skeleton width="100%" height="32px" />
|
|
167
|
+
<Skeleton width="100%" height="14px" />
|
|
168
|
+
<Skeleton width="90%" height="14px" />
|
|
169
|
+
<Skeleton width="95%" height="14px" />
|
|
170
|
+
<Skeleton width="85%" height="14px" />
|
|
171
|
+
<Skeleton width="92%" height="14px" />
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{:else if !available}
|
|
175
|
+
<!-- Unavailable state -->
|
|
176
|
+
<EmptyState
|
|
177
|
+
heading={unavailableHeading}
|
|
178
|
+
body={unavailableBody}
|
|
179
|
+
actionLabel={fallbackUrl ? 'OPEN EXTERNAL LOGS' : undefined}
|
|
180
|
+
actionVariant="secondary"
|
|
181
|
+
onaction={fallbackUrl ? () => window.open(fallbackUrl, '_blank') : undefined}
|
|
182
|
+
icon={unavailableIcon}
|
|
183
|
+
/>
|
|
184
|
+
|
|
185
|
+
{:else if entries.length === 0}
|
|
186
|
+
<!-- Empty state -->
|
|
187
|
+
<EmptyState
|
|
188
|
+
heading={emptyHeading}
|
|
189
|
+
body={emptyBody}
|
|
190
|
+
icon={emptyIcon}
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
{:else}
|
|
194
|
+
<!-- Toolbar -->
|
|
195
|
+
<div class="log-viewer-toolbar">
|
|
196
|
+
<div class="log-viewer-toolbar-row">
|
|
197
|
+
<div class="log-viewer-search">
|
|
198
|
+
<Input
|
|
199
|
+
size="sm"
|
|
200
|
+
placeholder="Filter logs..."
|
|
201
|
+
bind:value={search}
|
|
202
|
+
>
|
|
203
|
+
{#snippet leadingIcon()}
|
|
204
|
+
<svg viewBox="0 0 256 256" fill="none">
|
|
205
|
+
<path d="M229.66 218.34l-50.07-50.07a88.11 88.11 0 1 0-11.31 11.31l50.07 50.07a8 8 0 0 0 11.32-11.31ZM40 112a72 72 0 1 1 72 72 72.08 72.08 0 0 1-72-72Z" fill="currentColor"/>
|
|
206
|
+
</svg>
|
|
207
|
+
{/snippet}
|
|
208
|
+
</Input>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<span class="log-viewer-toolbar-separator" aria-hidden="true"></span>
|
|
212
|
+
|
|
213
|
+
<div class="log-viewer-filters">
|
|
214
|
+
<Checkbox label="Info ({counts.INFO})" bind:checked={showInfo} />
|
|
215
|
+
<Checkbox label="Warn ({counts.WARNING})" bind:checked={showWarning} />
|
|
216
|
+
<Checkbox label="Error ({counts.ERROR})" bind:checked={showError} />
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<span class="log-viewer-toolbar-separator" aria-hidden="true"></span>
|
|
220
|
+
|
|
221
|
+
<div class="log-viewer-toggle">
|
|
222
|
+
<Toggle label="Relative" bind:checked={relativeTime} />
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- Truncation warning -->
|
|
228
|
+
{#if truncated}
|
|
229
|
+
<div class="log-viewer-alert">
|
|
230
|
+
<Alert variant="warning">
|
|
231
|
+
Log output was truncated. Some entries may be missing.
|
|
232
|
+
</Alert>
|
|
233
|
+
</div>
|
|
234
|
+
{/if}
|
|
235
|
+
|
|
236
|
+
<!-- Log entries -->
|
|
237
|
+
<div
|
|
238
|
+
class="log-viewer-entries"
|
|
239
|
+
role="log"
|
|
240
|
+
aria-live="polite"
|
|
241
|
+
aria-label="Log entries"
|
|
242
|
+
>
|
|
243
|
+
{#if filteredEntries.length === 0}
|
|
244
|
+
<div class="log-viewer-no-match">
|
|
245
|
+
No entries match the current filters.
|
|
246
|
+
</div>
|
|
247
|
+
{:else}
|
|
248
|
+
{#each filteredEntries as entry, i (entry.timestamp + i)}
|
|
249
|
+
<div class="log-viewer-entry log-viewer-entry--{entry._level.toLowerCase()}">
|
|
250
|
+
<span class="log-viewer-timestamp" title={entry.timestamp}>
|
|
251
|
+
{formatTimestamp(entry.timestamp)}
|
|
252
|
+
</span>
|
|
253
|
+
<Badge variant={levelVariant[entry._level]}>{entry._level}</Badge>
|
|
254
|
+
<span class="log-viewer-message">{entry.message}</span>
|
|
255
|
+
</div>
|
|
256
|
+
{/each}
|
|
257
|
+
{/if}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<!-- Status bar -->
|
|
261
|
+
<div class="log-viewer-status">
|
|
262
|
+
<span class="log-viewer-status-text">
|
|
263
|
+
{filteredEntries.length}{hasActiveFilters ? ` of ${entries.length}` : ''} entries
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
{/if}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<style>
|
|
270
|
+
.log-viewer {
|
|
271
|
+
display: flex;
|
|
272
|
+
flex-direction: column;
|
|
273
|
+
background: var(--log-viewer-bg);
|
|
274
|
+
border: var(--log-viewer-border);
|
|
275
|
+
border-radius: var(--log-viewer-radius);
|
|
276
|
+
overflow: hidden;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ─── Loading ─── */
|
|
280
|
+
.log-viewer-loading {
|
|
281
|
+
display: flex;
|
|
282
|
+
flex-direction: column;
|
|
283
|
+
gap: var(--space-sm);
|
|
284
|
+
padding: var(--space-md);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* ─── Toolbar ─── */
|
|
288
|
+
.log-viewer-toolbar {
|
|
289
|
+
padding: var(--log-viewer-toolbar-padding);
|
|
290
|
+
border-bottom: var(--log-viewer-toolbar-border);
|
|
291
|
+
background: var(--color-surface);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.log-viewer-toolbar-row {
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
gap: var(--log-viewer-toolbar-gap);
|
|
298
|
+
flex-wrap: wrap;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.log-viewer-search {
|
|
302
|
+
flex: 1;
|
|
303
|
+
min-width: 160px;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.log-viewer-filters {
|
|
307
|
+
display: flex;
|
|
308
|
+
align-items: center;
|
|
309
|
+
gap: var(--space-md);
|
|
310
|
+
flex-shrink: 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.log-viewer-toggle {
|
|
314
|
+
flex-shrink: 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.log-viewer-toolbar-separator {
|
|
318
|
+
width: var(--border-width-default);
|
|
319
|
+
height: 20px;
|
|
320
|
+
background: var(--color-border);
|
|
321
|
+
flex-shrink: 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* ─── Truncation alert ─── */
|
|
325
|
+
.log-viewer-alert {
|
|
326
|
+
padding: 0 var(--space-md) var(--space-sm);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ─── Entries ─── */
|
|
330
|
+
.log-viewer-entries {
|
|
331
|
+
overflow-y: auto;
|
|
332
|
+
max-height: var(--log-viewer-max-height);
|
|
333
|
+
scroll-behavior: smooth;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.log-viewer-entry {
|
|
337
|
+
display: flex;
|
|
338
|
+
align-items: baseline;
|
|
339
|
+
gap: var(--space-md);
|
|
340
|
+
padding: var(--log-viewer-entry-padding-y) var(--log-viewer-entry-padding-x);
|
|
341
|
+
border-bottom: var(--log-viewer-entry-border);
|
|
342
|
+
font-family: var(--log-viewer-entry-font);
|
|
343
|
+
font-size: var(--log-viewer-entry-size);
|
|
344
|
+
line-height: var(--log-viewer-entry-leading);
|
|
345
|
+
transition: background var(--duration-instant) var(--easing-default);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.log-viewer-entry:last-child {
|
|
349
|
+
border-bottom: none;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.log-viewer-entry:hover {
|
|
353
|
+
background: var(--log-viewer-entry-hover-bg);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* Level accent — left border stripe */
|
|
357
|
+
.log-viewer-entry--error {
|
|
358
|
+
border-left: var(--accent-stripe-width) solid var(--log-viewer-level-error-color);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.log-viewer-entry--warning {
|
|
362
|
+
border-left: var(--accent-stripe-width) solid var(--log-viewer-level-warning-color);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.log-viewer-entry--info {
|
|
366
|
+
border-left: var(--accent-stripe-width) solid transparent;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.log-viewer-timestamp {
|
|
370
|
+
flex-shrink: 0;
|
|
371
|
+
width: var(--log-viewer-timestamp-width);
|
|
372
|
+
color: var(--log-viewer-timestamp-color);
|
|
373
|
+
font-variant-numeric: tabular-nums;
|
|
374
|
+
white-space: nowrap;
|
|
375
|
+
overflow: hidden;
|
|
376
|
+
text-overflow: ellipsis;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.log-viewer-message {
|
|
380
|
+
flex: 1;
|
|
381
|
+
min-width: 0;
|
|
382
|
+
color: var(--log-viewer-entry-color);
|
|
383
|
+
white-space: pre-wrap;
|
|
384
|
+
word-break: break-word;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.log-viewer-no-match {
|
|
388
|
+
padding: var(--space-lg);
|
|
389
|
+
text-align: center;
|
|
390
|
+
font-family: var(--log-viewer-status-font);
|
|
391
|
+
font-size: var(--log-viewer-status-size);
|
|
392
|
+
color: var(--log-viewer-status-color);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* ─── Status bar ─── */
|
|
396
|
+
.log-viewer-status {
|
|
397
|
+
display: flex;
|
|
398
|
+
align-items: center;
|
|
399
|
+
justify-content: space-between;
|
|
400
|
+
padding: var(--log-viewer-status-padding);
|
|
401
|
+
border-top: var(--log-viewer-toolbar-border);
|
|
402
|
+
background: var(--color-surface);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.log-viewer-status-text {
|
|
406
|
+
font-family: var(--log-viewer-status-font);
|
|
407
|
+
font-size: var(--log-viewer-status-size);
|
|
408
|
+
color: var(--log-viewer-status-color);
|
|
409
|
+
}
|
|
410
|
+
</style>
|