Initial commit
This commit is contained in:
893
app/static/app.js
Normal file
893
app/static/app.js
Normal file
@@ -0,0 +1,893 @@
|
||||
const CHART_WINDOWS = {
|
||||
"5m": 5 * 60 * 1000,
|
||||
"30m": 30 * 60 * 1000,
|
||||
"2h": 2 * 60 * 60 * 1000,
|
||||
"6h": 6 * 60 * 60 * 1000,
|
||||
all: null,
|
||||
};
|
||||
const chartWindowSetting = window.localStorage.getItem("chartWindow");
|
||||
const state = {
|
||||
settings: null,
|
||||
commands: [],
|
||||
charts: {},
|
||||
chartWindow: chartWindowSetting in CHART_WINDOWS ? chartWindowSetting : "30m",
|
||||
history: {
|
||||
timestamps: [],
|
||||
blocks: [],
|
||||
headers: [],
|
||||
mempool: [],
|
||||
peers: [],
|
||||
},
|
||||
pollTimer: null,
|
||||
};
|
||||
|
||||
const numberFmt = new Intl.NumberFormat();
|
||||
const MAX_HISTORY_POINTS = 20000;
|
||||
const HISTORY_CACHE_KEY = "tellscoin_history_cache_v1";
|
||||
const GAP_BREAK_MS = 10 * 60 * 1000;
|
||||
|
||||
const el = {
|
||||
loginView: document.getElementById("login-view"),
|
||||
appView: document.getElementById("app-view"),
|
||||
loginForm: document.getElementById("login-form"),
|
||||
loginUser: document.getElementById("login-username"),
|
||||
loginPass: document.getElementById("login-password"),
|
||||
loginError: document.getElementById("login-error"),
|
||||
logoutBtn: document.getElementById("logout-btn"),
|
||||
refreshBtn: document.getElementById("refresh-btn"),
|
||||
settingsBtn: document.getElementById("settings-btn"),
|
||||
settingsModal: document.getElementById("settings-modal"),
|
||||
settingsClose: document.getElementById("settings-close"),
|
||||
settingsForm: document.getElementById("settings-form"),
|
||||
settingsError: document.getElementById("settings-error"),
|
||||
liveStatus: document.getElementById("live-status"),
|
||||
syncTrack: document.getElementById("sync-track"),
|
||||
syncFill: document.getElementById("sync-fill"),
|
||||
syncPercent: document.getElementById("sync-percent"),
|
||||
syncSummary: document.getElementById("sync-summary"),
|
||||
syncDetails: document.getElementById("sync-details"),
|
||||
commandList: document.getElementById("command-list"),
|
||||
commandSearch: document.getElementById("command-search"),
|
||||
commandCount: document.getElementById("command-count"),
|
||||
rpcForm: document.getElementById("rpc-form"),
|
||||
rpcMethod: document.getElementById("rpc-method"),
|
||||
rpcParams: document.getElementById("rpc-params"),
|
||||
rpcWallet: document.getElementById("rpc-wallet"),
|
||||
rpcHelpBtn: document.getElementById("rpc-help-btn"),
|
||||
rpcError: document.getElementById("rpc-error"),
|
||||
rpcOutput: document.getElementById("rpc-output"),
|
||||
actionStop: document.getElementById("action-stop"),
|
||||
actionStart: document.getElementById("action-start"),
|
||||
actionRestart: document.getElementById("action-restart"),
|
||||
chartWindow: document.getElementById("chart-window"),
|
||||
chartHistoryInfo: document.getElementById("chart-history-info"),
|
||||
};
|
||||
|
||||
function showToast(message, isError = false) {
|
||||
const node = document.createElement("div");
|
||||
node.textContent = message;
|
||||
node.style.position = "fixed";
|
||||
node.style.right = "1rem";
|
||||
node.style.bottom = "1rem";
|
||||
node.style.padding = "0.7rem 0.9rem";
|
||||
node.style.maxWidth = "340px";
|
||||
node.style.borderRadius = "12px";
|
||||
node.style.fontSize = "0.86rem";
|
||||
node.style.fontWeight = "600";
|
||||
node.style.background = isError ? "#9f2525" : "#005c53";
|
||||
node.style.color = "#fff";
|
||||
node.style.zIndex = "30";
|
||||
node.style.boxShadow = "0 14px 30px rgba(0,0,0,0.2)";
|
||||
document.body.appendChild(node);
|
||||
window.setTimeout(() => node.remove(), 3200);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return "-";
|
||||
}
|
||||
return numberFmt.format(Number(value));
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return "-";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const sec = Number(seconds);
|
||||
if (!Number.isFinite(sec)) {
|
||||
return "-";
|
||||
}
|
||||
const days = Math.floor(sec / 86400);
|
||||
const hours = Math.floor((sec % 86400) / 3600);
|
||||
const mins = Math.floor((sec % 3600) / 60);
|
||||
return `${days}d ${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
function formatChartTick(valueMs) {
|
||||
const ts = Number(valueMs);
|
||||
if (!Number.isFinite(ts)) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(ts);
|
||||
if (state.chartWindow === "all" || state.chartWindow === "6h") {
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getWindowMs(windowKey) {
|
||||
if (Object.prototype.hasOwnProperty.call(CHART_WINDOWS, windowKey)) {
|
||||
return CHART_WINDOWS[windowKey];
|
||||
}
|
||||
return CHART_WINDOWS["30m"];
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const opts = {
|
||||
method: options.method || "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
||||
};
|
||||
|
||||
const response = await fetch(path, opts);
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
moveToLogin();
|
||||
throw new Error("Session expired, please sign in again.");
|
||||
}
|
||||
const detail = payload && payload.detail ? payload.detail : `HTTP ${response.status}`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function moveToLogin() {
|
||||
el.appView.classList.add("hidden");
|
||||
el.loginView.classList.remove("hidden");
|
||||
if (state.pollTimer) {
|
||||
window.clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function moveToApp() {
|
||||
el.loginView.classList.add("hidden");
|
||||
el.appView.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function setStat(id, text) {
|
||||
const node = document.getElementById(id);
|
||||
if (node) {
|
||||
node.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSyncPanel(chain) {
|
||||
if (!el.syncFill || !el.syncPercent || !el.syncSummary || !el.syncDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawProgress = Number(chain.verificationprogress);
|
||||
const hasProgress = Number.isFinite(rawProgress);
|
||||
const clampedPercent = hasProgress ? Math.max(0, Math.min(rawProgress * 100, 100)) : 0;
|
||||
const blocks = Number(chain.blocks);
|
||||
const headers = Number(chain.headers);
|
||||
const blockGap = Number.isFinite(blocks) && Number.isFinite(headers) ? Math.max(headers - blocks, 0) : 0;
|
||||
const inIbd = Boolean(chain.initialblockdownload);
|
||||
|
||||
el.syncFill.style.width = `${clampedPercent.toFixed(3)}%`;
|
||||
el.syncPercent.textContent = hasProgress ? `${clampedPercent.toFixed(2)}%` : "-";
|
||||
if (el.syncTrack) {
|
||||
el.syncTrack.setAttribute("aria-valuenow", clampedPercent.toFixed(3));
|
||||
}
|
||||
|
||||
if (!hasProgress) {
|
||||
el.syncSummary.textContent = "Sync progress unavailable.";
|
||||
} else if (inIbd || clampedPercent < 99.999) {
|
||||
el.syncSummary.textContent = `Syncing: ${formatNumber(blocks)} / ${formatNumber(headers)} blocks`;
|
||||
} else {
|
||||
el.syncSummary.textContent = "Node reports fully synced.";
|
||||
}
|
||||
|
||||
const details = [];
|
||||
details.push(`${formatNumber(blockGap)} headers behind`);
|
||||
details.push(`IBD: ${inIbd ? "yes" : "no"}`);
|
||||
|
||||
const tipTime = Number(chain.mediantime);
|
||||
if (Number.isFinite(tipTime)) {
|
||||
details.push(`Tip time: ${new Date(tipTime * 1000).toLocaleString()}`);
|
||||
}
|
||||
|
||||
el.syncDetails.textContent = details.join(" | ");
|
||||
}
|
||||
|
||||
function resetHistory() {
|
||||
state.history.timestamps = [];
|
||||
state.history.blocks = [];
|
||||
state.history.headers = [];
|
||||
state.history.mempool = [];
|
||||
state.history.peers = [];
|
||||
}
|
||||
|
||||
function getHistoryPoints() {
|
||||
const points = [];
|
||||
const length = state.history.timestamps.length;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
points.push({
|
||||
ts: Math.floor(state.history.timestamps[index] / 1000),
|
||||
blocks: state.history.blocks[index],
|
||||
headers: state.history.headers[index],
|
||||
mempool_bytes: state.history.mempool[index],
|
||||
peers: state.history.peers[index],
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function saveHistoryCache() {
|
||||
try {
|
||||
const points = getHistoryPoints();
|
||||
const tail = points.slice(Math.max(0, points.length - 12000));
|
||||
window.localStorage.setItem(HISTORY_CACHE_KEY, JSON.stringify(tail));
|
||||
} catch {
|
||||
// best-effort cache only
|
||||
}
|
||||
}
|
||||
|
||||
function loadHistoryCache() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HISTORY_CACHE_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setHistoryInfo(message) {
|
||||
if (el.chartHistoryInfo) {
|
||||
el.chartHistoryInfo.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function describeHistory(points, source) {
|
||||
if (!points.length) {
|
||||
return `History status: no points (${source}).`;
|
||||
}
|
||||
|
||||
const firstTs = Number(points[0].ts);
|
||||
const lastTs = Number(points[points.length - 1].ts);
|
||||
const firstLabel = Number.isFinite(firstTs) ? new Date(firstTs * 1000).toLocaleString() : "-";
|
||||
const lastLabel = Number.isFinite(lastTs) ? new Date(lastTs * 1000).toLocaleString() : "-";
|
||||
const parts = [`History status: ${points.length} points (${source}) from ${firstLabel} to ${lastLabel} (browser local time).`];
|
||||
|
||||
const windowMs = getWindowMs(state.chartWindow);
|
||||
if (windowMs !== null && Number.isFinite(firstTs)) {
|
||||
const expectedStartMs = Date.now() - windowMs;
|
||||
if (firstTs * 1000 > expectedStartMs + 60_000) {
|
||||
parts.push("No samples exist for earlier part of selected window.");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function filterPointsForWindow(points, windowKey) {
|
||||
const windowMs = getWindowMs(windowKey);
|
||||
if (windowMs === null) {
|
||||
return points;
|
||||
}
|
||||
const threshold = Date.now() - windowMs;
|
||||
return points.filter((point) => Number(point.ts) * 1000 >= threshold);
|
||||
}
|
||||
|
||||
function setHistoryFromPoints(points) {
|
||||
resetHistory();
|
||||
const sortedPoints = [...points].sort((a, b) => Number(a.ts) - Number(b.ts));
|
||||
sortedPoints.forEach((point) => {
|
||||
state.history.timestamps.push(Number(point.ts) * 1000);
|
||||
state.history.blocks.push(Number(point.blocks || 0));
|
||||
state.history.headers.push(Number(point.headers || 0));
|
||||
state.history.mempool.push(Number(point.mempool_bytes || 0));
|
||||
state.history.peers.push(Number(point.peers || 0));
|
||||
});
|
||||
saveHistoryCache();
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", state.chartWindow === "all" ? "20000" : "8000");
|
||||
params.set("_", String(Date.now()));
|
||||
const payload = await api(`/api/dashboard/history/${encodeURIComponent(state.chartWindow)}?${params.toString()}`);
|
||||
const points = Array.isArray(payload.points) ? payload.points : [];
|
||||
if (points.length > 0) {
|
||||
setHistoryFromPoints(points);
|
||||
setHistoryInfo(describeHistory(points, "server"));
|
||||
refreshCharts();
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedPoints = filterPointsForWindow(loadHistoryCache(), state.chartWindow);
|
||||
setHistoryFromPoints(cachedPoints);
|
||||
setHistoryInfo(describeHistory(cachedPoints, "browser cache"));
|
||||
refreshCharts();
|
||||
}
|
||||
|
||||
function updateHistory(summary) {
|
||||
const timestamp = Number(summary.updated_at || Math.floor(Date.now() / 1000)) * 1000;
|
||||
const chain = summary.blockchain || {};
|
||||
const net = summary.network || {};
|
||||
const mem = summary.mempool || {};
|
||||
const blocks = Number(chain.blocks || 0);
|
||||
const headers = Number(chain.headers || 0);
|
||||
const mempoolBytes = Number(mem.bytes || 0);
|
||||
const peers = Number(net.connections || 0);
|
||||
|
||||
const lastIndex = state.history.timestamps.length - 1;
|
||||
if (lastIndex >= 0 && state.history.timestamps[lastIndex] === timestamp) {
|
||||
state.history.blocks[lastIndex] = blocks;
|
||||
state.history.headers[lastIndex] = headers;
|
||||
state.history.mempool[lastIndex] = mempoolBytes;
|
||||
state.history.peers[lastIndex] = peers;
|
||||
return;
|
||||
}
|
||||
|
||||
state.history.timestamps.push(timestamp);
|
||||
state.history.blocks.push(blocks);
|
||||
state.history.headers.push(headers);
|
||||
state.history.mempool.push(mempoolBytes);
|
||||
state.history.peers.push(peers);
|
||||
|
||||
if (state.history.timestamps.length > MAX_HISTORY_POINTS) {
|
||||
Object.keys(state.history).forEach((key) => {
|
||||
state.history[key].shift();
|
||||
});
|
||||
}
|
||||
saveHistoryCache();
|
||||
}
|
||||
|
||||
function getChartSlice() {
|
||||
const timestamps = state.history.timestamps;
|
||||
if (!timestamps.length) {
|
||||
return {
|
||||
timestamps: [],
|
||||
blocks: [],
|
||||
headers: [],
|
||||
mempool: [],
|
||||
peers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const windowMs = getWindowMs(state.chartWindow);
|
||||
let startIndex = 0;
|
||||
if (windowMs !== null) {
|
||||
const threshold = Date.now() - windowMs;
|
||||
while (startIndex < timestamps.length && timestamps[startIndex] < threshold) {
|
||||
startIndex += 1;
|
||||
}
|
||||
if (startIndex >= timestamps.length) {
|
||||
return {
|
||||
timestamps: [],
|
||||
blocks: [],
|
||||
headers: [],
|
||||
mempool: [],
|
||||
peers: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timestamps: timestamps.slice(startIndex),
|
||||
blocks: state.history.blocks.slice(startIndex),
|
||||
headers: state.history.headers.slice(startIndex),
|
||||
mempool: state.history.mempool.slice(startIndex),
|
||||
peers: state.history.peers.slice(startIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function createChart(nodeId, datasets, yScale = {}) {
|
||||
const context = document.getElementById(nodeId);
|
||||
return new Chart(context, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: [],
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
animation: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
parsing: false,
|
||||
normalized: true,
|
||||
spanGaps: false,
|
||||
scales: {
|
||||
x: {
|
||||
type: "linear",
|
||||
ticks: {
|
||||
maxTicksLimit: 6,
|
||||
callback: (value) => formatChartTick(value),
|
||||
},
|
||||
},
|
||||
y: yScale,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ensureCharts() {
|
||||
if (state.charts.blocks) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.charts.blocks = createChart("chart-blocks", [
|
||||
{
|
||||
label: "Blocks",
|
||||
data: [],
|
||||
borderColor: "#005c53",
|
||||
backgroundColor: "rgba(0,92,83,0.16)",
|
||||
borderWidth: 2,
|
||||
tension: 0,
|
||||
pointRadius: 1.2,
|
||||
pointHoverRadius: 3,
|
||||
},
|
||||
{
|
||||
label: "Headers",
|
||||
data: [],
|
||||
borderColor: "#cf6f1b",
|
||||
backgroundColor: "rgba(207,111,27,0.14)",
|
||||
borderWidth: 2,
|
||||
tension: 0,
|
||||
pointRadius: 1.2,
|
||||
pointHoverRadius: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
state.charts.mempool = createChart("chart-mempool", [
|
||||
{
|
||||
label: "Bytes",
|
||||
data: [],
|
||||
borderColor: "#234b9b",
|
||||
backgroundColor: "rgba(35,75,155,0.14)",
|
||||
borderWidth: 2,
|
||||
tension: 0,
|
||||
pointRadius: 1.2,
|
||||
pointHoverRadius: 3,
|
||||
},
|
||||
], {
|
||||
min: 0,
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value) => formatBytes(value),
|
||||
},
|
||||
});
|
||||
|
||||
state.charts.peers = createChart("chart-peers", [
|
||||
{
|
||||
label: "Connections",
|
||||
data: [],
|
||||
borderColor: "#8f3c23",
|
||||
backgroundColor: "rgba(143,60,35,0.14)",
|
||||
borderWidth: 2,
|
||||
tension: 0,
|
||||
pointRadius: 1.2,
|
||||
pointHoverRadius: 3,
|
||||
},
|
||||
], {
|
||||
min: 0,
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
stepSize: 1,
|
||||
callback: (value) => Math.round(Number(value)).toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function refreshCharts() {
|
||||
ensureCharts();
|
||||
const slice = getChartSlice();
|
||||
const toSeries = (values) => {
|
||||
const points = [];
|
||||
for (let index = 0; index < slice.timestamps.length; index += 1) {
|
||||
const ts = slice.timestamps[index];
|
||||
const y = values[index];
|
||||
if (index > 0) {
|
||||
const prevTs = slice.timestamps[index - 1];
|
||||
if (ts - prevTs > GAP_BREAK_MS) {
|
||||
points.push({ x: prevTs + 1, y: null });
|
||||
points.push({ x: ts - 1, y: null });
|
||||
}
|
||||
}
|
||||
points.push({ x: ts, y });
|
||||
}
|
||||
return points;
|
||||
};
|
||||
const windowMs = getWindowMs(state.chartWindow);
|
||||
const now = Date.now();
|
||||
let xMin = null;
|
||||
let xMax = null;
|
||||
|
||||
if (windowMs === null) {
|
||||
if (slice.timestamps.length > 0) {
|
||||
xMin = slice.timestamps[0];
|
||||
xMax = slice.timestamps[slice.timestamps.length - 1];
|
||||
}
|
||||
} else {
|
||||
xMin = now - windowMs;
|
||||
xMax = now;
|
||||
}
|
||||
|
||||
state.charts.blocks.data.labels = [];
|
||||
state.charts.blocks.data.datasets[0].data = toSeries(slice.blocks);
|
||||
state.charts.blocks.data.datasets[1].data = toSeries(slice.headers);
|
||||
|
||||
state.charts.mempool.data.labels = [];
|
||||
state.charts.mempool.data.datasets[0].data = toSeries(slice.mempool);
|
||||
|
||||
state.charts.peers.data.labels = [];
|
||||
state.charts.peers.data.datasets[0].data = toSeries(slice.peers);
|
||||
|
||||
Object.values(state.charts).forEach((chart) => {
|
||||
chart.options.scales.x.min = xMin;
|
||||
chart.options.scales.x.max = xMax;
|
||||
});
|
||||
|
||||
Object.values(state.charts).forEach((chart) => chart.update("none"));
|
||||
}
|
||||
|
||||
async function refreshSummary(showSuccess = false) {
|
||||
try {
|
||||
const summary = await api("/api/dashboard/summary");
|
||||
const chain = summary.blockchain || {};
|
||||
const net = summary.network || {};
|
||||
const mem = summary.mempool || {};
|
||||
|
||||
setStat("stat-chain", chain.chain || "-");
|
||||
setStat("stat-blocks", formatNumber(chain.blocks));
|
||||
setStat("stat-headers", formatNumber(chain.headers));
|
||||
setStat("stat-peers", formatNumber(net.connections));
|
||||
setStat("stat-mempool-tx", formatNumber(mem.size));
|
||||
setStat("stat-mempool-size", formatBytes(mem.bytes));
|
||||
setStat("stat-difficulty", formatNumber(chain.difficulty));
|
||||
setStat("stat-uptime", formatUptime(summary.uptime));
|
||||
updateSyncPanel(chain);
|
||||
|
||||
updateHistory(summary);
|
||||
refreshCharts();
|
||||
setHistoryInfo(describeHistory(getHistoryPoints(), "live"));
|
||||
|
||||
const now = new Date().toLocaleTimeString();
|
||||
el.liveStatus.textContent = `Connected - updated ${now}`;
|
||||
if (showSuccess) {
|
||||
showToast("Dashboard refreshed");
|
||||
}
|
||||
} catch (error) {
|
||||
el.liveStatus.textContent = `Connection issue: ${error.message}`;
|
||||
if (showSuccess) {
|
||||
showToast(error.message, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function populateSettingsForm(settings) {
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
const input = el.settingsForm.elements.namedItem(key);
|
||||
if (input) {
|
||||
input.value = value ?? "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
state.settings = await api("/api/settings");
|
||||
populateSettingsForm(state.settings);
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
el.settingsError.textContent = "";
|
||||
el.settingsModal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
el.settingsModal.classList.add("hidden");
|
||||
}
|
||||
|
||||
function renderCommandList() {
|
||||
const filter = (el.commandSearch.value || "").trim().toLowerCase();
|
||||
const filtered = state.commands.filter((item) => item.method.includes(filter));
|
||||
el.commandList.innerHTML = "";
|
||||
|
||||
filtered.forEach((item) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "command-item";
|
||||
button.type = "button";
|
||||
|
||||
const method = document.createElement("span");
|
||||
method.className = "command-method";
|
||||
method.textContent = item.method;
|
||||
|
||||
const synopsis = document.createElement("span");
|
||||
synopsis.className = "command-synopsis";
|
||||
synopsis.textContent = item.synopsis;
|
||||
|
||||
button.append(method, synopsis);
|
||||
button.addEventListener("click", () => {
|
||||
el.rpcMethod.value = item.method;
|
||||
el.rpcError.textContent = "";
|
||||
Array.from(el.commandList.children).forEach((child) => child.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
});
|
||||
el.commandList.appendChild(button);
|
||||
});
|
||||
|
||||
el.commandCount.textContent = `${filtered.length} shown / ${state.commands.length} total`;
|
||||
}
|
||||
|
||||
async function loadCommandCatalog() {
|
||||
const payload = await api("/api/rpc/commands");
|
||||
state.commands = payload.commands || [];
|
||||
renderCommandList();
|
||||
}
|
||||
|
||||
function parseParams(text) {
|
||||
const trimmed = (text || "").trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error("Params must be valid JSON.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Params must be a JSON array, e.g. [] or [\"value\", 1].");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function writeOutput(label, data) {
|
||||
const timestamp = new Date().toLocaleString();
|
||||
el.rpcOutput.textContent = `${label} @ ${timestamp}\n\n${JSON.stringify(data, null, 2)}`;
|
||||
}
|
||||
|
||||
async function executeRpc(method, params, wallet) {
|
||||
const result = await api("/api/rpc/call", {
|
||||
method: "POST",
|
||||
body: {
|
||||
method,
|
||||
params,
|
||||
wallet: wallet || null,
|
||||
},
|
||||
});
|
||||
writeOutput(`RPC ${method}`, result.result);
|
||||
}
|
||||
|
||||
async function handleNodeAction(action, button) {
|
||||
const confirmations = {
|
||||
stop: "Stop bitcoind via RPC?",
|
||||
restart: "Restart bitcoind now?",
|
||||
};
|
||||
|
||||
if (confirmations[action] && !window.confirm(confirmations[action])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = "Working...";
|
||||
|
||||
try {
|
||||
const result = await api(`/api/actions/${action}`, { method: "POST" });
|
||||
writeOutput(`Action ${action}`, result);
|
||||
showToast(`Action ${action} completed`);
|
||||
window.setTimeout(() => refreshSummary(false), 1500);
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootDashboard() {
|
||||
moveToApp();
|
||||
if (el.chartWindow) {
|
||||
el.chartWindow.value = state.chartWindow;
|
||||
}
|
||||
await loadSettings();
|
||||
await loadCommandCatalog();
|
||||
await loadHistory();
|
||||
await refreshSummary(false);
|
||||
|
||||
if (state.pollTimer) {
|
||||
window.clearInterval(state.pollTimer);
|
||||
}
|
||||
state.pollTimer = window.setInterval(() => {
|
||||
refreshSummary(false);
|
||||
}, 15000);
|
||||
|
||||
if (!state.settings.rpc_username || !state.settings.rpc_password) {
|
||||
openSettings();
|
||||
showToast("Configure node settings to connect.", true);
|
||||
}
|
||||
}
|
||||
|
||||
el.loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
el.loginError.textContent = "";
|
||||
|
||||
try {
|
||||
await api("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: el.loginUser.value,
|
||||
password: el.loginPass.value,
|
||||
},
|
||||
});
|
||||
await bootDashboard();
|
||||
} catch (error) {
|
||||
el.loginError.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
el.logoutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
// ignore; local state still transitions to login
|
||||
}
|
||||
moveToLogin();
|
||||
});
|
||||
|
||||
el.refreshBtn.addEventListener("click", async () => {
|
||||
await refreshSummary(true);
|
||||
});
|
||||
|
||||
el.settingsBtn.addEventListener("click", openSettings);
|
||||
el.settingsClose.addEventListener("click", closeSettings);
|
||||
el.settingsModal.addEventListener("click", (event) => {
|
||||
if (event.target === el.settingsModal) {
|
||||
closeSettings();
|
||||
}
|
||||
});
|
||||
|
||||
el.settingsForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
el.settingsError.textContent = "";
|
||||
|
||||
const body = Object.fromEntries(new FormData(el.settingsForm).entries());
|
||||
body.ssh_port = Number(body.ssh_port || 22);
|
||||
|
||||
try {
|
||||
state.settings = await api("/api/settings", {
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
populateSettingsForm(state.settings);
|
||||
closeSettings();
|
||||
showToast("Settings saved");
|
||||
await refreshSummary(false);
|
||||
await loadCommandCatalog();
|
||||
} catch (error) {
|
||||
el.settingsError.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
el.commandSearch.addEventListener("input", renderCommandList);
|
||||
|
||||
el.rpcForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
el.rpcError.textContent = "";
|
||||
|
||||
try {
|
||||
const params = parseParams(el.rpcParams.value);
|
||||
await executeRpc(el.rpcMethod.value.trim(), params, el.rpcWallet.value.trim());
|
||||
} catch (error) {
|
||||
el.rpcError.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
el.rpcHelpBtn.addEventListener("click", async () => {
|
||||
el.rpcError.textContent = "";
|
||||
const method = el.rpcMethod.value.trim();
|
||||
if (!method) {
|
||||
el.rpcError.textContent = "Enter a method first.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api(`/api/rpc/help/${encodeURIComponent(method)}`);
|
||||
writeOutput(`Help ${method}`, result.help);
|
||||
} catch (error) {
|
||||
el.rpcError.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll(".quick-rpc").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const method = button.dataset.method;
|
||||
el.rpcMethod.value = method;
|
||||
el.rpcParams.value = "[]";
|
||||
el.rpcError.textContent = "";
|
||||
|
||||
try {
|
||||
await executeRpc(method, [], el.rpcWallet.value.trim());
|
||||
} catch (error) {
|
||||
el.rpcError.textContent = error.message;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
el.actionStop.addEventListener("click", () => handleNodeAction("stop", el.actionStop));
|
||||
el.actionStart.addEventListener("click", () => handleNodeAction("start", el.actionStart));
|
||||
el.actionRestart.addEventListener("click", () => handleNodeAction("restart", el.actionRestart));
|
||||
if (el.chartWindow) {
|
||||
el.chartWindow.addEventListener("change", async () => {
|
||||
state.chartWindow = el.chartWindow.value in CHART_WINDOWS ? el.chartWindow.value : "30m";
|
||||
window.localStorage.setItem("chartWindow", state.chartWindow);
|
||||
try {
|
||||
await loadHistory();
|
||||
} catch (error) {
|
||||
showToast(error.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("load", async () => {
|
||||
try {
|
||||
const auth = await api("/api/auth/me");
|
||||
if (auth.authenticated) {
|
||||
await bootDashboard();
|
||||
} else {
|
||||
moveToLogin();
|
||||
}
|
||||
} catch {
|
||||
moveToLogin();
|
||||
}
|
||||
});
|
||||
466
app/static/styles.css
Normal file
466
app/static/styles.css
Normal file
@@ -0,0 +1,466 @@
|
||||
:root {
|
||||
--bg: #f3efe8;
|
||||
--ink: #17120f;
|
||||
--surface: rgba(255, 255, 255, 0.78);
|
||||
--surface-solid: #fffaf2;
|
||||
--stroke: rgba(104, 74, 41, 0.24);
|
||||
--accent: #005c53;
|
||||
--accent-2: #cf6f1b;
|
||||
--danger: #9f2525;
|
||||
--muted: #665a50;
|
||||
--mono: "IBM Plex Mono", monospace;
|
||||
--sans: "Space Grotesk", sans-serif;
|
||||
--shadow: 0 16px 35px rgba(59, 41, 23, 0.14);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
background: radial-gradient(circle at 8% 14%, #ffe0af 0%, transparent 46%),
|
||||
radial-gradient(circle at 85% 20%, #c6f4e3 0%, transparent 42%),
|
||||
linear-gradient(160deg, #f8f4ed 0%, #f0e8dc 52%, #ece3d6 100%);
|
||||
}
|
||||
|
||||
.background-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
-32deg,
|
||||
rgba(145, 107, 77, 0.04),
|
||||
rgba(145, 107, 77, 0.04) 2px,
|
||||
transparent 2px,
|
||||
transparent 18px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1200px, 100% - 2rem);
|
||||
margin: 2rem auto 4rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
max-width: 420px;
|
||||
margin: 8vh auto;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.45rem, 1.2rem + 1.15vw, 2.1rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.74rem;
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
min-height: 1.2rem;
|
||||
margin: 0.7rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
color: #2f2822;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
border: 1px solid rgba(74, 56, 34, 0.26);
|
||||
background: var(--surface-solid);
|
||||
border-radius: 12px;
|
||||
padding: 0.62rem 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 90px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.88rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: #18312f;
|
||||
border-color: rgba(7, 70, 65, 0.37);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sync-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.sync-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.sync-percent {
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
color: #1f3f3b;
|
||||
}
|
||||
|
||||
.sync-track {
|
||||
margin-top: 0.6rem;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(74, 56, 34, 0.22);
|
||||
background: rgba(255, 250, 242, 0.8);
|
||||
}
|
||||
|
||||
.sync-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #005c53 0%, #cf6f1b 100%);
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
|
||||
.sync-summary {
|
||||
margin: 0.55rem 0 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--mono);
|
||||
font-size: clamp(1.1rem, 1rem + 0.8vw, 1.65rem);
|
||||
margin: 0.7rem 0 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chart-toolbar label {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.chart-history-info {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.chart-toolbar select {
|
||||
border: 1px solid rgba(74, 56, 34, 0.26);
|
||||
background: var(--surface-solid);
|
||||
border-radius: 12px;
|
||||
padding: 0.62rem 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-frame {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-frame canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-row.wrap button {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.explorer-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.explorer-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.explorer-grid {
|
||||
margin-top: 0.8rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 340px) minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.command-list-wrap {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.command-list {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(74, 56, 34, 0.2);
|
||||
background: var(--surface-solid);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(74, 56, 34, 0.12);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: #1a1713;
|
||||
border-radius: 0;
|
||||
padding: 0.52rem 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.command-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.command-item:hover,
|
||||
.command-item.active {
|
||||
background: rgba(0, 92, 83, 0.12);
|
||||
}
|
||||
|
||||
.command-method {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.command-synopsis {
|
||||
font-size: 0.74rem;
|
||||
color: #544941;
|
||||
}
|
||||
|
||||
.rpc-form {
|
||||
display: grid;
|
||||
gap: 0.72rem;
|
||||
}
|
||||
|
||||
#rpc-output {
|
||||
margin: 0.65rem 0 0;
|
||||
background: #110f0d;
|
||||
color: #d7f5dd;
|
||||
border-radius: 12px;
|
||||
padding: 0.88rem;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(21, 17, 13, 0.45);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 15;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
width: min(900px, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.cards-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-frame {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.explorer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.container {
|
||||
width: min(1200px, 100% - 1rem);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user