mirror of
https://github.com/curly60e/pyblock.git
synced 2026-08-16 13:00:52 +02:00
Merge pull request #729 from GaltRanch/frontend/ux-improvements
This commit is contained in:
commit
cb2f4bee16
5 changed files with 299 additions and 62 deletions
|
|
@ -2,6 +2,7 @@
|
|||
#Tester: __B__T__C__
|
||||
#ℙ𝕪𝔹𝕃𝕆ℂ𝕂 𝕚𝕥𝕤 𝕒 𝔹𝕚𝕥𝕔𝕠𝕚𝕟 𝔻𝕒𝕤𝕙𝕓𝕠𝕒𝕣𝕕 𝕨𝕚𝕥𝕙 ℂ𝕪𝕡𝕙𝕖𝕣𝕡𝕦𝕟𝕜 𝕒𝕖𝕤𝕥𝕙𝕖𝕥𝕚𝕔.
|
||||
|
||||
import codecs
|
||||
import os
|
||||
import os.path
|
||||
import time as t
|
||||
|
|
@ -887,6 +888,8 @@ def artist(): # here we convert the result of the command 'getblockcount' on a r
|
|||
clear()
|
||||
close()
|
||||
design()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Loop interrupted: %s", e)
|
||||
break
|
||||
|
|
@ -1824,6 +1827,20 @@ def MainMenu(mode): #Unified Main Menu - mode: "local", "onchain_only", or "remo
|
|||
sysinfo()
|
||||
pathexec()
|
||||
|
||||
# Validate bitcoincli path before attempting to use it
|
||||
if mode in ("local", "onchain_only") and not path.get('bitcoincli'):
|
||||
show_error("Bitcoin CLI path not configured. Redirecting to Lite Mode.")
|
||||
t.sleep(2)
|
||||
from SPV.spvblock import MainMenuCROPPED as _lite_menu
|
||||
_lite_menu()
|
||||
return
|
||||
if mode == "remote" and not lndconnectload.get('tls'):
|
||||
show_error("Remote node not configured. Redirecting to Lite Mode.")
|
||||
t.sleep(2)
|
||||
from SPV.spvblock import MainMenuCROPPED as _lite_menu
|
||||
_lite_menu()
|
||||
return
|
||||
|
||||
# Fetch BTC price for status bar
|
||||
try:
|
||||
_price_r = requests.get("https://mempool.space/api/v1/prices", timeout=3)
|
||||
|
|
@ -5137,7 +5154,8 @@ def menuSelection():
|
|||
path = pathv # Copy the variable pathv to 'path'
|
||||
MainMenuLOCAL()
|
||||
elif chln == "C":
|
||||
MainMenuCROPPED()
|
||||
from SPV.spvblock import MainMenuCROPPED as _lite_menu
|
||||
_lite_menu()
|
||||
else:
|
||||
if os.path.isfile('config/blndconnect.conf'):
|
||||
chln['offchain'] = "offchain"
|
||||
|
|
@ -7405,17 +7423,9 @@ def main():
|
|||
set_terminal_background()
|
||||
menuSelection()
|
||||
except KeyboardInterrupt:
|
||||
print("\n")
|
||||
sys.exit(0)
|
||||
except PermissionError as e:
|
||||
if str(e).endswith("''"):
|
||||
show_error("Bitcoin CLI path is not configured. Please set it up.")
|
||||
logger.error("Empty bitcoincli path in bclock.conf")
|
||||
introINIT()
|
||||
else:
|
||||
logger.error("Fatal error: %s", e)
|
||||
sys.exit(101)
|
||||
continue # Ctrl+C returns to main menu
|
||||
except Exception as e:
|
||||
show_error(str(e))
|
||||
logger.error("Fatal error: %s", e)
|
||||
sys.exit(101)
|
||||
|
||||
|
|
@ -7427,10 +7437,16 @@ if __name__ == "__main__":
|
|||
if cfg.has_config('intro.conf'):
|
||||
with open("config/intro.conf", "r") as f:
|
||||
init_data = json.load(f)
|
||||
if init_data.get("fullbtclnd"):
|
||||
mode = "local"
|
||||
elif init_data.get("fullbtc"):
|
||||
mode = "onchain_only"
|
||||
if isinstance(init_data, str):
|
||||
if init_data == "A":
|
||||
mode = "local"
|
||||
elif init_data == "B":
|
||||
mode = "onchain_only"
|
||||
elif isinstance(init_data, dict):
|
||||
if init_data.get("fullbtclnd"):
|
||||
mode = "local"
|
||||
elif init_data.get("fullbtc"):
|
||||
mode = "onchain_only"
|
||||
run_tui(mode=mode)
|
||||
else:
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ PYBLOCK_THEME = Theme({
|
|||
"pyblock.block": "bold white",
|
||||
})
|
||||
|
||||
console = Console(theme=PYBLOCK_THEME)
|
||||
console = Console(theme=PYBLOCK_THEME, highlight=False, color_system="truecolor")
|
||||
|
||||
|
||||
def rich_status_bar(mode="", block_height="", btc_price="", extra=""):
|
||||
|
|
@ -80,68 +80,47 @@ def rich_status_bar(mode="", block_height="", btc_price="", extra=""):
|
|||
combined.append_text(separator)
|
||||
combined.append_text(part)
|
||||
|
||||
console.print(Panel(combined, style="pyblock.dim", expand=False, padding=(0, 2)))
|
||||
console.print(Panel(combined, expand=False, style="on default", border_style="dim", padding=(0, 2)))
|
||||
|
||||
|
||||
def rich_sysinfo(cpu_percent, mem_percent):
|
||||
"""Render CPU and Memory as a compact Rich panel."""
|
||||
"""Render CPU and Memory with colored bars in a panel."""
|
||||
cpu_color = "green" if cpu_percent < 70 else ("yellow" if cpu_percent < 90 else "red")
|
||||
mem_color = "green" if mem_percent < 70 else ("yellow" if mem_percent < 90 else "red")
|
||||
|
||||
cpu_bar = _make_bar(cpu_percent, cpu_color)
|
||||
mem_bar = _make_bar(mem_percent, mem_color)
|
||||
|
||||
table = Table(show_header=False, box=None, padding=(0, 1))
|
||||
table.add_column(width=10)
|
||||
table.add_column(width=22)
|
||||
table.add_column(width=5, justify="right")
|
||||
table.add_row(
|
||||
Text("CPU", style="italic yellow"),
|
||||
Text.from_markup(cpu_bar),
|
||||
Text(f"{cpu_percent}%", style=f"bold {cpu_color}"),
|
||||
text = Text.from_markup(
|
||||
f"[italic yellow]CPU[/] {cpu_bar} [bold {cpu_color}]{cpu_percent}%[/]\n"
|
||||
f"[italic yellow]Memory[/] {mem_bar} [bold {mem_color}]{mem_percent}%[/]"
|
||||
)
|
||||
table.add_row(
|
||||
Text("Memory", style="italic yellow"),
|
||||
Text.from_markup(mem_bar),
|
||||
Text(f"{mem_percent}%", style=f"bold {mem_color}"),
|
||||
)
|
||||
console.print(table)
|
||||
console.print(Panel(text, expand=False, style="on default", border_style="dim"))
|
||||
|
||||
|
||||
def _make_bar(percent, color):
|
||||
"""Create a simple text-based progress bar."""
|
||||
filled = int(percent / 5)
|
||||
empty = 20 - filled
|
||||
return f"[{color}]{'█' * filled}[/{color}][dim]{'░' * empty}[/dim]"
|
||||
return f"[{color}]{'█' * filled}[/{color}][rgb(60,60,60)]{'─' * empty}[/rgb(60,60,60)]"
|
||||
|
||||
|
||||
def rich_menu(title, items, footer_text=""):
|
||||
"""Render a styled menu table.
|
||||
"""Render a styled menu in a panel.
|
||||
|
||||
Args:
|
||||
title: Menu section title
|
||||
items: List of (key, label, style) tuples
|
||||
footer_text: Optional text below the menu
|
||||
"""
|
||||
table = Table(
|
||||
show_header=False,
|
||||
box=None,
|
||||
padding=(0, 1),
|
||||
pad_edge=False,
|
||||
)
|
||||
table.add_column("Key", width=6, justify="right")
|
||||
table.add_column("Label")
|
||||
|
||||
lines = []
|
||||
for key, label, style in items:
|
||||
table.add_row(
|
||||
Text(f"{key}.", style=f"bold {style}"),
|
||||
Text(label, style="white"),
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
lines.append(f"[bold {style}]{key}.[/] {label}")
|
||||
if footer_text:
|
||||
console.print(f" [pyblock.dim]{footer_text}[/pyblock.dim]")
|
||||
lines.append(f"\n[dim]{footer_text}[/dim]")
|
||||
|
||||
content = Text.from_markup("\n".join(lines))
|
||||
console.print(Panel(content, expand=False, style="on default", border_style="dim", padding=(1, 2)))
|
||||
console.print()
|
||||
|
||||
|
||||
|
|
@ -191,7 +170,7 @@ def rich_header(node_type, block_height, version, alias=None):
|
|||
info.append("Version: ", style="bold white")
|
||||
info.append(f"{version}", style="dim")
|
||||
|
||||
console.print(Panel(info, style="pyblock.dim", expand=False, padding=(0, 2)))
|
||||
console.print(Panel(info, expand=False, style="on default", border_style="dim", padding=(0, 2)))
|
||||
|
||||
|
||||
def rich_loading(label="Loading"):
|
||||
|
|
|
|||
|
|
@ -6,14 +6,19 @@ Or from PyBlock.py with: --tui flag
|
|||
"""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Footer, Static
|
||||
from textual.widgets import Footer, Static, RichLog
|
||||
from textual.containers import Vertical
|
||||
from textual.timer import Timer
|
||||
from textual.binding import Binding
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from tui.widgets.status_bar import StatusBar
|
||||
from tui.screens.main_menu import MainMenuScreen
|
||||
from tui.workers.data_fetcher import fetch_block_height, fetch_btc_price, fetch_fees
|
||||
from tui.widgets.main_menu import MainMenu
|
||||
from tui.workers.data_fetcher import (
|
||||
fetch_block_height, fetch_btc_price, fetch_fees,
|
||||
fetch_mempool_info, fetch_latest_blocks, fetch_hashrate,
|
||||
)
|
||||
|
||||
|
||||
CSS = """
|
||||
|
|
@ -30,6 +35,7 @@ Screen {
|
|||
#content {
|
||||
height: 1fr;
|
||||
padding: 1 2;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#fees-panel {
|
||||
|
|
@ -55,19 +61,27 @@ class PyBlockApp(App):
|
|||
CSS = CSS
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
|
||||
Binding("m", "show_menu", "Menu", show=True),
|
||||
Binding("a", "select('pyblock')", "Dashboard", show=True),
|
||||
Binding("b", "select('bitcoin')", "Bitcoin", show=True),
|
||||
Binding("l", "select('lightning')", "Lightning", show=True),
|
||||
Binding("p", "select('platforms')", "Platforms", show=True),
|
||||
Binding("s", "select('settings')", "Settings", show=True),
|
||||
Binding("q", "quit", "Quit", show=True),
|
||||
Binding("ctrl+r", "refresh_data", "Refresh", show=True),
|
||||
]
|
||||
|
||||
def __init__(self, mode="lite"):
|
||||
super().__init__()
|
||||
self.mode = mode
|
||||
self._update_timer = None
|
||||
self._block = "---"
|
||||
self._price = "---"
|
||||
self._fees = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield StatusBar(id="status-bar")
|
||||
yield Vertical(
|
||||
MainMenuScreen(mode=self.mode),
|
||||
Static(id="section-view"),
|
||||
id="content",
|
||||
)
|
||||
yield self._build_fees_panel()
|
||||
|
|
@ -82,7 +96,8 @@ class PyBlockApp(App):
|
|||
|
||||
def on_mount(self):
|
||||
self.query_one(StatusBar).mode = self.mode
|
||||
self._update_timer = self.set_interval(30, self._do_refresh)
|
||||
self.action_show_menu()
|
||||
self.set_interval(30, self._do_refresh)
|
||||
self._do_refresh()
|
||||
|
||||
def _do_refresh(self):
|
||||
|
|
@ -92,9 +107,13 @@ class PyBlockApp(App):
|
|||
block = fetch_block_height()
|
||||
price = fetch_btc_price()
|
||||
fees = fetch_fees()
|
||||
self.call_from_thread(self._update_ui, block, price, fees)
|
||||
self.call_from_thread(self._update_status, block, price, fees)
|
||||
|
||||
def _update_status(self, block, price, fees):
|
||||
self._block = block
|
||||
self._price = price
|
||||
self._fees = fees
|
||||
|
||||
def _update_ui(self, block, price, fees):
|
||||
status = self.query_one(StatusBar)
|
||||
status.block_height = block
|
||||
status.btc_price = price
|
||||
|
|
@ -107,6 +126,144 @@ class PyBlockApp(App):
|
|||
f"[dim]Slow:[/dim] {fees.get('hourFee', '?')}\n"
|
||||
)
|
||||
|
||||
def _set_content(self, renderable):
|
||||
"""Replace the content area with new renderable."""
|
||||
try:
|
||||
view = self.query_one("#section-view", Static)
|
||||
view.update(renderable)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Actions ---
|
||||
|
||||
def action_show_menu(self):
|
||||
menu = MainMenu(mode=self.mode)
|
||||
# Build the menu renderable
|
||||
self._set_content(menu._build_menu())
|
||||
|
||||
def action_select(self, section):
|
||||
if section == "pyblock":
|
||||
self._load_dashboard()
|
||||
elif section == "bitcoin":
|
||||
self._load_bitcoin()
|
||||
elif section == "lightning":
|
||||
self._load_lightning()
|
||||
elif section == "platforms":
|
||||
self._load_platforms()
|
||||
elif section == "settings":
|
||||
self._load_settings()
|
||||
|
||||
def _load_dashboard(self):
|
||||
"""Show dashboard with block, price, mempool summary."""
|
||||
self.notify("Loading dashboard...", timeout=1)
|
||||
self.run_worker(self._fetch_dashboard, thread=True)
|
||||
|
||||
def _fetch_dashboard(self):
|
||||
mempool = fetch_mempool_info()
|
||||
blocks = fetch_latest_blocks()
|
||||
hashrate = fetch_hashrate()
|
||||
self.call_from_thread(self._render_dashboard_sync, mempool, blocks, hashrate)
|
||||
|
||||
def _render_dashboard_sync(self, mempool, blocks, hashrate):
|
||||
"""Build dashboard as a Rich Group and update the section view."""
|
||||
from rich.console import Group
|
||||
|
||||
summary = Table(show_header=False, box=None, padding=(0, 2))
|
||||
summary.add_column("Key", style="bold yellow", width=18)
|
||||
summary.add_column("Value", style="bold white")
|
||||
summary.add_row("Block Height", str(self._block))
|
||||
summary.add_row("BTC Price", f"${self._price}")
|
||||
summary.add_row("Hashrate", f"{hashrate['hashrate_eh']} EH/s")
|
||||
summary.add_row("Difficulty", hashrate["difficulty"])
|
||||
summary.add_row("Mempool Txs", f"{mempool.get('count', '?'):,}" if isinstance(mempool.get('count'), int) else str(mempool.get('count', '?')))
|
||||
summary.add_row("Mempool Size", f"{mempool.get('vsize', 0) / 1_000_000:.1f} MvB" if isinstance(mempool.get('vsize'), (int, float)) else "?")
|
||||
|
||||
blocks_table = Table(title="Latest Blocks", expand=False, padding=(0, 1))
|
||||
blocks_table.add_column("Height", style="bold green", width=10)
|
||||
blocks_table.add_column("Txs", style="white", width=8, justify="right")
|
||||
blocks_table.add_column("Size (MB)", style="cyan", width=10, justify="right")
|
||||
blocks_table.add_column("Pool", style="yellow", width=16)
|
||||
for b in blocks:
|
||||
blocks_table.add_row(str(b["height"]), str(b["tx_count"]), str(b["size"]), b["pool"])
|
||||
|
||||
dashboard = Group(
|
||||
Panel(summary, title="[bold red]PyBLOCK Dashboard[/bold red]", expand=False, padding=(1, 2)),
|
||||
"",
|
||||
Panel(blocks_table, expand=False, padding=(0, 1)),
|
||||
)
|
||||
self._set_content(dashboard)
|
||||
|
||||
def _load_bitcoin(self):
|
||||
"""Show Bitcoin info panel."""
|
||||
table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
table.add_column("Key", width=4, justify="right")
|
||||
table.add_column("Label")
|
||||
items = [
|
||||
("A", "Blockchain Info", "bold rgb(255,102,0)"),
|
||||
("C", "Mempool Monitor", "bold rgb(255,102,0)"),
|
||||
("D", "Latest Blocks", "bold rgb(255,102,0)"),
|
||||
("E", "Fee Estimates", "bold rgb(255,102,0)"),
|
||||
("H", "Hashrate & Difficulty", "bold rgb(255,102,0)"),
|
||||
]
|
||||
for key, label, style in items:
|
||||
table.add_row(Text(f"{key}.", style=style), Text(label, style="white"))
|
||||
|
||||
panel = Panel(table, title="[bold rgb(255,102,0)]Bitcoin[/bold rgb(255,102,0)]",
|
||||
subtitle="[dim]Press M for main menu[/dim]", expand=False, padding=(1, 2))
|
||||
self._set_content(panel)
|
||||
self.notify("Bitcoin section - submenu navigation coming soon", timeout=2)
|
||||
|
||||
def _load_lightning(self):
|
||||
"""Show Lightning info panel."""
|
||||
panel = Panel(
|
||||
"[bold yellow]Lightning Network[/bold yellow]\n\n"
|
||||
"Connect your Lightning node to access:\n\n"
|
||||
" [yellow]1.[/yellow] Channel Management\n"
|
||||
" [yellow]2.[/yellow] Create/Pay Invoices\n"
|
||||
" [yellow]3.[/yellow] Keysend Payments\n"
|
||||
" [yellow]4.[/yellow] Node Info & Peers\n"
|
||||
" [yellow]5.[/yellow] Rebalance Channels\n\n"
|
||||
f"[dim]Mode: {self.mode} | Press M for main menu[/dim]",
|
||||
title="[bold yellow]Lightning[/bold yellow]",
|
||||
expand=False,
|
||||
padding=(1, 2),
|
||||
)
|
||||
self._set_content(panel)
|
||||
|
||||
def _load_platforms(self):
|
||||
"""Show Platforms panel."""
|
||||
panel = Panel(
|
||||
"[bold green]Platforms & APIs[/bold green]\n\n"
|
||||
" [green]1.[/green] LNBits\n"
|
||||
" [green]2.[/green] OpenNode\n"
|
||||
" [green]3.[/green] TallyCoin\n"
|
||||
" [green]4.[/green] CoinGecko Price\n"
|
||||
" [green]5.[/green] Weather (wttr.in)\n"
|
||||
" [green]6.[/green] Rate.sx Charts\n\n"
|
||||
"[dim]Press M for main menu[/dim]",
|
||||
title="[bold green]Platforms[/bold green]",
|
||||
expand=False,
|
||||
padding=(1, 2),
|
||||
)
|
||||
self._set_content(panel)
|
||||
|
||||
def _load_settings(self):
|
||||
"""Show Settings panel."""
|
||||
panel = Panel(
|
||||
"[bold blue]Settings[/bold blue]\n\n"
|
||||
f" [blue]Mode:[/blue] {self.mode}\n"
|
||||
f" [blue]Block:[/blue] {self._block}\n"
|
||||
f" [blue]Refresh:[/blue] 30s auto\n\n"
|
||||
" [dim]Logo colors, fonts, and node\n"
|
||||
" configuration available in\n"
|
||||
" classic mode (without --tui)[/dim]\n\n"
|
||||
"[dim]Press M for main menu[/dim]",
|
||||
title="[bold blue]Settings[/bold blue]",
|
||||
expand=False,
|
||||
padding=(1, 2),
|
||||
)
|
||||
self._set_content(panel)
|
||||
|
||||
def action_refresh_data(self):
|
||||
self._do_refresh()
|
||||
self.notify("Refreshing data...", timeout=1)
|
||||
|
|
|
|||
54
pybitblock/tui/widgets/main_menu.py
Normal file
54
pybitblock/tui/widgets/main_menu.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Main menu widget for PyBLOCK TUI."""
|
||||
|
||||
from textual.widgets import Static
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
class MainMenu(Static):
|
||||
"""The primary navigation menu rendered as a widget."""
|
||||
|
||||
def __init__(self, mode="lite", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.mode = mode
|
||||
|
||||
def on_mount(self):
|
||||
self.update(self._build_menu())
|
||||
|
||||
def _build_menu(self):
|
||||
table = Table(
|
||||
show_header=False,
|
||||
box=None,
|
||||
padding=(0, 2),
|
||||
expand=False,
|
||||
)
|
||||
table.add_column("Key", width=4, justify="right")
|
||||
table.add_column("Label", width=30)
|
||||
|
||||
items = [
|
||||
("A", "PyBLOCK Dashboard", "bold red"),
|
||||
("B", "Bitcoin", "bold rgb(255,102,0)"),
|
||||
]
|
||||
if self.mode != "onchain_only":
|
||||
items.append(("L", "Lightning Network", "bold yellow"))
|
||||
items.extend([
|
||||
("P", "Platforms & APIs", "bold rgb(0,200,0)"),
|
||||
("S", "Settings", "bold blue"),
|
||||
("X", "Donate", "bold white"),
|
||||
("Q", "Exit", "bold rgb(128,0,255)"),
|
||||
])
|
||||
|
||||
for key, label, style in items:
|
||||
table.add_row(
|
||||
Text(f"{key}.", style=style),
|
||||
Text(label, style="white"),
|
||||
)
|
||||
|
||||
return Panel(
|
||||
table,
|
||||
title="[bold red]PyBLOCK[/bold red]",
|
||||
subtitle="[dim]Navigate with keyboard[/dim]",
|
||||
expand=False,
|
||||
padding=(1, 2),
|
||||
)
|
||||
|
|
@ -43,3 +43,34 @@ def fetch_mempool_info():
|
|||
}
|
||||
except Exception:
|
||||
return {"count": "?", "vsize": "?", "total_fee": "?"}
|
||||
|
||||
|
||||
def fetch_latest_blocks():
|
||||
"""Fetch latest 5 blocks from mempool.space."""
|
||||
try:
|
||||
r = requests.get("https://mempool.space/api/v1/blocks", timeout=5)
|
||||
blocks = r.json()[:5]
|
||||
return [
|
||||
{
|
||||
"height": b.get("height", "?"),
|
||||
"tx_count": b.get("tx_count", "?"),
|
||||
"size": round(b.get("size", 0) / 1_000_000, 2),
|
||||
"pool": b.get("extras", {}).get("pool", {}).get("name", "Unknown"),
|
||||
}
|
||||
for b in blocks
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def fetch_hashrate():
|
||||
"""Fetch network hashrate info."""
|
||||
try:
|
||||
r = requests.get("https://mempool.space/api/v1/mining/hashrate/3d", timeout=5)
|
||||
data = r.json()
|
||||
current = data.get("currentHashrate", 0)
|
||||
difficulty = data.get("currentDifficulty", 0)
|
||||
eh = current / 1e18
|
||||
return {"hashrate_eh": f"{eh:.1f}", "difficulty": f"{difficulty:.2e}"}
|
||||
except Exception:
|
||||
return {"hashrate_eh": "?", "difficulty": "?"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue