@nexus-ai-fs/tui 0.9.18
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/README.md +30 -0
- package/package.json +48 -0
- package/src/app.tsx +349 -0
- package/src/index.tsx +137 -0
- package/src/opentui-env.d.ts +61 -0
- package/src/panels/access/access-panel.tsx +597 -0
- package/src/panels/access/alert-list.tsx +77 -0
- package/src/panels/access/constraint-creator.tsx +128 -0
- package/src/panels/access/constraint-list.tsx +72 -0
- package/src/panels/access/credential-list.tsx +68 -0
- package/src/panels/access/delegation-chain-view.tsx +110 -0
- package/src/panels/access/delegation-completer.tsx +120 -0
- package/src/panels/access/delegation-creator.tsx +237 -0
- package/src/panels/access/delegation-list.tsx +74 -0
- package/src/panels/access/fraud-score-view.tsx +94 -0
- package/src/panels/access/manifest-creator.tsx +167 -0
- package/src/panels/access/manifest-list.tsx +105 -0
- package/src/panels/access/namespace-config-view.tsx +525 -0
- package/src/panels/access/permission-checker.tsx +231 -0
- package/src/panels/agents/agent-status-view.tsx +196 -0
- package/src/panels/agents/agents-panel.tsx +493 -0
- package/src/panels/agents/delegation-list.tsx +154 -0
- package/src/panels/agents/inbox-view.tsx +96 -0
- package/src/panels/agents/trajectories-tab.tsx +40 -0
- package/src/panels/api-console/api-console-panel.tsx +189 -0
- package/src/panels/api-console/codegen-viewer.tsx +36 -0
- package/src/panels/api-console/codegen.ts +112 -0
- package/src/panels/api-console/endpoint-list.tsx +57 -0
- package/src/panels/api-console/request-builder.tsx +69 -0
- package/src/panels/api-console/response-viewer.tsx +54 -0
- package/src/panels/connectors/available-tab.tsx +357 -0
- package/src/panels/connectors/connector-row.tsx +121 -0
- package/src/panels/connectors/connectors-panel.tsx +88 -0
- package/src/panels/connectors/error-parser.ts +116 -0
- package/src/panels/connectors/mounted-tab.tsx +179 -0
- package/src/panels/connectors/skills-tab.tsx +235 -0
- package/src/panels/connectors/template-generator.ts +211 -0
- package/src/panels/connectors/write-tab.tsx +514 -0
- package/src/panels/events/audit-tab.tsx +69 -0
- package/src/panels/events/audit-trail.tsx +75 -0
- package/src/panels/events/connector-detail.tsx +49 -0
- package/src/panels/events/connector-list.tsx +73 -0
- package/src/panels/events/connectors-tab.tsx +92 -0
- package/src/panels/events/event-replay.tsx +80 -0
- package/src/panels/events/events-panel.tsx +414 -0
- package/src/panels/events/events-tab.tsx +212 -0
- package/src/panels/events/lock-list.tsx +54 -0
- package/src/panels/events/locks-tab.tsx +103 -0
- package/src/panels/events/mcl-replay.tsx +77 -0
- package/src/panels/events/mcl-tab.tsx +83 -0
- package/src/panels/events/operations-tab-wrapper.tsx +62 -0
- package/src/panels/events/operations-tab.tsx +41 -0
- package/src/panels/events/replay-tab.tsx +76 -0
- package/src/panels/events/secrets-audit.tsx +64 -0
- package/src/panels/events/secrets-tab.tsx +75 -0
- package/src/panels/events/subscription-list.tsx +54 -0
- package/src/panels/events/subscriptions-tab.tsx +82 -0
- package/src/panels/files/file-aspects.tsx +93 -0
- package/src/panels/files/file-editor.tsx +160 -0
- package/src/panels/files/file-explorer-keybindings.ts +468 -0
- package/src/panels/files/file-explorer-panel.tsx +545 -0
- package/src/panels/files/file-lineage.tsx +163 -0
- package/src/panels/files/file-list-item.tsx +28 -0
- package/src/panels/files/file-metadata.tsx +62 -0
- package/src/panels/files/file-preview.tsx +108 -0
- package/src/panels/files/file-schema.tsx +89 -0
- package/src/panels/files/file-tree-node.tsx +44 -0
- package/src/panels/files/file-tree.tsx +169 -0
- package/src/panels/files/share-links-tab.tsx +33 -0
- package/src/panels/files/uploads-tab.tsx +45 -0
- package/src/panels/payments/approval-list.tsx +83 -0
- package/src/panels/payments/balance-card.tsx +43 -0
- package/src/panels/payments/budget-card.tsx +70 -0
- package/src/panels/payments/payments-panel.tsx +451 -0
- package/src/panels/payments/policy-list.tsx +64 -0
- package/src/panels/payments/reservation-list.tsx +78 -0
- package/src/panels/payments/transaction-list.tsx +103 -0
- package/src/panels/payments/transfer-form.tsx +109 -0
- package/src/panels/search/column-search.tsx +79 -0
- package/src/panels/search/knowledge-view.tsx +100 -0
- package/src/panels/search/memory-list.tsx +197 -0
- package/src/panels/search/playbook-list.tsx +77 -0
- package/src/panels/search/rlm-answer-view.tsx +105 -0
- package/src/panels/search/search-panel.tsx +405 -0
- package/src/panels/search/search-results.tsx +116 -0
- package/src/panels/stack/stack-panel.tsx +474 -0
- package/src/panels/versions/conflicts-tab.tsx +59 -0
- package/src/panels/versions/entry-detail.tsx +89 -0
- package/src/panels/versions/transaction-actions.tsx +34 -0
- package/src/panels/versions/transaction-list.tsx +90 -0
- package/src/panels/versions/versions-panel.tsx +276 -0
- package/src/panels/workflows/execution-list.tsx +102 -0
- package/src/panels/workflows/scheduler-view.tsx +135 -0
- package/src/panels/workflows/workflow-list.tsx +88 -0
- package/src/panels/workflows/workflows-panel.tsx +295 -0
- package/src/panels/zones/brick-detail.tsx +136 -0
- package/src/panels/zones/brick-list.tsx +56 -0
- package/src/panels/zones/cache-tab.tsx +118 -0
- package/src/panels/zones/drift-view.tsx +97 -0
- package/src/panels/zones/mcp-mounts-tab.tsx +38 -0
- package/src/panels/zones/memories-tab.tsx +37 -0
- package/src/panels/zones/reindex-status.tsx +84 -0
- package/src/panels/zones/workspaces-tab.tsx +37 -0
- package/src/panels/zones/zone-list.tsx +73 -0
- package/src/panels/zones/zones-panel.tsx +559 -0
- package/src/services/command-runner.ts +303 -0
- package/src/shared/accessibility-announcements.ts +44 -0
- package/src/shared/action-registry.ts +466 -0
- package/src/shared/brick-states.ts +91 -0
- package/src/shared/command-palette.ts +35 -0
- package/src/shared/components/announcement-bar.tsx +30 -0
- package/src/shared/components/app-confirm-dialog.tsx +29 -0
- package/src/shared/components/breadcrumb.tsx +21 -0
- package/src/shared/components/brick-gate.tsx +60 -0
- package/src/shared/components/command-output.tsx +95 -0
- package/src/shared/components/command-palette.tsx +97 -0
- package/src/shared/components/confirm-dialog.tsx +61 -0
- package/src/shared/components/diff-viewer.tsx +219 -0
- package/src/shared/components/empty-state.tsx +36 -0
- package/src/shared/components/error-bar.tsx +60 -0
- package/src/shared/components/error-boundary.tsx +53 -0
- package/src/shared/components/help-overlay.tsx +99 -0
- package/src/shared/components/identity-switcher.tsx +168 -0
- package/src/shared/components/loading-indicator.tsx +40 -0
- package/src/shared/components/pagination-bar.tsx +68 -0
- package/src/shared/components/pre-connection-screen.tsx +398 -0
- package/src/shared/components/scroll-indicator.tsx +46 -0
- package/src/shared/components/side-nav-utils.ts +68 -0
- package/src/shared/components/side-nav.tsx +287 -0
- package/src/shared/components/spinner.tsx +26 -0
- package/src/shared/components/status-bar.tsx +117 -0
- package/src/shared/components/styled-text.tsx +72 -0
- package/src/shared/components/sub-tab-bar-utils.ts +100 -0
- package/src/shared/components/sub-tab-bar.tsx +40 -0
- package/src/shared/components/tab-bar-utils.ts +36 -0
- package/src/shared/components/tab-bar.tsx +50 -0
- package/src/shared/components/text-input.tsx +73 -0
- package/src/shared/components/tooltip.tsx +53 -0
- package/src/shared/components/virtual-list.tsx +93 -0
- package/src/shared/components/welcome-screen.tsx +111 -0
- package/src/shared/hooks/use-api.ts +10 -0
- package/src/shared/hooks/use-brick-available.ts +42 -0
- package/src/shared/hooks/use-confirm.ts +66 -0
- package/src/shared/hooks/use-connection-state.ts +67 -0
- package/src/shared/hooks/use-copy.ts +31 -0
- package/src/shared/hooks/use-fresh-server.ts +62 -0
- package/src/shared/hooks/use-keyboard.ts +58 -0
- package/src/shared/hooks/use-list-navigation.ts +106 -0
- package/src/shared/hooks/use-swr.ts +117 -0
- package/src/shared/hooks/use-tab-fallback.ts +32 -0
- package/src/shared/hooks/use-text-input.ts +113 -0
- package/src/shared/hooks/use-visible-tabs.ts +61 -0
- package/src/shared/lib/circular-buffer.ts +82 -0
- package/src/shared/lib/clipboard.ts +14 -0
- package/src/shared/nav-items.ts +73 -0
- package/src/shared/navigation.ts +110 -0
- package/src/shared/status-breadcrumb.ts +74 -0
- package/src/shared/syntax-style.ts +3 -0
- package/src/shared/tab-visibility.ts +15 -0
- package/src/shared/text-style.ts +23 -0
- package/src/shared/theme.ts +179 -0
- package/src/shared/utils/format-size.ts +20 -0
- package/src/shared/utils/format-text.ts +10 -0
- package/src/shared/utils/format-time.ts +72 -0
- package/src/shared/utils/lru-cache.ts +75 -0
- package/src/stores/access-store-types.ts +154 -0
- package/src/stores/access-store.ts +674 -0
- package/src/stores/agents-store.ts +404 -0
- package/src/stores/announcement-store.ts +46 -0
- package/src/stores/api-console-store.ts +476 -0
- package/src/stores/connectors-store.ts +434 -0
- package/src/stores/create-api-action.ts +140 -0
- package/src/stores/delegation-store.ts +300 -0
- package/src/stores/error-store.ts +102 -0
- package/src/stores/events-store.ts +163 -0
- package/src/stores/files-store.ts +630 -0
- package/src/stores/first-run-store.ts +34 -0
- package/src/stores/global-store.ts +255 -0
- package/src/stores/infra-store.ts +461 -0
- package/src/stores/knowledge-store.ts +358 -0
- package/src/stores/lineage-store.ts +126 -0
- package/src/stores/mcp-store.ts +147 -0
- package/src/stores/payments-store.ts +545 -0
- package/src/stores/search-store-types.ts +155 -0
- package/src/stores/search-store.ts +656 -0
- package/src/stores/share-link-store.ts +151 -0
- package/src/stores/stack-store.ts +352 -0
- package/src/stores/ui-store.ts +161 -0
- package/src/stores/upload-store.ts +131 -0
- package/src/stores/versions-store.ts +355 -0
- package/src/stores/workflows-store.ts +402 -0
- package/src/stores/workspace-store.ts +185 -0
- package/src/stores/zones-store.ts +378 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrollable transaction list with status badges and selection highlight.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import type { Transaction } from "../../stores/versions-store.js";
|
|
7
|
+
import { EmptyState } from "../../shared/components/empty-state.js";
|
|
8
|
+
import { textStyle } from "../../shared/text-style.js";
|
|
9
|
+
import { transactionStatusColor } from "../../shared/theme.js";
|
|
10
|
+
import { ScrollIndicator } from "../../shared/components/scroll-indicator.js";
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Status badges
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
const STATUS_BADGE: Readonly<Record<Transaction["status"], string>> = {
|
|
17
|
+
active: "\u25CF", // filled circle
|
|
18
|
+
committed: "\u2713", // check mark
|
|
19
|
+
rolled_back: "\u2717", // ballot x
|
|
20
|
+
expired: "\u25CB", // empty circle
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function statusBadge(status: Transaction["status"]): string {
|
|
24
|
+
return STATUS_BADGE[status];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function truncateId(id: string): string {
|
|
28
|
+
return id.length > 8 ? id.slice(0, 8) : id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatTime(iso: string): string {
|
|
32
|
+
try {
|
|
33
|
+
const date = new Date(iso);
|
|
34
|
+
return date.toLocaleTimeString(undefined, {
|
|
35
|
+
hour: "2-digit",
|
|
36
|
+
minute: "2-digit",
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
return iso;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Component
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
interface TransactionListProps {
|
|
48
|
+
readonly transactions: readonly Transaction[];
|
|
49
|
+
readonly selectedIndex: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TransactionList({
|
|
53
|
+
transactions,
|
|
54
|
+
selectedIndex,
|
|
55
|
+
}: TransactionListProps): React.ReactNode {
|
|
56
|
+
if (transactions.length === 0) {
|
|
57
|
+
return (
|
|
58
|
+
<EmptyState
|
|
59
|
+
message="No transactions yet."
|
|
60
|
+
hint="Press n to begin one, or write a file to create an auto-transaction."
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<ScrollIndicator selectedIndex={selectedIndex} totalItems={transactions.length} visibleItems={20}>
|
|
67
|
+
<scrollbox flexGrow={1} width="100%">
|
|
68
|
+
{transactions.map((txn, index) => {
|
|
69
|
+
const selected = index === selectedIndex;
|
|
70
|
+
const prefix = selected ? "\u25B8 " : " ";
|
|
71
|
+
const badge = statusBadge(txn.status);
|
|
72
|
+
const desc = txn.description ?? "";
|
|
73
|
+
const id = truncateId(txn.transaction_id);
|
|
74
|
+
const time = formatTime(txn.created_at);
|
|
75
|
+
const entries = `${txn.entry_count} entries`;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<box key={txn.transaction_id} height={1} width="100%">
|
|
79
|
+
<text>{prefix}</text>
|
|
80
|
+
<text style={textStyle({ fg: transactionStatusColor[txn.status] })}>{badge}</text>
|
|
81
|
+
<text>
|
|
82
|
+
{` ${id} ${desc ? desc + " " : ""}${entries} ${time}`}
|
|
83
|
+
</text>
|
|
84
|
+
</box>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</scrollbox>
|
|
88
|
+
</ScrollIndicator>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versions & Snapshots panel.
|
|
3
|
+
*
|
|
4
|
+
* Left pane: transaction list with status badges.
|
|
5
|
+
* Right pane: entry detail for the selected transaction.
|
|
6
|
+
* Bottom: keyboard shortcut hints.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
10
|
+
import {
|
|
11
|
+
useVersionsStore,
|
|
12
|
+
nextStatusFilter,
|
|
13
|
+
} from "../../stores/versions-store.js";
|
|
14
|
+
import { useKeyboard } from "../../shared/hooks/use-keyboard.js";
|
|
15
|
+
import { useCopy } from "../../shared/hooks/use-copy.js";
|
|
16
|
+
import { jumpToStart, jumpToEnd } from "../../shared/hooks/use-list-navigation.js";
|
|
17
|
+
import { useApi } from "../../shared/hooks/use-api.js";
|
|
18
|
+
import { BrickGate } from "../../shared/components/brick-gate.js";
|
|
19
|
+
import { TransactionList } from "./transaction-list.js";
|
|
20
|
+
import { EntryDetail } from "./entry-detail.js";
|
|
21
|
+
import { ConflictsView } from "./conflicts-tab.js";
|
|
22
|
+
import { useUiStore } from "../../stores/ui-store.js";
|
|
23
|
+
import { focusColor } from "../../shared/theme.js";
|
|
24
|
+
import { textStyle } from "../../shared/text-style.js";
|
|
25
|
+
import { formatActionHints, getVersionsFooterBindings } from "../../shared/action-registry.js";
|
|
26
|
+
|
|
27
|
+
export default function VersionsPanel(): React.ReactNode {
|
|
28
|
+
const client = useApi();
|
|
29
|
+
|
|
30
|
+
const transactions = useVersionsStore((s) => s.transactions);
|
|
31
|
+
const selectedIndex = useVersionsStore((s) => s.selectedIndex);
|
|
32
|
+
const statusFilter = useVersionsStore((s) => s.statusFilter);
|
|
33
|
+
const isLoading = useVersionsStore((s) => s.isLoading);
|
|
34
|
+
const error = useVersionsStore((s) => s.error);
|
|
35
|
+
const entries = useVersionsStore((s) => s.entries);
|
|
36
|
+
const entriesLoading = useVersionsStore((s) => s.entriesLoading);
|
|
37
|
+
|
|
38
|
+
const conflicts = useVersionsStore((s) => s.conflicts);
|
|
39
|
+
const conflictsLoading = useVersionsStore((s) => s.conflictsLoading);
|
|
40
|
+
const showConflicts = useVersionsStore((s) => s.showConflicts);
|
|
41
|
+
|
|
42
|
+
const transactionDetail = useVersionsStore((s) => s.transactionDetail);
|
|
43
|
+
const transactionDetailLoading = useVersionsStore((s) => s.transactionDetailLoading);
|
|
44
|
+
const diffContent = useVersionsStore((s) => s.diffContent);
|
|
45
|
+
const diffLoading = useVersionsStore((s) => s.diffLoading);
|
|
46
|
+
const fetchTransactionDetail = useVersionsStore((s) => s.fetchTransactionDetail);
|
|
47
|
+
|
|
48
|
+
const fetchTransactions = useVersionsStore((s) => s.fetchTransactions);
|
|
49
|
+
const setSelectedIndex = useVersionsStore((s) => s.setSelectedIndex);
|
|
50
|
+
const setStatusFilter = useVersionsStore((s) => s.setStatusFilter);
|
|
51
|
+
const fetchEntries = useVersionsStore((s) => s.fetchEntries);
|
|
52
|
+
const fetchDiff = useVersionsStore((s) => s.fetchDiff);
|
|
53
|
+
const beginTransaction = useVersionsStore((s) => s.beginTransaction);
|
|
54
|
+
const commitTransaction = useVersionsStore((s) => s.commitTransaction);
|
|
55
|
+
const rollbackTransaction = useVersionsStore((s) => s.rollbackTransaction);
|
|
56
|
+
const fetchConflicts = useVersionsStore((s) => s.fetchConflicts);
|
|
57
|
+
const toggleConflicts = useVersionsStore((s) => s.toggleConflicts);
|
|
58
|
+
|
|
59
|
+
// Clipboard copy
|
|
60
|
+
const { copy, copied } = useCopy();
|
|
61
|
+
|
|
62
|
+
// Focus pane (ui-store)
|
|
63
|
+
const uiFocusPane = useUiStore((s) => s.getFocusPane("versions"));
|
|
64
|
+
const toggleFocus = useUiStore((s) => s.toggleFocusPane);
|
|
65
|
+
const overlayActive = useUiStore((s) => s.overlayActive);
|
|
66
|
+
|
|
67
|
+
// Transaction search/filter
|
|
68
|
+
const [txnFilterMode, setTxnFilterMode] = useState(false);
|
|
69
|
+
const [txnFilter, setTxnFilter] = useState("");
|
|
70
|
+
|
|
71
|
+
const filteredTransactions = useMemo(() => {
|
|
72
|
+
if (!txnFilter) return transactions;
|
|
73
|
+
const lower = txnFilter.toLowerCase();
|
|
74
|
+
return transactions.filter(
|
|
75
|
+
(t) =>
|
|
76
|
+
t.transaction_id.toLowerCase().includes(lower) ||
|
|
77
|
+
(t.description ?? "").toLowerCase().includes(lower),
|
|
78
|
+
);
|
|
79
|
+
}, [transactions, txnFilter]);
|
|
80
|
+
|
|
81
|
+
// Derive selectedTransaction from filtered list so the index always maps correctly
|
|
82
|
+
const selectedTransaction = filteredTransactions[selectedIndex] ?? null;
|
|
83
|
+
|
|
84
|
+
const handleFilterKey = useCallback(
|
|
85
|
+
(keyName: string) => {
|
|
86
|
+
if (!txnFilterMode) return;
|
|
87
|
+
if (keyName.length === 1) {
|
|
88
|
+
setTxnFilter((b) => b + keyName);
|
|
89
|
+
} else if (keyName === "space") {
|
|
90
|
+
setTxnFilter((b) => b + " ");
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
[txnFilterMode],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Fetch transactions on mount and when filter changes
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (client) {
|
|
99
|
+
fetchTransactions(client);
|
|
100
|
+
}
|
|
101
|
+
}, [client, statusFilter, fetchTransactions]);
|
|
102
|
+
|
|
103
|
+
// Fetch entries and transaction detail when selection changes
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (client && selectedTransaction) {
|
|
106
|
+
fetchEntries(selectedTransaction.transaction_id, client);
|
|
107
|
+
fetchTransactionDetail(selectedTransaction.transaction_id, client);
|
|
108
|
+
}
|
|
109
|
+
}, [client, selectedTransaction, fetchEntries, fetchTransactionDetail]);
|
|
110
|
+
|
|
111
|
+
// Keyboard navigation
|
|
112
|
+
useKeyboard(
|
|
113
|
+
overlayActive
|
|
114
|
+
? {}
|
|
115
|
+
: txnFilterMode
|
|
116
|
+
? {
|
|
117
|
+
return: () => {
|
|
118
|
+
setTxnFilterMode(false);
|
|
119
|
+
setSelectedIndex(0);
|
|
120
|
+
},
|
|
121
|
+
escape: () => {
|
|
122
|
+
setTxnFilterMode(false);
|
|
123
|
+
setTxnFilter("");
|
|
124
|
+
setSelectedIndex(0);
|
|
125
|
+
},
|
|
126
|
+
backspace: () => {
|
|
127
|
+
setTxnFilter((b) => b.slice(0, -1));
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
: {
|
|
131
|
+
"j": () => {
|
|
132
|
+
if (filteredTransactions.length === 0) return;
|
|
133
|
+
setSelectedIndex(Math.max(0, Math.min(selectedIndex + 1, filteredTransactions.length - 1)));
|
|
134
|
+
},
|
|
135
|
+
"down": () => {
|
|
136
|
+
if (filteredTransactions.length === 0) return;
|
|
137
|
+
setSelectedIndex(Math.max(0, Math.min(selectedIndex + 1, filteredTransactions.length - 1)));
|
|
138
|
+
},
|
|
139
|
+
"k": () => setSelectedIndex(Math.max(selectedIndex - 1, 0)),
|
|
140
|
+
"up": () => setSelectedIndex(Math.max(selectedIndex - 1, 0)),
|
|
141
|
+
"return": () => {
|
|
142
|
+
if (selectedTransaction?.status === "active" && client) {
|
|
143
|
+
commitTransaction(selectedTransaction.transaction_id, client);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
"backspace": () => {
|
|
147
|
+
if (selectedTransaction?.status === "active" && client) {
|
|
148
|
+
rollbackTransaction(selectedTransaction.transaction_id, client);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"n": () => {
|
|
152
|
+
if (client) {
|
|
153
|
+
beginTransaction(client);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"f": () => {
|
|
157
|
+
const next = nextStatusFilter(statusFilter);
|
|
158
|
+
setStatusFilter(next);
|
|
159
|
+
},
|
|
160
|
+
"/": () => {
|
|
161
|
+
setTxnFilterMode(true);
|
|
162
|
+
setTxnFilter("");
|
|
163
|
+
},
|
|
164
|
+
"v": () => {
|
|
165
|
+
// View diff for the first entry of the selected transaction
|
|
166
|
+
if (!client || !selectedTransaction || entries.length === 0) return;
|
|
167
|
+
const entry = entries[0];
|
|
168
|
+
if (entry && entry.original_hash && entry.new_hash) {
|
|
169
|
+
fetchDiff(entry.path, entry.original_hash, entry.new_hash, client);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
"c": () => {
|
|
173
|
+
// Toggle conflicts view; fetch on first open
|
|
174
|
+
toggleConflicts();
|
|
175
|
+
if (!showConflicts && client) {
|
|
176
|
+
fetchConflicts(client);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"tab": () => toggleFocus("versions"),
|
|
180
|
+
"g": () => setSelectedIndex(jumpToStart()),
|
|
181
|
+
"shift+g": () => setSelectedIndex(jumpToEnd(filteredTransactions.length)),
|
|
182
|
+
"y": () => {
|
|
183
|
+
if (selectedTransaction) {
|
|
184
|
+
copy(selectedTransaction.transaction_id);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
txnFilterMode ? handleFilterKey : undefined,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const filterLabel = statusFilter ? ` [${statusFilter}]` : " [all]";
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<BrickGate brick="versioning">
|
|
195
|
+
<box height="100%" width="100%" flexDirection="column">
|
|
196
|
+
{/* Title bar */}
|
|
197
|
+
<box height={1} width="100%">
|
|
198
|
+
<text>
|
|
199
|
+
{isLoading
|
|
200
|
+
? `Versions & Snapshots${filterLabel} -- loading...`
|
|
201
|
+
: error
|
|
202
|
+
? `Versions & Snapshots${filterLabel} -- error: ${error}`
|
|
203
|
+
: `Versions & Snapshots${filterLabel} -- ${filteredTransactions.length} transactions${txnFilter ? ` (filtered)` : ""}`}
|
|
204
|
+
</text>
|
|
205
|
+
</box>
|
|
206
|
+
|
|
207
|
+
{/* Filter bar */}
|
|
208
|
+
{txnFilterMode && (
|
|
209
|
+
<box height={1} width="100%">
|
|
210
|
+
<text>{`Search: ${txnFilter}\u2588`}</text>
|
|
211
|
+
</box>
|
|
212
|
+
)}
|
|
213
|
+
{!txnFilterMode && txnFilter && (
|
|
214
|
+
<box height={1} width="100%">
|
|
215
|
+
<text>{`Filter: "${txnFilter}" (/ to change, Esc in filter to clear)`}</text>
|
|
216
|
+
</box>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{/* Main content: transaction list + entry detail */}
|
|
220
|
+
<box flexGrow={1} flexDirection="row">
|
|
221
|
+
{/* Left pane: transaction list (40%) */}
|
|
222
|
+
<box width="40%" height="100%" borderStyle="single" borderColor={uiFocusPane === "left" ? focusColor.activeBorder : focusColor.inactiveBorder}>
|
|
223
|
+
<TransactionList
|
|
224
|
+
transactions={filteredTransactions}
|
|
225
|
+
selectedIndex={selectedIndex}
|
|
226
|
+
/>
|
|
227
|
+
</box>
|
|
228
|
+
|
|
229
|
+
{/* Right pane: entry detail (60%) */}
|
|
230
|
+
<box width="60%" height="100%" borderStyle="single" borderColor={uiFocusPane === "right" ? focusColor.activeBorder : focusColor.inactiveBorder}>
|
|
231
|
+
<EntryDetail
|
|
232
|
+
transaction={selectedTransaction}
|
|
233
|
+
entries={entries}
|
|
234
|
+
isLoading={entriesLoading}
|
|
235
|
+
/>
|
|
236
|
+
</box>
|
|
237
|
+
</box>
|
|
238
|
+
|
|
239
|
+
{/* Transaction detail (below entry detail) */}
|
|
240
|
+
{transactionDetail && !transactionDetailLoading && (
|
|
241
|
+
<box height={3} width="100%">
|
|
242
|
+
<text>
|
|
243
|
+
{`Detail: zone=${transactionDetail.zone_id} agent=${transactionDetail.agent_id ?? "n/a"} entries=${transactionDetail.entry_count} created=${transactionDetail.created_at} expires=${transactionDetail.expires_at}`}
|
|
244
|
+
</text>
|
|
245
|
+
</box>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
{/* Diff viewer */}
|
|
249
|
+
{diffContent && !diffLoading && (
|
|
250
|
+
<box height={8} width="100%" borderStyle="single" flexDirection="column">
|
|
251
|
+
<box height={1} width="100%"><text>--- Old ---</text></box>
|
|
252
|
+
<scrollbox flexGrow={1} width="100%"><text>{diffContent.old}</text></scrollbox>
|
|
253
|
+
<box height={1} width="100%"><text>--- New ---</text></box>
|
|
254
|
+
<scrollbox flexGrow={1} width="100%"><text>{diffContent.new}</text></scrollbox>
|
|
255
|
+
</box>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Conflicts pane (toggleable) */}
|
|
259
|
+
<ConflictsView
|
|
260
|
+
conflicts={conflicts}
|
|
261
|
+
loading={conflictsLoading}
|
|
262
|
+
visible={showConflicts}
|
|
263
|
+
/>
|
|
264
|
+
|
|
265
|
+
{/* Help bar */}
|
|
266
|
+
<box height={1} width="100%">
|
|
267
|
+
{copied
|
|
268
|
+
? <text style={textStyle({ fg: "green" })}>Copied!</text>
|
|
269
|
+
: <text>
|
|
270
|
+
{formatActionHints(getVersionsFooterBindings({ txnFilterMode }))}
|
|
271
|
+
</text>}
|
|
272
|
+
</box>
|
|
273
|
+
</box>
|
|
274
|
+
</BrickGate>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution list view: execution_id, trigger_type, status, started_at,
|
|
3
|
+
* completed_at, actions progress, error_message.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useCallback } from "react";
|
|
7
|
+
import type { ExecutionSummary } from "../../stores/workflows-store.js";
|
|
8
|
+
import { EmptyState } from "../../shared/components/empty-state.js";
|
|
9
|
+
import { VirtualList } from "../../shared/components/virtual-list.js";
|
|
10
|
+
|
|
11
|
+
const VIEWPORT_HEIGHT = 20;
|
|
12
|
+
|
|
13
|
+
interface ExecutionListProps {
|
|
14
|
+
readonly executions: readonly ExecutionSummary[];
|
|
15
|
+
readonly selectedIndex: number;
|
|
16
|
+
readonly loading: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatTimestamp(ts: string | null): string {
|
|
20
|
+
if (!ts) return "---";
|
|
21
|
+
try {
|
|
22
|
+
return new Date(ts).toLocaleString();
|
|
23
|
+
} catch {
|
|
24
|
+
return ts;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function shortId(id: string): string {
|
|
29
|
+
if (id.length <= 10) return id;
|
|
30
|
+
return `${id.slice(0, 8)}..`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function truncate(text: string, maxLen: number): string {
|
|
34
|
+
if (text.length <= maxLen) return text;
|
|
35
|
+
return `${text.slice(0, maxLen - 3)}...`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ExecutionList({
|
|
39
|
+
executions,
|
|
40
|
+
selectedIndex,
|
|
41
|
+
loading,
|
|
42
|
+
}: ExecutionListProps): React.ReactNode {
|
|
43
|
+
if (loading) {
|
|
44
|
+
return (
|
|
45
|
+
<box height="100%" width="100%" justifyContent="center" alignItems="center">
|
|
46
|
+
<text>Loading executions...</text>
|
|
47
|
+
</box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (executions.length === 0) {
|
|
52
|
+
return (
|
|
53
|
+
<EmptyState
|
|
54
|
+
message="No executions found."
|
|
55
|
+
hint="Select a workflow and press e to execute it."
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const renderExecution = useCallback(
|
|
61
|
+
(ex: ExecutionSummary, i: number) => {
|
|
62
|
+
const isSelected = i === selectedIndex;
|
|
63
|
+
const id = shortId(ex.execution_id);
|
|
64
|
+
const status = truncate(ex.status, 9);
|
|
65
|
+
const trigger = truncate(ex.trigger_type, 12);
|
|
66
|
+
const progress = `${ex.actions_completed}/${ex.actions_total}`;
|
|
67
|
+
const errorText = ex.error_message
|
|
68
|
+
? truncate(ex.error_message, 20)
|
|
69
|
+
: "";
|
|
70
|
+
const prefix = isSelected ? "> " : " ";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<box key={ex.execution_id} height={1} width="100%">
|
|
74
|
+
<text>
|
|
75
|
+
{`${prefix}${id.padEnd(10)} ${status.padEnd(9)} ${trigger.padEnd(12)} ${progress.padEnd(8)} ${formatTimestamp(ex.started_at).padEnd(19)} ${errorText}`}
|
|
76
|
+
</text>
|
|
77
|
+
</box>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
[selectedIndex],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<box height="100%" width="100%" flexDirection="column">
|
|
85
|
+
{/* Header */}
|
|
86
|
+
<box height={1} width="100%">
|
|
87
|
+
<text>{" ID STATUS TRIGGER PROGRESS STARTED ERROR"}</text>
|
|
88
|
+
</box>
|
|
89
|
+
<box height={1} width="100%">
|
|
90
|
+
<text>{" ---------- --------- ------------ -------- ------------------- -----"}</text>
|
|
91
|
+
</box>
|
|
92
|
+
|
|
93
|
+
{/* Rows */}
|
|
94
|
+
<VirtualList
|
|
95
|
+
items={executions}
|
|
96
|
+
renderItem={renderExecution}
|
|
97
|
+
viewportHeight={VIEWPORT_HEIGHT}
|
|
98
|
+
selectedIndex={selectedIndex}
|
|
99
|
+
/>
|
|
100
|
+
</box>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler metrics dashboard: queued, running, completed, failed, throughput.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import type { SchedulerMetrics } from "../../stores/workflows-store.js";
|
|
7
|
+
|
|
8
|
+
interface SchedulerViewProps {
|
|
9
|
+
readonly metrics: SchedulerMetrics | null;
|
|
10
|
+
readonly loading: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatMs(ms: number): string {
|
|
14
|
+
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
15
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SchedulerView({ metrics, loading }: SchedulerViewProps): React.ReactNode {
|
|
19
|
+
if (loading) {
|
|
20
|
+
return (
|
|
21
|
+
<box height="100%" width="100%" justifyContent="center" alignItems="center">
|
|
22
|
+
<text>Loading scheduler metrics...</text>
|
|
23
|
+
</box>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!metrics) {
|
|
28
|
+
return (
|
|
29
|
+
<box height="100%" width="100%" justifyContent="center" alignItems="center">
|
|
30
|
+
<text>No scheduler metrics available</text>
|
|
31
|
+
</box>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const total = metrics.queued_tasks + metrics.running_tasks + metrics.completed_tasks + metrics.failed_tasks;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<scrollbox height="100%" width="100%">
|
|
39
|
+
{/* Scheduler type */}
|
|
40
|
+
<box height={1} width="100%">
|
|
41
|
+
<text>{`--- Astraea Scheduler (${metrics.use_hrrn ? "HRRN" : "FIFO"}) ---`}</text>
|
|
42
|
+
</box>
|
|
43
|
+
<box height={1} width="100%">
|
|
44
|
+
<text>{""}</text>
|
|
45
|
+
</box>
|
|
46
|
+
|
|
47
|
+
{/* Task counts */}
|
|
48
|
+
<box height={1} width="100%">
|
|
49
|
+
<text>{" Task Counts:"}</text>
|
|
50
|
+
</box>
|
|
51
|
+
<box height={1} width="100%">
|
|
52
|
+
<text>{` Queued: ${metrics.queued_tasks}`}</text>
|
|
53
|
+
</box>
|
|
54
|
+
<box height={1} width="100%">
|
|
55
|
+
<text>{` Running: ${metrics.running_tasks}`}</text>
|
|
56
|
+
</box>
|
|
57
|
+
<box height={1} width="100%">
|
|
58
|
+
<text>{` Completed: ${metrics.completed_tasks}`}</text>
|
|
59
|
+
</box>
|
|
60
|
+
<box height={1} width="100%">
|
|
61
|
+
<text>{` Failed: ${metrics.failed_tasks}`}</text>
|
|
62
|
+
</box>
|
|
63
|
+
<box height={1} width="100%">
|
|
64
|
+
<text>{` Total: ${total}`}</text>
|
|
65
|
+
</box>
|
|
66
|
+
|
|
67
|
+
{/* Queue by priority class */}
|
|
68
|
+
{metrics.queue_by_class && metrics.queue_by_class.length > 0 && (
|
|
69
|
+
<>
|
|
70
|
+
<box height={1} width="100%">
|
|
71
|
+
<text>{""}</text>
|
|
72
|
+
</box>
|
|
73
|
+
<box height={1} width="100%">
|
|
74
|
+
<text>{" Queue by Priority:"}</text>
|
|
75
|
+
</box>
|
|
76
|
+
{metrics.queue_by_class.map((c, i) => (
|
|
77
|
+
<box key={i} height={1} width="100%">
|
|
78
|
+
<text>{` ${(c.priority_class ?? "unknown").padEnd(12)} ${c.count} tasks`}</text>
|
|
79
|
+
</box>
|
|
80
|
+
))}
|
|
81
|
+
</>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{/* Fair share */}
|
|
85
|
+
{metrics.fair_share && Object.keys(metrics.fair_share).length > 0 && (
|
|
86
|
+
<>
|
|
87
|
+
<box height={1} width="100%">
|
|
88
|
+
<text>{""}</text>
|
|
89
|
+
</box>
|
|
90
|
+
<box height={1} width="100%">
|
|
91
|
+
<text>{" Fair Share Allocation:"}</text>
|
|
92
|
+
</box>
|
|
93
|
+
{Object.entries(metrics.fair_share).map(([agent, share], i) => (
|
|
94
|
+
<box key={i} height={1} width="100%">
|
|
95
|
+
<text>{` ${agent.padEnd(20)} ${JSON.stringify(share)}`}</text>
|
|
96
|
+
</box>
|
|
97
|
+
))}
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{/* Performance */}
|
|
102
|
+
{(metrics.avg_wait_ms > 0 || metrics.throughput_per_minute > 0) && (
|
|
103
|
+
<>
|
|
104
|
+
<box height={1} width="100%">
|
|
105
|
+
<text>{""}</text>
|
|
106
|
+
</box>
|
|
107
|
+
<box height={1} width="100%">
|
|
108
|
+
<text>{" Performance:"}</text>
|
|
109
|
+
</box>
|
|
110
|
+
<box height={1} width="100%">
|
|
111
|
+
<text>{` Avg wait: ${formatMs(metrics.avg_wait_ms)}`}</text>
|
|
112
|
+
</box>
|
|
113
|
+
<box height={1} width="100%">
|
|
114
|
+
<text>{` Avg duration: ${formatMs(metrics.avg_duration_ms)}`}</text>
|
|
115
|
+
</box>
|
|
116
|
+
<box height={1} width="100%">
|
|
117
|
+
<text>{` Throughput: ${metrics.throughput_per_minute.toFixed(1)}/min`}</text>
|
|
118
|
+
</box>
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Success rate */}
|
|
123
|
+
{total > 0 && (
|
|
124
|
+
<>
|
|
125
|
+
<box height={1} width="100%">
|
|
126
|
+
<text>{""}</text>
|
|
127
|
+
</box>
|
|
128
|
+
<box height={1} width="100%">
|
|
129
|
+
<text>{` Success: ${((metrics.completed_tasks / total) * 100).toFixed(1)}% | Failed: ${((metrics.failed_tasks / total) * 100).toFixed(1)}%`}</text>
|
|
130
|
+
</box>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</scrollbox>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow list view: name, version, enabled, triggers count, actions count, description.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { useCallback } from "react";
|
|
6
|
+
import type { WorkflowSummary } from "../../stores/workflows-store.js";
|
|
7
|
+
import { textStyle } from "../../shared/text-style.js";
|
|
8
|
+
import { statusColor } from "../../shared/theme.js";
|
|
9
|
+
import { EmptyState } from "../../shared/components/empty-state.js";
|
|
10
|
+
import { VirtualList } from "../../shared/components/virtual-list.js";
|
|
11
|
+
|
|
12
|
+
const VIEWPORT_HEIGHT = 20;
|
|
13
|
+
|
|
14
|
+
interface WorkflowListProps {
|
|
15
|
+
readonly workflows: readonly WorkflowSummary[];
|
|
16
|
+
readonly selectedIndex: number;
|
|
17
|
+
readonly loading: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function truncate(text: string, maxLen: number): string {
|
|
21
|
+
if (text.length <= maxLen) return text;
|
|
22
|
+
return `${text.slice(0, maxLen - 3)}...`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function WorkflowList({
|
|
26
|
+
workflows,
|
|
27
|
+
selectedIndex,
|
|
28
|
+
loading,
|
|
29
|
+
}: WorkflowListProps): React.ReactNode {
|
|
30
|
+
if (loading) {
|
|
31
|
+
return (
|
|
32
|
+
<box height="100%" width="100%" justifyContent="center" alignItems="center">
|
|
33
|
+
<text>Loading workflows...</text>
|
|
34
|
+
</box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (workflows.length === 0) {
|
|
39
|
+
return (
|
|
40
|
+
<EmptyState
|
|
41
|
+
message="No workflows defined."
|
|
42
|
+
hint="Create one via the API: POST /api/v2/workflows"
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const renderWorkflow = useCallback(
|
|
48
|
+
(w: WorkflowSummary, i: number) => {
|
|
49
|
+
const isSelected = i === selectedIndex;
|
|
50
|
+
const enabledBadge = w.enabled ? "[ON]" : "[--]";
|
|
51
|
+
const name = truncate(w.name, 19);
|
|
52
|
+
const version = truncate(w.version, 8);
|
|
53
|
+
const desc = w.description ? truncate(w.description, 30) : "";
|
|
54
|
+
const prefix = isSelected ? "> " : " ";
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<box key={w.name} height={1} width="100%">
|
|
58
|
+
<text>
|
|
59
|
+
<span>{prefix}</span>
|
|
60
|
+
<span style={textStyle({ fg: w.enabled ? statusColor.healthy : statusColor.dim })}>{enabledBadge.padEnd(5)}</span>
|
|
61
|
+
<span>{`${name.padEnd(19)} ${version.padEnd(8)} ${String(w.triggers).padEnd(4)} ${String(w.actions).padEnd(3)} ${desc}`}</span>
|
|
62
|
+
</text>
|
|
63
|
+
</box>
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
[selectedIndex],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<box height="100%" width="100%" flexDirection="column">
|
|
71
|
+
{/* Header */}
|
|
72
|
+
<box height={1} width="100%">
|
|
73
|
+
<text>{" EN NAME VERSION TRIG ACT DESCRIPTION"}</text>
|
|
74
|
+
</box>
|
|
75
|
+
<box height={1} width="100%">
|
|
76
|
+
<text>{" --- ------------------- -------- ---- --- -----------"}</text>
|
|
77
|
+
</box>
|
|
78
|
+
|
|
79
|
+
{/* Rows */}
|
|
80
|
+
<VirtualList
|
|
81
|
+
items={workflows}
|
|
82
|
+
renderItem={renderWorkflow}
|
|
83
|
+
viewportHeight={VIEWPORT_HEIGHT}
|
|
84
|
+
selectedIndex={selectedIndex}
|
|
85
|
+
/>
|
|
86
|
+
</box>
|
|
87
|
+
);
|
|
88
|
+
}
|