Skip to content

Commit 6d70909

Browse files
authored
Display more details. (#4305)
## Motivation Currently only a few fields (from the header, body or messages) are stored in a denormalized way in a database – ie they are stored in dedicated fields – while the whole block is stored as a blob data. We want to show more details to user, this could be done in one of the two ways: 1. load the blob, deserialize in frontend 2. store in denormalized way and load directly in frontend ## Proposal Implement the approach `#2` – add more fields to database tables and store block's details directly there. Also, use the new data to show on the frontend of the explorer. Some refactoring was done as part of the PR: - add a dedicated type `ExpandableSection` that shows only the metadata of the section and expands on click to show more - add `CopyableHash` to present a text that can be copied. ## Test Plan Manual ## Release Plan - Nothing to do / These changes follow the usual release cycle. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist)
1 parent 282dfa3 commit 6d70909

File tree

18 files changed

+2008
-297
lines changed

18 files changed

+2008
-297
lines changed

.github/workflows/rust.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ jobs:
181181
needs: changed-files
182182
if: needs.changed-files.outputs.should-run == 'true'
183183
runs-on: ubuntu-latest
184-
timeout-minutes: 30
184+
timeout-minutes: 40
185185

186186
steps:
187187
- uses: actions/checkout@v4

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

linera-execution/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,17 @@ pub enum MessageKind {
945945
Bouncing,
946946
}
947947

948+
impl Display for MessageKind {
949+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
950+
match self {
951+
MessageKind::Simple => write!(f, "Simple"),
952+
MessageKind::Protected => write!(f, "Protected"),
953+
MessageKind::Tracked => write!(f, "Tracked"),
954+
MessageKind::Bouncing => write!(f, "Bouncing"),
955+
}
956+
}
957+
}
958+
948959
/// A posted message together with routing information.
949960
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, SimpleObject)]
950961
pub struct OutgoingMessage {

linera-execution/src/system.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod tests;
88

99
use std::{
1010
collections::{BTreeMap, BTreeSet, HashSet},
11+
fmt::{Display, Formatter},
1112
mem,
1213
};
1314

@@ -269,6 +270,15 @@ impl Recipient {
269270
}
270271
}
271272

