added envediting to server panel
This commit is contained in:
parent
12b8b52ced
commit
b6217d5191
2 changed files with 308 additions and 8 deletions
|
|
@ -1,2 +1,3 @@
|
||||||
textual>=0.60
|
textual>=0.60
|
||||||
psutil>=5.9
|
psutil>=5.9
|
||||||
|
cryptography>=41.0
|
||||||
|
|
|
||||||
313
server-panel.py
313
server-panel.py
|
|
@ -19,10 +19,12 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
@ -30,9 +32,9 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat,
|
||||||
from textual import work
|
from textual import work
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import DataTable, Footer, Header, Label, OptionList, ProgressBar, RichLog, Static
|
from textual.widgets import Button, Collapsible, DataTable, Footer, Header, Input, Label, OptionList, ProgressBar, RichLog, Static
|
||||||
from textual.widgets.option_list import Option
|
from textual.widgets.option_list import Option
|
||||||
|
|
||||||
IS_WINDOWS = platform.system() == "Windows"
|
IS_WINDOWS = platform.system() == "Windows"
|
||||||
|
|
@ -47,6 +49,7 @@ DEPLOY_DIR = ROOT / "deploy"
|
||||||
BIN_DIR = ROOT / "bin"
|
BIN_DIR = ROOT / "bin"
|
||||||
LOG_DIR = ROOT / "logs"
|
LOG_DIR = ROOT / "logs"
|
||||||
ENV_FILE = ROOT / ".env"
|
ENV_FILE = ROOT / ".env"
|
||||||
|
ENV_EXAMPLE_FILE = ROOT / ".env.example"
|
||||||
COMPOSE_FILE = DEPLOY_DIR / "docker-compose.yml"
|
COMPOSE_FILE = DEPLOY_DIR / "docker-compose.yml"
|
||||||
STATE_FILE = ROOT / ".server_panel.json"
|
STATE_FILE = ROOT / ".server_panel.json"
|
||||||
|
|
||||||
|
|
@ -316,6 +319,154 @@ def read_env_value(key: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- .env.example parsing / editing ------------------------------------
|
||||||
|
|
||||||
|
_ACTIVE_FIELD_RE = re.compile(r"^(TELESRV_[A-Z0-9_]+)=(.*)$")
|
||||||
|
_COMMENTED_FIELD_RE = re.compile(r"^#\s*(TELESRV_[A-Z0-9_]+)=(.*)$")
|
||||||
|
_SENSITIVE_KEY_RE = re.compile(r"(PASSWORD|SECRET|_TOKEN|API_KEY)")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnvField:
|
||||||
|
key: str
|
||||||
|
default_value: str
|
||||||
|
description: str
|
||||||
|
enabled_by_default: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sensitive(self) -> bool:
|
||||||
|
return bool(_SENSITIVE_KEY_RE.search(self.key))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnvGroup:
|
||||||
|
title: str
|
||||||
|
fields: list[EnvField] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_env_template() -> list[EnvGroup]:
|
||||||
|
"""Parses .env.example into groups of fields for the editor. Grouping
|
||||||
|
follows the file's own blank-line-separated blocks; a group's title is
|
||||||
|
the first sentence of whichever field in it has a comment (falling back
|
||||||
|
to its first key when none of them do, e.g. the bare Postgres/Redis DSN
|
||||||
|
pair that has no comment of its own)."""
|
||||||
|
if not ENV_EXAMPLE_FILE.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
groups: list[EnvGroup] = []
|
||||||
|
current_fields: list[EnvField] = []
|
||||||
|
pending: list[str] = []
|
||||||
|
in_comment_run = False
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
|
||||||
|
def flush_group() -> None:
|
||||||
|
nonlocal current_fields
|
||||||
|
if current_fields:
|
||||||
|
described = next((f.description for f in current_fields if f.description), "")
|
||||||
|
if described:
|
||||||
|
title = described.split(". ", 1)[0].strip().rstrip(".")
|
||||||
|
else:
|
||||||
|
title = current_fields[0].key
|
||||||
|
groups.append(EnvGroup(title=title[:70], fields=current_fields))
|
||||||
|
current_fields = []
|
||||||
|
|
||||||
|
for raw_line in ENV_EXAMPLE_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
|
||||||
|
if not stripped:
|
||||||
|
flush_group()
|
||||||
|
pending = []
|
||||||
|
in_comment_run = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
active = _ACTIVE_FIELD_RE.match(stripped)
|
||||||
|
if active:
|
||||||
|
# TELESRV_AI_PROVIDERS legitimately appears twice in the file: once
|
||||||
|
# as the real setting, once as an inline "if you want Kimi" example
|
||||||
|
# inside an unrelated comment block further down. A second Input
|
||||||
|
# for the same env var would just be confusing, so the first
|
||||||
|
# occurrence wins and later repeats fold into descriptive text.
|
||||||
|
if active.group(1) not in seen_keys:
|
||||||
|
seen_keys.add(active.group(1))
|
||||||
|
current_fields.append(EnvField(
|
||||||
|
key=active.group(1), default_value=active.group(2),
|
||||||
|
description=" ".join(pending), enabled_by_default=True,
|
||||||
|
))
|
||||||
|
in_comment_run = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
commented = _COMMENTED_FIELD_RE.match(stripped)
|
||||||
|
if commented and commented.group(1) not in seen_keys:
|
||||||
|
seen_keys.add(commented.group(1))
|
||||||
|
current_fields.append(EnvField(
|
||||||
|
key=commented.group(1), default_value=commented.group(2),
|
||||||
|
description=" ".join(pending), enabled_by_default=False,
|
||||||
|
))
|
||||||
|
in_comment_run = False
|
||||||
|
continue
|
||||||
|
text = stripped.lstrip("#").strip()
|
||||||
|
if in_comment_run:
|
||||||
|
pending.append(text)
|
||||||
|
else:
|
||||||
|
pending = [text]
|
||||||
|
in_comment_run = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Any other line (shouldn't normally happen in this file) just ends
|
||||||
|
# whatever comment run was in progress without touching fields.
|
||||||
|
in_comment_run = False
|
||||||
|
|
||||||
|
flush_group()
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def current_env_values(groups: list[EnvGroup]) -> dict[str, str]:
|
||||||
|
"""Current value for every known field: from .env if it's set there,
|
||||||
|
otherwise the template's default -- EXCEPT for a field that's commented
|
||||||
|
out (disabled) in the template, whose "default_value" is only the
|
||||||
|
example shown in the comment (e.g. "openai_responses"), not something
|
||||||
|
that should silently start populated and get activated on save. Those
|
||||||
|
start blank; the example still shows as the input's placeholder."""
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for group in groups:
|
||||||
|
for f in group.fields:
|
||||||
|
existing = read_env_value(f.key)
|
||||||
|
if existing is not None:
|
||||||
|
values[f.key] = existing
|
||||||
|
elif f.enabled_by_default:
|
||||||
|
values[f.key] = f.default_value
|
||||||
|
else:
|
||||||
|
values[f.key] = ""
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def save_env(values: dict[str, str]) -> None:
|
||||||
|
"""(Re)writes .env from .env.example's exact text, substituting each
|
||||||
|
known field's value in place -- this is what preserves every comment and
|
||||||
|
the file's layout untouched. A template-commented optional field is
|
||||||
|
uncommented when given a non-empty value, and left as-is (commented,
|
||||||
|
disabled) when left empty."""
|
||||||
|
out_lines = []
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
for raw_line in ENV_EXAMPLE_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
active = _ACTIVE_FIELD_RE.match(stripped)
|
||||||
|
if active and active.group(1) in values and active.group(1) not in seen_keys:
|
||||||
|
seen_keys.add(active.group(1))
|
||||||
|
out_lines.append(f"{active.group(1)}={values[active.group(1)]}")
|
||||||
|
continue
|
||||||
|
commented = _COMMENTED_FIELD_RE.match(stripped)
|
||||||
|
if commented and commented.group(1) in values and commented.group(1) not in seen_keys:
|
||||||
|
seen_keys.add(commented.group(1))
|
||||||
|
key = commented.group(1)
|
||||||
|
value = values[key]
|
||||||
|
out_lines.append(f"{key}={value}" if value else raw_line)
|
||||||
|
continue
|
||||||
|
out_lines.append(raw_line)
|
||||||
|
ENV_FILE.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def admin_ui_info() -> tuple[str, str | None] | None:
|
def admin_ui_info() -> tuple[str, str | None] | None:
|
||||||
"""Returns (url, password) for the admin UI, or None if it isn't
|
"""Returns (url, password) for the admin UI, or None if it isn't
|
||||||
configured at all. password is None when TELESRV_ADMIN_UI_PASSWORD is
|
configured at all. password is None when TELESRV_ADMIN_UI_PASSWORD is
|
||||||
|
|
@ -409,6 +560,15 @@ class CopyButton(Static):
|
||||||
self._on_copy_callback = on_copy_callback
|
self._on_copy_callback = on_copy_callback
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
self._update_display()
|
||||||
|
|
||||||
|
def _update_display(self) -> None:
|
||||||
|
# NOTE: deliberately not named `_render` -- that name collides with
|
||||||
|
# Widget's own internal `_render()` (returns a Visual, used by
|
||||||
|
# get_content_height during layout); shadowing it with a method that
|
||||||
|
# returns None broke height calculation as soon as anything forced a
|
||||||
|
# reflow (e.g. pushing another screen), with a `'NoneType' object has
|
||||||
|
# no attribute 'get_height'` crash.
|
||||||
if self.value is None:
|
if self.value is None:
|
||||||
self.update(f"{self.label}: [dim]not available[/]")
|
self.update(f"{self.label}: [dim]not available[/]")
|
||||||
elif self.masked:
|
elif self.masked:
|
||||||
|
|
@ -416,6 +576,12 @@ class CopyButton(Static):
|
||||||
else:
|
else:
|
||||||
self.update(f"{self.label}: [dim](click to copy)[/]")
|
self.update(f"{self.label}: [dim](click to copy)[/]")
|
||||||
|
|
||||||
|
def set_value(self, value: str | None) -> None:
|
||||||
|
"""Updates the underlying value (e.g. after the env editor changes
|
||||||
|
it) without ever having displayed the old one on screen."""
|
||||||
|
self.value = value
|
||||||
|
self._update_display()
|
||||||
|
|
||||||
def on_click(self, event) -> None:
|
def on_click(self, event) -> None:
|
||||||
if self.value is not None:
|
if self.value is not None:
|
||||||
self._on_copy_callback(self)
|
self._on_copy_callback(self)
|
||||||
|
|
@ -516,12 +682,98 @@ class LogPickerScreen(Screen):
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
|
||||||
|
class EnvEditorScreen(Screen):
|
||||||
|
"""Every field .env.example knows about, grouped and described, with an
|
||||||
|
Input per field. If .env doesn't exist yet, it's copied verbatim from
|
||||||
|
.env.example first (so editing starts from the same defaults a fresh
|
||||||
|
`cp .env.example .env` would have given you). Save rewrites .env from
|
||||||
|
.env.example's exact text with just the values swapped in, so every
|
||||||
|
comment and the file's layout survive untouched; Back discards unsaved
|
||||||
|
edits."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Back"),
|
||||||
|
Binding("ctrl+s", "save", "Save"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._created_env = False
|
||||||
|
if not ENV_FILE.exists() and ENV_EXAMPLE_FILE.exists():
|
||||||
|
shutil.copy(ENV_EXAMPLE_FILE, ENV_FILE)
|
||||||
|
self._created_env = True
|
||||||
|
self._groups: list[EnvGroup] = parse_env_template()
|
||||||
|
self._values: dict[str, str] = current_env_values(self._groups)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Static("Environment configuration (.env)", id="env-title")
|
||||||
|
if not self._groups:
|
||||||
|
yield Static(
|
||||||
|
"[b red]No .env.example found -- nothing to configure.[/]",
|
||||||
|
id="env-missing",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with VerticalScroll(id="env-scroll"):
|
||||||
|
for i, group in enumerate(self._groups):
|
||||||
|
with Collapsible(title=f"{group.title} ({len(group.fields)})", id=f"env-group-{i}"):
|
||||||
|
for f in group.fields:
|
||||||
|
with Vertical(classes="env-field"):
|
||||||
|
badge = "" if f.enabled_by_default else " [dim i](optional, currently disabled)[/]"
|
||||||
|
yield Static(f"[b]{f.key}[/]{badge}", classes="env-field-key")
|
||||||
|
if f.description:
|
||||||
|
yield Static(f.description, classes="env-field-desc")
|
||||||
|
yield Input(
|
||||||
|
value=self._values.get(f.key, ""),
|
||||||
|
placeholder=f.default_value if not f.enabled_by_default else "",
|
||||||
|
password=f.sensitive,
|
||||||
|
id=self._input_id(f.key),
|
||||||
|
)
|
||||||
|
with Horizontal(id="env-actions"):
|
||||||
|
yield Button("Save", id="env-save-button", variant="success")
|
||||||
|
yield Button("Back", id="env-back-button", variant="default")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _input_id(key: str) -> str:
|
||||||
|
return f"env-{key}"
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
if self._created_env:
|
||||||
|
self.notify("Created .env from .env.example")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "env-save-button":
|
||||||
|
self.action_save()
|
||||||
|
elif event.button.id == "env-back-button":
|
||||||
|
self.action_back()
|
||||||
|
|
||||||
|
def action_save(self) -> None:
|
||||||
|
if not self._groups:
|
||||||
|
return
|
||||||
|
values = dict(self._values)
|
||||||
|
for group in self._groups:
|
||||||
|
for f in group.fields:
|
||||||
|
values[f.key] = self.query_one(f"#{self._input_id(f.key)}", Input).value
|
||||||
|
try:
|
||||||
|
save_env(values)
|
||||||
|
except OSError as exc:
|
||||||
|
self.notify(f"Failed to save .env: {exc}", severity="error", timeout=10)
|
||||||
|
return
|
||||||
|
self._values = values
|
||||||
|
self.notify(".env saved")
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
|
||||||
class MainScreen(Screen):
|
class MainScreen(Screen):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("1", "start", "Start"),
|
Binding("1", "start", "Start"),
|
||||||
Binding("2", "stop", "Stop"),
|
Binding("2", "stop", "Stop"),
|
||||||
Binding("3", "restart", "Restart"),
|
Binding("3", "restart", "Restart"),
|
||||||
Binding("4", "logs", "Logs"),
|
Binding("4", "logs", "Logs"),
|
||||||
|
Binding("5", "env", "Configure .env"),
|
||||||
Binding("q", "quit_panel", "Exit"),
|
Binding("q", "quit_panel", "Exit"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -551,16 +803,15 @@ class MainScreen(Screen):
|
||||||
Option("Stop server", id="stop"),
|
Option("Stop server", id="stop"),
|
||||||
Option("Restart server", id="restart"),
|
Option("Restart server", id="restart"),
|
||||||
Option("View logs", id="logs"),
|
Option("View logs", id="logs"),
|
||||||
|
Option("Configure .env", id="env"),
|
||||||
Option("Exit panel (server keeps running)", id="exit"),
|
Option("Exit panel (server keeps running)", id="exit"),
|
||||||
)
|
)
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def _admin_password_widget(self) -> Static:
|
def _admin_password_widget(self) -> CopyButton:
|
||||||
info = admin_ui_info()
|
info = admin_ui_info()
|
||||||
password = info[1] if info else None
|
password = info[1] if info else None
|
||||||
if password:
|
|
||||||
return CopyButton("Password", password, self._handle_copy_click, masked=True, id="admin-password")
|
return CopyButton("Password", password, self._handle_copy_click, masked=True, id="admin-password")
|
||||||
return Static("Password: [dim]not set (token auth)[/]", id="admin-password")
|
|
||||||
|
|
||||||
def _handle_copy_click(self, widget: CopyButton) -> None:
|
def _handle_copy_click(self, widget: CopyButton) -> None:
|
||||||
if copy_to_clipboard(widget.value):
|
if copy_to_clipboard(widget.value):
|
||||||
|
|
@ -572,9 +823,10 @@ class MainScreen(Screen):
|
||||||
self.query_one("#services-table", DataTable).add_columns("Service", "Type", "Status")
|
self.query_one("#services-table", DataTable).add_columns("Service", "Type", "Status")
|
||||||
self.refresh_status()
|
self.refresh_status()
|
||||||
self.refresh_stats()
|
self.refresh_stats()
|
||||||
self.refresh_admin_link()
|
self.refresh_config_widgets()
|
||||||
self.set_interval(3, self.refresh_status)
|
self.set_interval(3, self.refresh_status)
|
||||||
self.set_interval(2, self.refresh_stats)
|
self.set_interval(2, self.refresh_stats)
|
||||||
|
self.set_interval(3, self.refresh_config_widgets)
|
||||||
|
|
||||||
def refresh_status(self) -> None:
|
def refresh_status(self) -> None:
|
||||||
status = MANAGER.status()
|
status = MANAGER.status()
|
||||||
|
|
@ -616,6 +868,16 @@ class MainScreen(Screen):
|
||||||
else:
|
else:
|
||||||
widget.update("[dim]not configured[/]")
|
widget.update("[dim]not configured[/]")
|
||||||
|
|
||||||
|
def refresh_config_widgets(self) -> None:
|
||||||
|
"""Re-reads .env-derived values into the already-mounted widgets --
|
||||||
|
matters after coming back from the env editor, since e.g. the admin
|
||||||
|
password or the server's advertise address may have just changed."""
|
||||||
|
self.refresh_admin_link()
|
||||||
|
info = admin_ui_info()
|
||||||
|
self.query_one("#admin-password", CopyButton).set_value(info[1] if info else None)
|
||||||
|
self.query_one("#server-address", CopyButton).set_value(server_address())
|
||||||
|
self.query_one("#server-public-key", CopyButton).set_value(server_public_key_pem())
|
||||||
|
|
||||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||||
option_id = event.option.id
|
option_id = event.option.id
|
||||||
if option_id == "start":
|
if option_id == "start":
|
||||||
|
|
@ -626,12 +888,19 @@ class MainScreen(Screen):
|
||||||
self.action_restart()
|
self.action_restart()
|
||||||
elif option_id == "logs":
|
elif option_id == "logs":
|
||||||
self.action_logs()
|
self.action_logs()
|
||||||
|
elif option_id == "env":
|
||||||
|
self.action_env()
|
||||||
elif option_id == "exit":
|
elif option_id == "exit":
|
||||||
self.action_quit_panel()
|
self.action_quit_panel()
|
||||||
|
|
||||||
def action_logs(self) -> None:
|
def action_logs(self) -> None:
|
||||||
self.app.push_screen(LogPickerScreen())
|
self.app.push_screen(LogPickerScreen())
|
||||||
|
|
||||||
|
def action_env(self) -> None:
|
||||||
|
# Refresh once the editor screen is popped -- the admin password,
|
||||||
|
# server address, etc. it just edited are all shown right here too.
|
||||||
|
self.app.push_screen(EnvEditorScreen(), lambda _: self.refresh_config_widgets())
|
||||||
|
|
||||||
def action_quit_panel(self) -> None:
|
def action_quit_panel(self) -> None:
|
||||||
self.app.exit()
|
self.app.exit()
|
||||||
|
|
||||||
|
|
@ -816,7 +1085,37 @@ class ServerPanelApp(App):
|
||||||
}
|
}
|
||||||
#admin-password, #server-address, #server-public-key {
|
#admin-password, #server-address, #server-public-key {
|
||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
#env-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $accent;
|
||||||
|
padding: 1 2 0 2;
|
||||||
|
}
|
||||||
|
#env-missing {
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
#env-scroll {
|
||||||
|
height: 1fr;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
.env-field {
|
||||||
height: auto;
|
height: auto;
|
||||||
|
margin: 1 0 0 2;
|
||||||
|
}
|
||||||
|
.env-field-key {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
.env-field-desc {
|
||||||
|
color: $text-muted;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
#env-actions {
|
||||||
|
height: auto;
|
||||||
|
padding: 1 2;
|
||||||
|
align: right middle;
|
||||||
|
}
|
||||||
|
#env-actions Button {
|
||||||
|
margin-left: 1;
|
||||||
}
|
}
|
||||||
LogTailScreen Horizontal {
|
LogTailScreen Horizontal {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue