@chrysb/alphaclaw 0.6.2-beta.2 → 0.6.2-beta.4

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.
@@ -3,20 +3,15 @@ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import {
5
5
  buildCronOptimizationWarnings,
6
- formatCost,
7
6
  formatRelativeMs,
8
7
  formatTokenCount,
9
8
  getNextScheduledRunAcrossJobs,
10
9
  } from "./cron-helpers.js";
11
10
  import { CronCalendar } from "./cron-calendar.js";
12
11
  import { CronRunsTrendCard } from "./cron-runs-trend-card.js";
13
- import { SegmentedControl } from "../segmented-control.js";
12
+ import { CronRunHistoryPanel } from "./cron-run-history-panel.js";
14
13
  import { SummaryStatCard } from "../summary-stat-card.js";
15
14
  import { ErrorWarningLineIcon } from "../icons.js";
16
- import {
17
- formatDurationCompactMs,
18
- formatLocaleDateTimeWithTodayTime,
19
- } from "../../lib/format.js";
20
15
 
21
16
  const html = htm.bind(h);
22
17
  const kRecentRunFetchLimit = 100;
@@ -60,48 +55,6 @@ const formatWarningsAttentionText = (warnings = []) => {
60
55
  return `${parts.join(" and ")} may need your attention`;
61
56
  };
62
57
 
