UI: добавить просмотр любого PDA

This commit is contained in:
AidarKC 2026-06-23 17:05:57 +04:00
parent f1c1132690
commit 08628704c7
7 changed files with 390 additions and 16 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.244
server.version=1.2.229
client.version=1.2.245
server.version=1.2.230

View File

@ -623,6 +623,38 @@ export async function readShineUserPda({ login, solanaEndpoint }) {
};
}
export async function readShineUserPdaByAddress({ pdaAddress, solanaEndpoint }) {
const address = String(pdaAddress || '').trim();
const endpoint = String(solanaEndpoint || '').trim();
if (!address) throw new Error('Не указан адрес PDA');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const accountAddress = new solana.PublicKey(address);
const accountInfo = await connection.getAccountInfo(accountAddress, 'confirmed');
if (!accountInfo?.data) throw new Error(`PDA не найдена: ${address}`);
return {
...parseShineUserPda(accountInfo.data),
userPda: accountAddress.toBase58(),
pdaAddress: accountAddress.toBase58(),
endpoint,
};
}
export async function readShineUserPdaByRef({ value, solanaEndpoint }) {
const ref = String(value || '').trim();
if (!ref) throw new Error('Не указан логин или адрес PDA');
try {
const bytes = base58ToBytes(ref);
if (bytes.length === 32) {
return await readShineUserPdaByAddress({ pdaAddress: ref, solanaEndpoint });
}
} catch {
// Если это не адрес, читаем как логин.
}
return readShineUserPda({ login: ref, solanaEndpoint });
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const parsed = await readShineUserPda({ login, solanaEndpoint });
const bch = parsed.blockchain;

View File

@ -14,21 +14,28 @@
<div class="card">
<h2>Действия</h2>
<div style="margin-bottom: 12px;">
<a href="server-ui/create-server-pda.html">
<button class="btn-primary" style="width:100%">
Зарегистрировать серверный аккаунт (создать PDA)
</button>
</a>
<div style="margin-bottom: 12px;">
<a href="server-ui/create-server-pda.html">
<button class="btn-primary" style="width:100%">
Зарегистрировать серверный аккаунт (создать PDA)
</button>
</a>
</div>
<div>
<a href="server-ui/update-server-pda.html">
<button class="btn-secondary" style="width:100%">
Обновить настройки сервера (update PDA)
</button>
</a>
</div>
<div style="margin-top: 12px;">
<a href="server-ui/read-pda.html">
<button class="btn-secondary" style="width:100%">
Просмотреть любой PDA
</button>
</a>
</div>
</div>
<div>
<a href="server-ui/update-server-pda.html">
<button class="btn-secondary" style="width:100%">
Обновить настройки сервера (update PDA)
</button>
</a>
</div>
</div>
<div class="card">
<h2>Как это работает</h2>

View File

@ -38,6 +38,7 @@
<div class="nav-links">
<a href="../server-ui.html">← Назад</a>
<a href="update-server-pda.html">Обновить PDA</a>
<a href="read-pda.html">Просмотреть PDA</a>
</div>
<h1>Регистрация серверного аккаунта</h1>

View File

@ -0,0 +1,242 @@
import { readShineUserPdaByRef } from '../../js/services/shine-user-pda-service.js';
import { bytesToBase58 } from '../../js/services/crypto-utils.js';
import { $, clearStatus, formatBigInt, formatTimestamp, setStatus } from './server-ui-shared.js';
function hex(bytes) {
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
return Array.from(data).map((x) => x.toString(16).padStart(2, '0')).join('');
}
function base58(bytes) {
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
return bytesToBase58(data);
}
function renderRows(rows) {
const container = $('summaryRows');
container.innerHTML = '';
for (const row of rows) {
const el = document.createElement('div');
el.className = 'field-row';
const label = document.createElement('div');
label.className = 'field-label';
label.textContent = row.label;
const value = document.createElement('div');
value.className = `field-value${row.muted ? ' muted' : ''}`;
value.textContent = row.value;
el.append(label, value);
container.appendChild(el);
}
}
function renderMiniGrid(items) {
const wrap = document.createElement('div');
wrap.className = 'mini-grid';
for (const item of items) {
const node = document.createElement('div');
node.className = 'mini-item';
const label = document.createElement('div');
label.className = 'mini-label';
label.textContent = item.label;
const value = document.createElement('div');
value.className = 'mini-value';
value.textContent = item.value;
node.append(label, value);
wrap.appendChild(node);
}
return wrap;
}
function renderBlock(title, subtitle, items) {
const card = document.createElement('div');
card.className = 'block-card';
const ttl = document.createElement('div');
ttl.className = 'block-title';
ttl.textContent = title;
card.appendChild(ttl);
if (subtitle) {
const sub = document.createElement('div');
sub.className = 'block-subtitle';
sub.textContent = subtitle;
card.appendChild(sub);
}
card.appendChild(renderMiniGrid(items));
return card;
}
function renderArrayLine(values, emptyLabel = '—') {
const arr = Array.isArray(values) ? values : [];
return arr.length ? arr.join(', ') : emptyLabel;
}
function renderSessionList(sessions) {
const list = document.createElement('div');
list.className = 'details-wrap';
const details = document.createElement('details');
details.open = true;
const summary = document.createElement('summary');
summary.textContent = `Сессии (${Array.isArray(sessions) ? sessions.length : 0})`;
const body = document.createElement('div');
body.className = 'details-body';
if (!Array.isArray(sessions) || sessions.length === 0) {
body.innerHTML = '<div class="field-value muted">Сессий нет.</div>';
} else {
const grid = document.createElement('div');
grid.className = 'block-list';
sessions.forEach((session, idx) => {
grid.appendChild(renderBlock(
`Session #${idx + 1}`,
`type=${session.sessionType}, version=${session.sessionVersion}`,
[
{ label: 'Имя', value: session.sessionName || '—' },
{ label: 'PubKey', value: base58(session.sessionPubKey32) },
],
));
});
body.appendChild(grid);
}
details.append(summary, body);
list.appendChild(details);
return list;
}
function renderParsed(parsed) {
$('summaryCard').style.display = 'block';
$('blocksCard').style.display = 'block';
$('rawCard').style.display = 'block';
renderRows([
{ label: 'PDA адрес', value: parsed.pdaAddress },
{ label: 'Логин', value: parsed.login },
{ label: 'Статус', value: parsed.isServer ? 'server' : 'not server' },
{ label: 'recordNumber', value: String(parsed.recordNumber) },
{ label: 'createdAtMs', value: `${parsed.createdAtMs} · ${formatTimestamp(parsed.createdAtMs)}` },
{ label: 'updatedAtMs', value: `${parsed.updatedAtMs} · ${formatTimestamp(parsed.updatedAtMs)}` },
{ label: 'recordLen', value: String(parsed.recordLen) },
{ label: 'prevRecordHash32', value: hex(parsed.prevRecordHash) },
{ label: 'signature64', value: base58(parsed.signature) },
{ label: 'unsignedBytesLen', value: String(parsed.unsignedBytes?.length || 0) },
]);
const badgeLine = $('badgeLine');
badgeLine.innerHTML = '';
const badges = [
{ label: `server=${parsed.isServer ? '1' : '0'}`, kind: parsed.isServer ? 'ok' : 'warn' },
{ label: `trusted=${Number(parsed.trustedCount || 0)}`, kind: Number(parsed.trustedCount || 0) > 0 ? 'ok' : 'warn' },
{ label: `sessions=${Array.isArray(parsed.sessions) ? parsed.sessions.length : 0}`, kind: 'info' },
{ label: `access=${Array.isArray(parsed.accessServers) ? parsed.accessServers.length : 0}`, kind: 'info' },
{ label: `sync=${Array.isArray(parsed.syncServers) ? parsed.syncServers.length : 0}`, kind: 'info' },
];
badges.forEach((item) => {
const badge = document.createElement('span');
badge.className = `badge ${item.kind}`;
badge.textContent = item.label;
badgeLine.appendChild(badge);
});
const blocks = $('blocksList');
blocks.innerHTML = '';
blocks.appendChild(renderBlock('RecoveryKeyBlock', 'block_type=0', [
{ label: 'recoveryKey32', value: base58(parsed.recoveryKey) },
]));
blocks.appendChild(renderBlock('RootKeyBlock', 'block_type=1', [
{ label: 'rootKey32', value: base58(parsed.rootKey) },
]));
blocks.appendChild(renderBlock('ClientKeyBlock', 'block_type=2', [
{ label: 'clientKey32', value: base58(parsed.clientKey) },
]));
blocks.appendChild(renderBlock('BlockchainRegistryBlock', 'block_type=3', [
{ label: 'blockchainType', value: String(parsed.blockchain?.blockchainType ?? 0) },
{ label: 'blockchainName', value: parsed.blockchain?.blockchainName || '—' },
{ label: 'blockchainPublicKey32', value: base58(parsed.blockchain?.blockchainPublicKey) },
{ label: 'paidLimitBytes', value: formatBigInt(parsed.blockchain?.paidLimitBytes || 0n) },
{ label: 'usedBytes', value: formatBigInt(parsed.blockchain?.usedBytes || 0n) },
{ label: 'lastBlockNumber', value: String(parsed.blockchain?.lastBlockNumber ?? 0) },
{ label: 'lastBlockHash32', value: hex(parsed.blockchain?.lastBlockHash) },
{ label: 'lastBlockSignature64', value: base58(parsed.blockchain?.lastBlockSignature) },
{ label: 'arweaveTxId', value: parsed.blockchain?.arweaveTxId || '—' },
]));
blocks.appendChild(renderBlock('ServerProfileBlock', 'block_type=30', [
{ label: 'isServer', value: parsed.isServer ? '1' : '0' },
{ label: 'addressFormatType', value: String(parsed.addressFormatType ?? 0) },
{ label: 'addressFormatVersion', value: String(parsed.addressFormatVersion ?? 0) },
{ label: 'serverAddress', value: parsed.serverAddress || '—' },
{ label: 'syncServersCount', value: String(parsed.syncServers?.length || 0) },
{ label: 'syncServers', value: renderArrayLine(parsed.syncServers) },
]));
blocks.appendChild(renderBlock('AccessServersBlock', 'block_type=40', [
{ label: 'accessServersCount', value: String(parsed.accessServers?.length || 0) },
{ label: 'accessServers', value: renderArrayLine(parsed.accessServers) },
]));
blocks.appendChild(renderBlock('SessionsBlock', 'block_type=50', [
{ label: 'sessionsMode', value: String(parsed.sessionsMode ?? 0) },
{ label: 'sessionsCount', value: String(parsed.sessions?.length || 0) },
{ label: 'sessions', value: parsed.sessions?.length ? 'ниже' : '—' },
]));
blocks.appendChild(renderBlock('TrustedStateBlock', 'block_type=70', [
{ label: 'trustedCount', value: String(parsed.trustedCount ?? 0) },
]));
blocks.appendChild(renderSessionList(parsed.sessions));
$('rawJson').textContent = JSON.stringify({
pdaAddress: parsed.pdaAddress,
login: parsed.login,
isServer: parsed.isServer,
recordNumber: parsed.recordNumber,
createdAtMs: parsed.createdAtMs.toString(),
updatedAtMs: parsed.updatedAtMs.toString(),
recordLen: parsed.recordLen,
trustedCount: parsed.trustedCount,
addressFormatType: parsed.addressFormatType,
addressFormatVersion: parsed.addressFormatVersion,
serverAddress: parsed.serverAddress,
syncServers: parsed.syncServers,
accessServers: parsed.accessServers,
sessionsMode: parsed.sessionsMode,
sessions: parsed.sessions.map((s) => ({
sessionType: s.sessionType,
sessionVersion: s.sessionVersion,
sessionName: s.sessionName,
sessionPubKey32: base58(s.sessionPubKey32),
})),
recoveryKey: base58(parsed.recoveryKey),
rootKey: base58(parsed.rootKey),
clientKey: base58(parsed.clientKey),
blockchain: {
blockchainType: parsed.blockchain.blockchainType,
blockchainName: parsed.blockchain.blockchainName,
blockchainPublicKey: base58(parsed.blockchain.blockchainPublicKey),
paidLimitBytes: parsed.blockchain.paidLimitBytes.toString(),
usedBytes: parsed.blockchain.usedBytes.toString(),
lastBlockNumber: parsed.blockchain.lastBlockNumber,
lastBlockHash: hex(parsed.blockchain.lastBlockHash),
lastBlockSignature: base58(parsed.blockchain.lastBlockSignature),
arweaveTxId: parsed.blockchain.arweaveTxId || '',
},
}, null, 2);
}
$('btnLoad').addEventListener('click', async () => {
clearStatus($('status'));
$('btnLoad').disabled = true;
$('summaryCard').style.display = 'none';
$('blocksCard').style.display = 'none';
$('rawCard').style.display = 'none';
try {
const endpoint = String($('endpoint').value || '').trim();
const ref = String($('ref').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
if (!ref) throw new Error('Укажите логин или адрес PDA');
setStatus($('status'), 'Чтение PDA...', 'info');
const parsed = await readShineUserPdaByRef({ value: ref, solanaEndpoint: endpoint });
renderParsed(parsed);
setStatus($('status'), 'PDA загружена.', 'success');
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnLoad').disabled = false;
}
});
document.body.dataset.ready = '1';

View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Просмотр PDA — SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.field-row { display: grid; grid-template-columns: 180px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.field-row:last-child { border-bottom: 0; }
.field-label { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.field-value { font-family: monospace; font-size: 12px; word-break: break-all; white-space: pre-wrap; }
.field-value.muted { color: var(--text-muted); }
.block-list { display: grid; gap: 12px; }
.block-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: #121212; }
.block-title { font-size: 13px; font-weight: 700; color: var(--accent); margin-bottom: 10px; }
.block-subtitle { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; }
.mini-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; }
.mini-item { min-width: 0; }
.mini-label { font-size: 11px; color: var(--text-muted); margin-bottom: 2px; text-transform: uppercase; letter-spacing: .04em; }
.mini-value { font-family: monospace; font-size: 12px; word-break: break-all; white-space: pre-wrap; }
.mono-box { font-family: monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; background: #0d0d0d; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; }
.summary-grid { display: grid; gap: 0; }
.badge-line { display: flex; gap: 8px; flex-wrap: wrap; }
.badge { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-size: 11px; color: var(--text-muted); background: #0d0d0d; }
.badge.ok { color: #7dcc7d; border-color: #2a4a2a; background: #1a2e1a; }
.badge.warn { color: #ffd37a; border-color: #5f4b22; background: #2f2614; }
.badge.err { color: #f08080; border-color: #5a2a2a; background: #2e1a1a; }
.details-wrap { margin-top: 12px; }
.details-wrap details { border: 1px solid var(--border); border-radius: var(--radius); background: #121212; }
.details-wrap summary { cursor: pointer; padding: 12px 14px; color: var(--accent); font-weight: 600; }
.details-body { padding: 0 14px 14px; }
.tight { margin-bottom: 8px; }
@media (max-width: 720px) {
.field-row { grid-template-columns: 1fr; gap: 4px; }
.mini-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="../server-ui.html">← Назад</a>
<a href="create-server-pda.html">Создать PDA</a>
<a href="update-server-pda.html">Обновить PDA</a>
</div>
<h1>Просмотр PDA</h1>
<p class="subtitle">Читает user_pda или server PDA по логину либо по адресу и показывает все поля</p>
<div class="card">
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
</div>
</div>
<div class="card">
<h2>Источник PDA</h2>
<div class="field">
<label>Логин или адрес PDA</label>
<input type="text" id="ref" placeholder="Логин сервера / пользователя или base58-адрес PDA" />
<div class="hint">Если введён base58-адрес длиной 32 байта, он читается напрямую. Иначе значение трактуется как логин.</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="btnLoad">Загрузить PDA</button>
</div>
<div class="status" id="status"></div>
</div>
<div class="card" id="summaryCard" style="display:none">
<h2>Сводка</h2>
<div class="summary-grid" id="summaryRows"></div>
<div class="badge-line" style="margin-top:12px" id="badgeLine"></div>
</div>
<div class="card" id="blocksCard" style="display:none">
<h2>Блоки и поля</h2>
<div class="block-list" id="blocksList"></div>
</div>
<div class="card" id="rawCard" style="display:none">
<h2>Raw JSON</h2>
<pre class="mono-box" id="rawJson"></pre>
</div>
</div>
<script type="module" src="./js/read-pda-page.js"></script>
</body>
</html>

View File

@ -46,6 +46,7 @@
<div class="nav-links">
<a href="../server-ui.html">← Назад</a>
<a href="create-server-pda.html">Создать PDA</a>
<a href="read-pda.html">Просмотреть PDA</a>
</div>
<h1>Обновление PDA сервера</h1>