273+
impl Display for Recipient {
274+
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
275+
match self {
276+
Recipient::Burn => write!(f, "burn"),
277+
Recipient::Account(account) => write!(f, "{}", account),
278+
}
279+
}
280+
}
281+
272282
impl From<ChainId> for Recipient {
273283
fn from(chain_id: ChainId) -> Self {
274284
Recipient::chain(chain_id)

linera-explorer-new/server/database.js

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,105 @@ export class BlockchainDatabase {
6060
return stmt.all(blockHash);
6161
}
6262

63-
// Get posted messages for a bundle
63+
// Get posted messages for a bundle with SystemMessage fields
6464
getPostedMessages(bundleId) {
6565
const stmt = this.db.prepare(`
66-
SELECT * FROM posted_messages
66+
SELECT *, system_target, system_amount, system_source, system_owner, system_recipient,
67+
message_type, application_id, system_message_type
68+
FROM posted_messages
6769
WHERE bundle_id = ?
6870
ORDER BY message_index ASC
6971
`);
7072
return stmt.all(bundleId);
7173
}
7274

75+
// Get block with all bundles and their messages in a single query
76+
getBlockWithBundlesAndMessages(blockHash) {
77+
const stmt = this.db.prepare(`
78+
SELECT
79+
ib.id as bundle_id,
80+
ib.bundle_index,
81+
ib.origin_chain_id,
82+
ib.action,
83+
ib.source_height,
84+
ib.source_timestamp,
85+
ib.source_cert_hash,
86+
ib.transaction_index as bundle_transaction_index,
87+
ib.created_at as bundle_created_at,
88+
pm.id as message_id,
89+
pm.message_index,
90+
pm.authenticated_signer,
91+
pm.grant_amount,
92+
pm.refund_grant_to,
93+
pm.message_kind,
94+
pm.message_type,
95+
pm.application_id,
96+
pm.system_message_type,
97+
pm.system_target,
98+
pm.system_amount,
99+
pm.system_source,
100+
pm.system_owner,
101+
pm.system_recipient,
102+
pm.message_data,
103+
pm.created_at as message_created_at
104+
FROM incoming_bundles ib
105+
LEFT JOIN posted_messages pm ON ib.id = pm.bundle_id
106+
WHERE ib.block_hash = ?
107+
ORDER BY ib.bundle_index, pm.message_index
108+
`);
109+
110+
const rows = stmt.all(blockHash);
111+
112+
// Group results by bundle
113+
const bundlesMap = new Map();
114+
115+
for (const row of rows) {
116+
const bundleId = row.bundle_id;
117+
118+
if (!bundlesMap.has(bundleId)) {
119+
bundlesMap.set(bundleId, {
120+
id: bundleId,
121+
block_hash: blockHash,
122+
bundle_index: row.bundle_index,
123+
origin_chain_id: row.origin_chain_id,
124+
action: row.action,
125+
source_height: row.source_height,
126+
source_timestamp: row.source_timestamp,
127+
source_cert_hash: row.source_cert_hash,
128+
transaction_index: row.bundle_transaction_index,
129+
created_at: row.bundle_created_at,
130+
messages: []
131+
});
132+
}
133+
134+
// Add message if it exists (LEFT JOIN may produce NULL messages)
135+
if (row.message_id) {
136+
const bundle = bundlesMap.get(bundleId);
137+
bundle.messages.push({
138+
id: row.message_id,
139+
bundle_id: bundleId,
140+
message_index: row.message_index,
141+
authenticated_signer: row.authenticated_signer,
142+
grant_amount: row.grant_amount,
143+
refund_grant_to: row.refund_grant_to,
144+
message_kind: row.message_kind,
145+
message_type: row.message_type,
146+
application_id: row.application_id,
147+
system_message_type: row.system_message_type,
148+
system_target: row.system_target,
149+
system_amount: row.system_amount,
150+
system_source: row.system_source,
151+
system_owner: row.system_owner,
152+
system_recipient: row.system_recipient,
153+
message_data: row.message_data ? Buffer.from(row.message_data).toString('base64') : null,
154+
created_at: row.message_created_at
155+
});
156+
}
157+
}
158+
159+
return Array.from(bundlesMap.values());
160+
}
161+
73162
// Get all unique chains with stats
74163
getChains() {
75164
const stmt = this.db.prepare(`
@@ -104,6 +193,71 @@ export class BlockchainDatabase {
104193
return result.count;
105194
}
106195

196+
// Get operations for a block
197+
getOperations(blockHash) {
198+
const stmt = this.db.prepare(`
199+
SELECT * FROM operations
200+
WHERE block_hash = ?
201+
ORDER BY operation_index ASC
202+
`);
203+
const operations = stmt.all(blockHash);
204+
205+
// Convert binary data to base64 for JSON transport
206+
return operations.map(op => ({
207+
...op,
208+
data: op.data ? Buffer.from(op.data).toString('base64') : null
209+
}));
210+
}
211+
212+
// Get messages for a block
213+
getMessages(blockHash) {
214+
const stmt = this.db.prepare(`
215+
SELECT *, system_target, system_amount, system_source, system_owner, system_recipient FROM outgoing_messages
216+
WHERE block_hash = ?
217+
ORDER BY transaction_index ASC, message_index ASC
218+
`);
219+
const messages = stmt.all(blockHash);
220+
221+
// Convert binary data to base64 for JSON transport
222+
return messages.map(msg => ({
223+
...msg,
224+
data: msg.data ? Buffer.from(msg.data).toString('base64') : null
225+
}));
226+
}
227+
228+
// Get events for a block
229+
getEvents(blockHash) {
230+
const stmt = this.db.prepare(`
231+
SELECT * FROM events
232+
WHERE block_hash = ?
233+
ORDER BY transaction_index ASC, event_index ASC
234+
`);
235+
const events = stmt.all(blockHash);
236+
237+
// Convert binary data to base64 for JSON transport
238+
return events.map(event => ({
239+
...event,
240+
data: event.data ? Buffer.from(event.data).toString('base64') : null
241+
}));
242+
}
243+
244+
// Get oracle responses for a block
245+
getOracleResponses(blockHash) {
246+
const stmt = this.db.prepare(`
247+
SELECT * FROM oracle_responses
248+
WHERE block_hash = ?
249+
ORDER BY transaction_index ASC, response_index ASC
250+
`);
251+
const responses = stmt.all(blockHash);
252+
253+
// Convert binary data to base64 for JSON transport
254+
return responses.map(resp => ({
255+
...resp,
256+
data: resp.data ? Buffer.from(resp.data).toString('base64') : null
257+
}));
258+
}
259+
260+
107261
close() {
108262
this.db.close();
109263
}

linera-explorer-new/server/index.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,30 @@ app.get('/api/blocks/:hash/bundles', (req, res) => {
7575
}
7676
});
7777

78+
// Get bundles with messages - optimized single query
79+
app.get('/api/blocks/:hash/bundles-with-messages', (req, res) => {
80+
try {
81+
const { hash } = req.params;
82+
const bundlesWithMessages = db.getBlockWithBundlesAndMessages(hash);
83+
84+
// Convert binary data to base64 for JSON transport
85+
bundlesWithMessages.forEach(bundle => {
86+
bundle.messages.forEach(message => {
87+
if (message.authenticated_signer) {
88+
message.authenticated_signer = Buffer.from(message.authenticated_signer).toString('base64');
89+
}
90+
if (message.refund_grant_to) {
91+
message.refund_grant_to = Buffer.from(message.refund_grant_to).toString('base64');
92+
}
93+
});
94+
});
95+
96+
res.json(bundlesWithMessages);
97+
} catch (error) {
98+
handleError(res, error, 'Failed to fetch bundles with messages');
99+
}
100+
});
101+
78102
// Get posted messages for a bundle
79103
app.get('/api/bundles/:id/messages', (req, res) => {
80104
try {
@@ -156,6 +180,51 @@ app.get('/api/stats', (req, res) => {
156180
}
157181
});
158182

183+
// Get operations for a block
184+
app.get('/api/blocks/:hash/operations', (req, res) => {
185+
try {
186+
const { hash } = req.params;
187+
const operations = db.getOperations(hash);
188+
res.json(operations);
189+
} catch (error) {
190+
handleError(res, error, 'Failed to fetch operations');
191+
}
192+
});
193+
194+
// Get messages for a block
195+
app.get('/api/blocks/:hash/messages', (req, res) => {
196+
try {
197+
const { hash } = req.params;
198+
const messages = db.getMessages(hash);
199+
res.json(messages);
200+
} catch (error) {
201+
handleError(res, error, 'Failed to fetch messages');
202+
}
203+
});
204+
205+
// Get events for a block
206+
app.get('/api/blocks/:hash/events', (req, res) => {
207+
try {
208+
const { hash } = req.params;
209+
const events = db.getEvents(hash);
210+
res.json(events);
211+
} catch (error) {
212+
handleError(res, error, 'Failed to fetch events');
213+
}
214+
});
215+
216+
// Get oracle responses for a block
217+
app.get('/api/blocks/:hash/oracle-responses', (req, res) => {
218+
try {
219+
const { hash } = req.params;
220+
const oracleResponses = db.getOracleResponses(hash);
221+
res.json(oracleResponses);
222+
} catch (error) {
223+
handleError(res, error, 'Failed to fetch oracle responses');
224+
}
225+
});
226+
227+
159228
// Health check
160229
app.get('/api/health', (req, res) => {
161230
res.json({ status: 'OK', timestamp: new Date().toISOString() });
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React, { useState } from 'react';
2+
import { Copy, Check } from 'lucide-react';
3+
4+
interface BinaryDataSectionProps {
5+
data: Uint8Array;
6+
title: string;
7+
maxDisplayBytes?: number;
8+
className?: string;
9+
}
10+
11+
export const BinaryDataSection: React.FC<BinaryDataSectionProps> = ({
12+
data,
13+
title,
14+
maxDisplayBytes = 400,
15+
className = ''
16+
}) => {
17+
const [copied, setCopied] = useState(false);
18+
19+
const formatBytes = (bytes: number) => {
20+
if (bytes < 1024) return `${bytes} B`;
21+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
22+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
23+
};
24+
25+
const copyToClipboard = async () => {
26+
try {
27+
const hexString = Array.from(data).map(byte => byte.toString(16).padStart(2, '0')).join('');
28+
await navigator.clipboard.writeText(hexString);
29+
setCopied(true);
30+
setTimeout(() => setCopied(false), 2000);
31+
} catch (err) {
32+
console.error('Failed to copy binary data: ', err);
33+
}
34+
};
35+
36+
const displayData = data.slice(0, maxDisplayBytes);
37+
const hexString = Array.from(displayData)
38+
.map(byte => byte.toString(16).padStart(2, '0'))
39+
.join(' ')
40+
.replace(/(.{48})/g, '$1\n'); // Add line breaks every 48 characters (16 bytes)
41+
42+
return (
43+
<div className={`mt-3 pt-3 border-t border-linera-border/30 ${className}`}>
44+
<span className="text-sm text-linera-gray-light block mb-2">{title}</span>
45+
<div className="bg-linera-darker/50 rounded-lg border border-linera-border/50 p-4 font-mono text-xs overflow-x-auto">
46+
<div className="text-linera-gray-light mb-4 flex items-center justify-between">
47+
<div>
48+
<span>Size: {formatBytes(data.length)}</span>
49+
<span className="mx-2"></span>
50+
<span>Type: Binary Data</span>
51+
</div>
52+
<button
53+
onClick={copyToClipboard}
54+
className="text-linera-gray-medium hover:text-linera-red transition-colors"
55+
title={copied ? 'Copied!' : 'Copy to clipboard'}
56+
>
57+
{copied ? (
58+
<Check className="w-4 h-4 text-green-400" />
59+
) : (
60+
<Copy className="w-4 h-4" />
61+
)}
62+
</button>
63+
</div>
64+
<div className="text-linera-gray-light leading-relaxed max-h-32 overflow-y-auto">
65+
{hexString}
66+
{data.length > maxDisplayBytes && (
67+
<div className="text-linera-gray-medium mt-4">
68+
... and {data.length - maxDisplayBytes} more bytes
69+
</div>
70+
)}
71+
</div>
72+
</div>
73+
</div>
74+
);
75+
};

0 commit comments

Comments
 (0)