63
- const runStatusClassName = (status = "") => {
64
- const normalized = String(status || "")
65
- .trim()
66
- .toLowerCase();
67
- if (normalized === "ok") return "text-green-300";
68
- if (normalized === "error") return "text-red-300";
69
- if (normalized === "skipped") return "text-yellow-300";
70
- return "text-gray-400";
71
- };
72
-
73
- const formatRecentRunTimestamp = (timestampMs) =>
74
- formatLocaleDateTimeWithTodayTime(timestampMs, {
75
- fallback: "—",
76
- valueIsEpochMs: true,
77
- }).replace(
78
- /\s([AP])M\b/g,
79
- (_, marker) => `${String(marker || "").toLowerCase()}m`,
80
- );
81
-
82
- const getRunEstimatedCost = (runEntry = {}) => {
83
- const usage = runEntry?.usage || {};
84
- const candidates = [
85
- usage?.estimatedCost,
86
- usage?.estimated_cost,
87
- usage?.totalCost,
88
- usage?.total_cost,
89
- usage?.costUsd,
90
- usage?.cost,
91
- runEntry?.estimatedCost,
92
- runEntry?.estimated_cost,
93
- runEntry?.totalCost,
94
- runEntry?.total_cost,
95
- runEntry?.costUsd,
96
- runEntry?.cost,
97
- ];
98
- for (const candidate of candidates) {
99
- const numericValue = Number(candidate);
100
- if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
101
- }
102
- return null;
103
- };
104
-
105
58
  const flattenRecentRuns = ({ bulkRunsByJobId = {}, jobs = [] } = {}) => {
106
59
  const jobNameById = jobs.reduce((accumulator, job) => {
107
60
  const jobId = String(job?.id || "");
@@ -233,7 +186,7 @@ export const CronOverview = ({
233
186
  const enabledCount = jobs.filter((job) => job.enabled !== false).length;
234
187
  const disabledCount = jobs.length - enabledCount;
235
188
  const nextRunMs = getNextScheduledRunAcrossJobs(jobs);
236
- const warnings = buildCronOptimizationWarnings(jobs);
189
+ const warnings = buildCronOptimizationWarnings(jobs, bulkRunsByJobId);
237
190
  const recentRuns = useMemo(
238
191
  () => flattenRecentRuns({ bulkRunsByJobId, jobs }),
239
192
  [bulkRunsByJobId, jobs],
@@ -367,232 +320,18 @@ export const CronOverview = ({
367
320
  onBucketFilterChange=${setSelectedTrendBucketFilter}
368
321
  />
369
322
 
370
- <section
371
- class="bg-surface border border-border rounded-xl p-4 space-y-3"
372
- >
373
- <div class="flex items-start justify-between gap-3">
374
- <div class="inline-flex items-center gap-3">
375
- <h3 class="card-label card-label-bright">Run history</h3>
376
- <div class="text-xs text-gray-500">
377
- ${formatTokenCount(filteredRecentRuns.length)} entries
378
- </div>
379
- </div>
380
- <div class="shrink-0">
381
- <${SegmentedControl}
382
- options=${kRunStatusFilterOptions}
383
- value=${recentRunStatusFilter}
384
- onChange=${setRecentRunStatusFilter}
385
- />
386
- </div>
387
- </div>
388
- ${selectedTrendBucketFilter
389
- ? html`
390
- <div class="flex items-center">
391
- <span class="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-2 py-1 rounded-full border border-border text-gray-300 bg-black/20">
392
- Filtered to ${selectedTrendBucketFilter.label}
393
- <button
394
- type="button"
395
- class="text-gray-500 hover:text-gray-200 leading-none"
396
- onclick=${() => setSelectedTrendBucketFilter(null)}
397
- aria-label="Clear trend filter"
398
- >
399
- ×
400
- </button>
401
- </span>
402
- </div>
403
- `
404
- : null}
405
- ${recentRunRows.length === 0
406
- ? html`<div class="text-sm text-gray-500">No runs found.</div>`
407
- : html`
408
- <div class="ac-history-list">
409
- ${recentRunRows.map((row, rowIndex) => {
410
- if (row.type === "collapsed-group") {
411
- const statusSummary = Object.entries(
412
- row.statusCounts || {},
413
- )
414
- .map(([status, count]) => `${status}: ${count}`)
415
- .join(" • ");
416
- const timeRangeLabel = `[${formatRecentRunTimestamp(row.oldestTs)} - ${formatRecentRunTimestamp(row.newestTs)}]`;
417
- return html`
418
- <details
419
- key=${`collapsed:${rowIndex}:${row.jobId}`}
420
- class="ac-history-item"
421
- >
422
- <summary class="ac-history-summary">
423
- <div class="ac-history-summary-row">
424
- <span
425
- class="inline-flex items-center gap-2 min-w-0"
426
- >
427
- <span
428
- class="ac-history-toggle shrink-0"
429
- aria-hidden="true"
430
- >▸</span
431
- >
432
- <span class="truncate text-xs text-gray-300">
433
- ${row.jobName} -
434
- ${formatTokenCount(row.count)} runs -
435
- ${timeRangeLabel}
436
- </span>
437
- </span>
438
- </div>
439
- </summary>
440
- <div class="ac-history-body space-y-2 text-xs">
441
- <div class="text-gray-500">
442
- ${formatTokenCount(row.count)} consecutive runs
443
- collapsed (${timeRangeLabel})
444
- </div>
445
- <div class="text-gray-500">
446
- Statuses: ${statusSummary}
447
- </div>
448
- <div>
449
- <button
450
- type="button"
451
- class="text-xs px-2 py-1 rounded border border-border text-gray-400 hover:text-gray-200"
452
- onclick=${() => onSelectJob(row.jobId)}
453
- >
454
- Open ${row.jobName}
455
- </button>
456
- </div>
457
- </div>
458
- </details>
459
- `;
460
- }
461
- const runEntry = row.entry || {};
462
- const runStatus = String(runEntry?.status || "unknown");
463
- const runUsage = runEntry?.usage || {};
464
- const runInputTokens = Number(
465
- runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0,
466
- );
467
- const runOutputTokens = Number(
468
- runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0,
469
- );
470
- const runTokens = Number(
471
- runUsage?.total_tokens ?? runUsage?.totalTokens ?? 0,
472
- );
473
- const runEstimatedCost = getRunEstimatedCost(runEntry);
474
- const runTitle = String(runEntry?.jobName || "").trim();
475
- const hasRunTitle = runTitle.length > 0;
476
- return html`
477
- <details
478
- key=${`entry:${rowIndex}:${runEntry.ts}:${runEntry.jobId || ""}`}
479
- class="ac-history-item"
480
- >
481
- <summary class="ac-history-summary">
482
- <div class="ac-history-summary-row">
483
- <span
484
- class="inline-flex items-center gap-2 min-w-0"
485
- >
486
- <span
487
- class="ac-history-toggle shrink-0"
488
- aria-hidden="true"
489
- >▸</span
490
- >
491
- ${hasRunTitle
492
- ? html`
493
- <span
494
- class="inline-flex items-center gap-2 min-w-0"
495
- >
496
- <span class="truncate text-xs text-gray-300">
497
- ${runTitle}
498
- </span>
499
- <span class="text-xs text-gray-500 shrink-0">
500
- ${formatRecentRunTimestamp(runEntry.ts)}
501
- </span>
502
- </span>
503
- `
504
- : html`
505
- <span class="truncate text-xs text-gray-300">
506
- ${runEntry.jobId} -
507
- ${formatRecentRunTimestamp(runEntry.ts)}
508
- </span>
509
- `}
510
- </span>
511
- <span
512
- class="inline-flex items-center gap-3 shrink-0 text-xs"
513
- >
514
- <span class=${runStatusClassName(runStatus)}
515
- >${runStatus}</span
516
- >
517
- <span class="text-gray-400"
518
- >${formatDurationCompactMs(
519
- runEntry.durationMs,
520
- )}</span
521
- >
522
- <span class="text-gray-400"
523
- >${formatTokenCount(runTokens)} tk</span
524
- >
525
- <span class="text-gray-500"
526
- >${runEstimatedCost == null
527
- ? "—"
528
- : `~${formatCost(runEstimatedCost)}`}</span
529
- >
530
- </span>
531
- </div>
532
- </summary>
533
- <div class="ac-history-body space-y-2 text-xs">
534
- ${runEntry.summary
535
- ? html`<div>
536
- <span class="text-gray-500">Summary:</span>
537
- ${runEntry.summary}
538
- </div>`
539
- : null}
540
- ${runEntry.error
541
- ? html`<div class="text-red-300">
542
- <span class="text-gray-500">Error:</span>
543
- ${runEntry.error}
544
- </div>`
545
- : null}
546
- <div class="ac-surface-inset rounded-lg p-2.5 space-y-1.5">
547
- <div class="text-gray-500">
548
- Model:
549
- <span class="text-gray-300 font-mono"
550
- >${runEntry.model || "—"}</span
551
- >
552
- ${runEntry.sessionKey
553
- ? html` | Session:
554
- <span class="text-gray-300 font-mono"
555
- >${runEntry.sessionKey}</span
556
- >`
557
- : null}
558
- </div>
559
- <div class="text-gray-500">
560
- Usage:
561
- <span class="text-gray-300"
562
- >${formatTokenCount(runInputTokens)} in</span
563
- >
564
- <span class="text-gray-600">•</span>
565
- <span class="text-gray-300"
566
- >${formatTokenCount(runOutputTokens)} out</span
567
- >
568
- <span class="text-gray-600">•</span>
569
- <span class="text-gray-300"
570
- >${formatTokenCount(runTokens)} tk</span
571
- >
572
- <span class="text-gray-600">•</span>
573
- <span class="text-gray-300"
574
- >${runEstimatedCost == null
575
- ? "—"
576
- : `~${formatCost(runEstimatedCost)}`}</span
577
- >
578
- </div>
579
- </div>
580
- <div>
581
- <button
582
- type="button"
583
- class="text-xs px-2 py-1 rounded border border-border text-gray-400 hover:text-gray-200"
584
- onclick=${() => onSelectJob(runEntry.jobId)}
585
- >
586
- Open ${runEntry.jobName || runEntry.jobId}
587
- </button>
588
- </div>
589
- </div>
590
- </details>
591
- `;
592
- })}
593
- </div>
594
- `}
595
- </section>
323
+ <${CronRunHistoryPanel}
324
+ entryCountLabel=${`${formatTokenCount(filteredRecentRuns.length)} entries`}
325
+ primaryFilterOptions=${kRunStatusFilterOptions}
326
+ primaryFilterValue=${recentRunStatusFilter}
327
+ onChangePrimaryFilter=${setRecentRunStatusFilter}
328
+ activeFilterLabel=${selectedTrendBucketFilter?.label || ""}
329
+ onClearActiveFilter=${() => setSelectedTrendBucketFilter(null)}
330
+ rows=${recentRunRows}
331
+ variant="overview"
332
+ onSelectJob=${onSelectJob}
333
+ showOpenJobButton=${true}
334
+ />
596
335
  </div>
597
336
  </div>
598
337
  `;
@@ -0,0 +1,296 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { SegmentedControl } from "../segmented-control.js";
4
+ import {
5
+ formatDurationCompactMs,
6
+ formatLocaleDateTimeWithTodayTime,
7
+ } from "../../lib/format.js";
8
+ import { formatCost, formatTokenCount } from "./cron-helpers.js";
9
+
10
+ const html = htm.bind(h);
11
+ const runStatusClassName = (status = "") => {
12
+ const normalized = String(status || "")
13
+ .trim()
14
+ .toLowerCase();
15
+ if (normalized === "ok") return "text-green-300";
16
+ if (normalized === "error") return "text-red-300";
17
+ if (normalized === "skipped") return "text-yellow-300";
18
+ return "text-gray-400";
19
+ };
20
+ const runDeliveryLabel = (run) => String(run?.deliveryStatus || "not-requested");
21
+ const getRunEstimatedCost = (runEntry = {}) => {
22
+ const parsed = Number(runEntry?.estimatedCost);
23
+ return Number.isFinite(parsed) ? parsed : null;
24
+ };
25
+ const formatOverviewTimestamp = (timestampMs) =>
26
+ formatLocaleDateTimeWithTodayTime(timestampMs, {
27
+ fallback: "—",
28
+ valueIsEpochMs: true,
29
+ }).replace(/\s([AP])M\b/g, (_, marker) => `${String(marker || "").toLowerCase()}m`);
30
+ const formatDetailTimestamp = (timestampMs) =>
31
+ formatLocaleDateTimeWithTodayTime(timestampMs, {
32
+ fallback: "—",
33
+ valueIsEpochMs: true,
34
+ });
35
+ const formatRowTimestamp = (timestampMs, variant = "overview") =>
36
+ variant === "detail"
37
+ ? formatDetailTimestamp(timestampMs)
38
+ : formatOverviewTimestamp(timestampMs);
39
+ const renderCollapsedGroupRow = ({
40
+ row,
41
+ rowIndex,
42
+ onSelectJob = () => {},
43
+ }) => {
44
+ const statusSummary = Object.entries(row.statusCounts || {})
45
+ .map(([status, count]) => `${status}: ${count}`)
46
+ .join(" • ");
47
+ const timeRangeLabel = `[${formatOverviewTimestamp(row.oldestTs)} - ${formatOverviewTimestamp(row.newestTs)}]`;
48
+ return html`
49
+ <details
50
+ key=${`collapsed:${rowIndex}:${row.jobId}`}
51
+ class="ac-history-item"
52
+ >
53
+ <summary class="ac-history-summary">
54
+ <div class="ac-history-summary-row">
55
+ <span
56
+ class="inline-flex items-center gap-2 min-w-0"
57
+ >
58
+ <span
59
+ class="ac-history-toggle shrink-0"
60
+ aria-hidden="true"
61
+ >▸</span
62
+ >
63
+ <span class="truncate text-xs text-gray-300">
64
+ ${row.jobName} -
65
+ ${formatTokenCount(row.count)} runs -
66
+ ${timeRangeLabel}
67
+ </span>
68
+ </span>
69
+ </div>
70
+ </summary>
71
+ <div class="ac-history-body space-y-2 text-xs">
72
+ <div class="text-gray-500">
73
+ ${formatTokenCount(row.count)} consecutive runs
74
+ collapsed (${timeRangeLabel})
75
+ </div>
76
+ <div class="text-gray-500">
77
+ Statuses: ${statusSummary}
78
+ </div>
79
+ ${row?.jobId
80
+ ? html`
81
+ <div>
82
+ <button
83
+ type="button"
84
+ class="text-xs px-2 py-1 rounded border border-border text-gray-400 hover:text-gray-200"
85
+ onclick=${() => onSelectJob(row.jobId)}
86
+ >
87
+ Open ${row.jobName || row.jobId}
88
+ </button>
89
+ </div>
90
+ `
91
+ : null}
92
+ </div>
93
+ </details>
94
+ `;
95
+ };
96
+ const renderEntryRow = ({
97
+ row,
98
+ rowIndex,
99
+ variant = "overview",
100
+ onSelectJob = () => {},
101
+ showOpenJobButton = false,
102
+ }) => {
103
+ const runEntry = row?.entry || row || {};
104
+ const runUsage = runEntry?.usage || {};
105
+ const runStatus = String(runEntry?.status || "unknown");
106
+ const runInputTokens = Number(runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0);
107
+ const runOutputTokens = Number(runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0);
108
+ const runTokens = Number(runUsage?.total_tokens ?? runUsage?.totalTokens ?? 0);
109
+ const runEstimatedCost = getRunEstimatedCost(runEntry);
110
+ const runTitle = String(runEntry?.jobName || "").trim();
111
+ const hasRunTitle = runTitle.length > 0;
112
+ const isDetail = variant === "detail";
113
+ return html`
114
+ <details
115
+ key=${`entry:${rowIndex}:${runEntry.ts}:${runEntry.jobId || ""}`}
116
+ class="ac-history-item"
117
+ >
118
+ <summary class="ac-history-summary">
119
+ <div class="ac-history-summary-row">
120
+ <span
121
+ class="inline-flex items-center gap-2 min-w-0"
122
+ >
123
+ <span
124
+ class="ac-history-toggle shrink-0"
125
+ aria-hidden="true"
126
+ >▸</span
127
+ >
128
+ ${isDetail
129
+ ? html`
130
+ <span class="truncate text-xs text-gray-300">
131
+ ${formatRowTimestamp(runEntry.ts, variant)}
132
+ </span>
133
+ `
134
+ : hasRunTitle
135
+ ? html`
136
+ <span class="inline-flex items-center gap-2 min-w-0">
137
+ <span class="truncate text-xs text-gray-300">${runTitle}</span>
138
+ <span class="text-xs text-gray-500 shrink-0">
139
+ ${formatRowTimestamp(runEntry.ts, variant)}
140
+ </span>
141
+ </span>
142
+ `
143
+ : html`
144
+ <span class="truncate text-xs text-gray-300">
145
+ ${runEntry.jobId} -
146
+ ${formatRowTimestamp(runEntry.ts, variant)}
147
+ </span>
148
+ `}
149
+ </span>
150
+ <span
151
+ class="inline-flex items-center gap-3 shrink-0 text-xs"
152
+ >
153
+ <span class=${runStatusClassName(runStatus)}>${runStatus}</span>
154
+ <span class="text-gray-400">${formatDurationCompactMs(runEntry.durationMs)}</span>
155
+ <span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
156
+ ${isDetail
157
+ ? html`<span class="text-gray-500">${runDeliveryLabel(runEntry)}</span>`
158
+ : html`
159
+ <span class="text-gray-500"
160
+ >${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}</span
161
+ >
162
+ `}
163
+ </span>
164
+ </div>
165
+ </summary>
166
+ <div class="ac-history-body space-y-2 text-xs">
167
+ ${runEntry.summary
168
+ ? html`<div><span class="text-gray-500">Summary:</span> ${runEntry.summary}</div>`
169
+ : null}
170
+ ${runEntry.error
171
+ ? html`<div class="text-red-300"><span class="text-gray-500">Error:</span> ${runEntry.error}</div>`
172
+ : null}
173
+ <div class="ac-surface-inset rounded-lg p-2.5 space-y-1.5">
174
+ <div class="text-gray-500">
175
+ Model:
176
+ <span class="text-gray-300 font-mono">${runEntry.model || "—"}</span>
177
+ </div>
178
+ <div class="text-gray-500">
179
+ Session:
180
+ <span class="text-gray-300 font-mono">${runEntry.sessionKey || "—"}</span>
181
+ </div>
182
+ <div class="text-gray-500">
183
+ Tokens in:
184
+ <span class="text-gray-300">${formatTokenCount(runInputTokens)}</span>
185
+ </div>
186
+ <div class="text-gray-500">
187
+ Tokens out:
188
+ <span class="text-gray-300">${formatTokenCount(runOutputTokens)}</span>
189
+ </div>
190
+ <div class="text-gray-500">
191
+ Total tokens:
192
+ <span class="text-gray-300">${formatTokenCount(runTokens)}</span>
193
+ </div>
194
+ <div class="text-gray-500">
195
+ Total cost:
196
+ <span class="text-gray-300">
197
+ ${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}
198
+ </span>
199
+ </div>
200
+ </div>
201
+ ${showOpenJobButton && runEntry?.jobId
202
+ ? html`
203
+ <div>
204
+ <button
205
+ type="button"
206
+ class="text-xs px-2 py-1 rounded border border-border text-gray-400 hover:text-gray-200"
207
+ onclick=${() => onSelectJob(runEntry.jobId)}
208
+ >
209
+ Open ${runEntry.jobName || runEntry.jobId}
210
+ </button>
211
+ </div>
212
+ `
213
+ : null}
214
+ </div>
215
+ </details>
216
+ `;
217
+ };
218
+
219
+ export const CronRunHistoryPanel = ({
220
+ entryCountLabel = "",
221
+ primaryFilterOptions = [],
222
+ primaryFilterValue = "all",
223
+ onChangePrimaryFilter = () => {},
224
+ secondaryFilterOptions = [],
225
+ secondaryFilterValue = "all",
226
+ onChangeSecondaryFilter = () => {},
227
+ activeFilterLabel = "",
228
+ onClearActiveFilter = () => {},
229
+ rows = [],
230
+ emptyText = "No runs found.",
231
+ variant = "overview",
232
+ onSelectJob = () => {},
233
+ showOpenJobButton = false,
234
+ footer = null,
235
+ }) => html`
236
+ <section class="bg-surface border border-border rounded-xl p-4 space-y-3">
237
+ <div class="flex items-start justify-between gap-3">
238
+ <div class="inline-flex items-center gap-3">
239
+ <h3 class="card-label card-label-bright">Run history</h3>
240
+ <div class="text-xs text-gray-500">${entryCountLabel}</div>
241
+ </div>
242
+ <div class="shrink-0 inline-flex items-center gap-2">
243
+ <${SegmentedControl}
244
+ options=${primaryFilterOptions}
245
+ value=${primaryFilterValue}
246
+ onChange=${onChangePrimaryFilter}
247
+ />
248
+ ${Array.isArray(secondaryFilterOptions) && secondaryFilterOptions.length > 0
249
+ ? html`
250
+ <${SegmentedControl}
251
+ options=${secondaryFilterOptions}
252
+ value=${secondaryFilterValue}
253
+ onChange=${onChangeSecondaryFilter}
254
+ />
255
+ `
256
+ : null}
257
+ </div>
258
+ </div>
259
+ ${activeFilterLabel
260
+ ? html`
261
+ <div class="flex items-center">
262
+ <span
263
+ class="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-2 py-1 rounded-full border border-border text-gray-300 bg-black/20"
264
+ >
265
+ Filtered to ${activeFilterLabel}
266
+ <button
267
+ type="button"
268
+ class="text-gray-500 hover:text-gray-200 leading-none"
269
+ onclick=${onClearActiveFilter}
270
+ aria-label="Clear trend filter"
271
+ >
272
+ ×
273
+ </button>
274
+ </span>
275
+ </div>
276
+ `
277
+ : null}
278
+ ${rows.length === 0
279
+ ? html`<div class="text-sm text-gray-500">${emptyText}</div>`
280
+ : html`
281
+ <div class="ac-history-list">
282
+ ${rows.map((row, rowIndex) =>
283
+ row?.type === "collapsed-group"
284
+ ? renderCollapsedGroupRow({ row, rowIndex, onSelectJob })
285
+ : renderEntryRow({
286
+ row,
287
+ rowIndex,
288
+ variant,
289
+ onSelectJob,
290
+ showOpenJobButton,
291
+ }))}
292
+ </div>
293
+ `}
294
+ ${footer}
295
+ </section>
296
+ `;