Сравнить коммиты
2
Коммитов
v0.12.7
...
a840e0c828
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
a840e0c828 | ||
|
|
c19d8db6e7 |
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||
|
||||
Текущая версия: **0.12.7** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
||||
Текущая версия: **0.12.8** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
||||
|
||||
Что умеет:
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
sudo apt install -y xorriso git curl python3-pip
|
||||
git clone https://git.stackworks.ru/viktor/virtuality.git
|
||||
cd virtuality
|
||||
make iso # dist/virtuality-0.12.7-ubuntu-26.04-amd64.iso
|
||||
make iso # dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
|
||||
```
|
||||
|
||||
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.12.7
|
||||
0.12.8
|
||||
|
||||
@@ -44,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
|
||||
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
|
||||
|
||||
```text
|
||||
dist/virtuality-0.12.7-ubuntu-26.04-amd64.iso
|
||||
dist/virtuality-0.12.7-ubuntu-26.04-amd64.iso.sha256
|
||||
dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
|
||||
dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
|
||||
```
|
||||
|
||||
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
|
||||
@@ -109,7 +109,7 @@ make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl p
|
||||
|
||||
## Что спрашивает установщик
|
||||
|
||||
Меню загрузки: **Установить Virtuality 0.12.7** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
||||
Меню загрузки: **Установить Virtuality 0.12.8** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
||||
|
||||
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
|
||||
|
||||
@@ -161,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
|
||||
Сверьте контрольную сумму скачанного или собранного образа:
|
||||
|
||||
```bash
|
||||
sha256sum -c virtuality-0.12.7-ubuntu-26.04-amd64.iso.sha256
|
||||
sha256sum -c virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
|
||||
```
|
||||
|
||||
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
||||
|
||||
```bash
|
||||
xorriso -indev virtuality-0.12.7-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
||||
xorriso -osirrox on -indev virtuality-0.12.7-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
||||
xorriso -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
||||
xorriso -osirrox on -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
||||
```
|
||||
|
||||
---
|
||||
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.12.7-ubuntu-26.04-amd64.iso -extract /au
|
||||
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
|
||||
|
||||
```bash
|
||||
sudo dd if=dist/virtuality-0.12.7-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
sudo dd if=dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
|
||||
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
||||
|
||||
Двоичные данные
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 203 KiB После Ширина: | Высота: | Размер: 266 KiB |
@@ -102,7 +102,7 @@ def test_dashboard_uses_plain_language(logged_in):
|
||||
def test_vm_create_preselects_image(logged_in, data_dirs):
|
||||
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
|
||||
html = logged_in.get("/vm/create?iso=debian.iso").text
|
||||
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" data-os="" selected' in html
|
||||
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" data-os="" data-login="" checked' in html
|
||||
|
||||
|
||||
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
|
||||
|
||||
@@ -188,5 +188,16 @@ def test_windows_iso_preselects_windows_type(logged_in, data_dirs):
|
||||
fake_iso(data_dirs["iso"] / "ubuntu.iso", "Ubuntu-Server 26.04 LTS amd64")
|
||||
assert app.iso_volume_label(data_dirs["iso"] / "ubuntu.iso") == "Ubuntu-Server 26.04 LTS amd64"
|
||||
html = logged_in.get("/vm/create?iso=19044.4046---Win10-IoT-LTSC.iso").text
|
||||
assert 'data-os="windows10" selected' in html and 'data-label="ubuntu.iso" data-os=""' in html
|
||||
assert 'data-os="windows10" data-login="" checked' in html and 'data-label="ubuntu.iso" data-os="" data-login="" >' in html
|
||||
assert 'value="windows10" checked' in html and 'value="linux" checked' not in html
|
||||
|
||||
|
||||
def test_images_are_offered_as_cards_with_system_size_and_date(logged_in, data_dirs):
|
||||
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
|
||||
fake_iso(data_dirs["iso"] / "ubuntu-24.04.1-live-server-amd64.iso", "Ubuntu-Server 24.04.1 LTS amd64")
|
||||
html = logged_in.get("/vm/create").text
|
||||
assert html.count('name="iso_path"') == 2 and '<span class="tag">Windows 10</span>' in html and '<span class="tag">Ubuntu</span>' in html
|
||||
assert "34 КБ · загружен" in html and "data-image-search" not in html # поиск только когда образов больше шести
|
||||
for number in range(5):
|
||||
fake_iso(data_dirs["iso"] / f"extra-{number}.iso", "")
|
||||
assert "data-image-search" in logged_in.get("/vm/create").text
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.12.8",
|
||||
"date": "2026-09-27",
|
||||
"title": "Удобный выбор образа при создании машины",
|
||||
"summary": "Установочные образы и готовые системы в форме создания машины теперь показаны карточками: какая система внутри, размер и когда образ загружен. Когда образов больше шести, появляется поиск по названию.",
|
||||
"features": [
|
||||
"Образы в форме создания машины показаны карточками вместо выпадающего списка",
|
||||
"На карточке видно систему (Windows 10, Windows 11, Ubuntu, Debian и другие), размер и дату загрузки",
|
||||
"Поиск по названию, когда образов больше шести",
|
||||
"Версия обновлена до 0.12.8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.12.7",
|
||||
"date": "2026-09-27",
|
||||
|
||||
+8
-2
@@ -577,7 +577,12 @@ def list_iso_files() -> list[dict[str, str]]:
|
||||
except OSError:
|
||||
size_mb = 0
|
||||
updated = "unknown"
|
||||
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated, "os_type": catalog.guess_os_type(iso_volume_label(item), item.name)})
|
||||
label = iso_volume_label(item)
|
||||
files.append({
|
||||
"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated,
|
||||
"os_type": catalog.guess_os_type(label, item.name), "system": catalog.iso_system_name(label, item.name),
|
||||
"size_label": presenters.format_bytes(stat.st_size) if size_mb else "", "date_label": presenters.short_date(stat.st_mtime) if size_mb else "",
|
||||
})
|
||||
return files
|
||||
|
||||
|
||||
@@ -635,7 +640,8 @@ def list_disk_image_files() -> list[dict[str, str]]:
|
||||
size_gb = 0
|
||||
updated = "unknown"
|
||||
entry = catalog.entry_for_filename(item.name)
|
||||
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else ""})
|
||||
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else "",
|
||||
"size_label": presenters.format_bytes(stat.st_size) if size_gb else "", "date_label": presenters.short_date(stat.st_mtime) if size_gb else ""})
|
||||
return files
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,25 @@ def guess_os_type(volume_label: str, filename: str) -> str:
|
||||
return "windows10" if WINDOWS10_RE.search(text) else "windows11"
|
||||
|
||||
|
||||
# Название системы для карточки образа в форме создания машины.
|
||||
DISTROS = (
|
||||
("UBUNTU", "Ubuntu"), ("DEBIAN", "Debian"), ("FEDORA", "Fedora"), ("ALPINE", "Alpine"), ("CENTOS", "CentOS"),
|
||||
("ROCKY", "Rocky Linux"), ("ALMALINUX", "AlmaLinux"), ("OPENSUSE", "openSUSE"), ("ARCHLINUX", "Arch Linux"),
|
||||
("LINUXMINT", "Linux Mint"), ("KALI", "Kali Linux"), ("ASTRA", "Astra Linux"), ("REDOS", "РЕД ОС"),
|
||||
("PROXMOX", "Proxmox"), ("TRUENAS", "TrueNAS"), ("FREEBSD", "FreeBSD"), ("PFSENSE", "pfSense"),
|
||||
("OPNSENSE", "OPNsense"), ("OPENWRT", "OpenWrt"),
|
||||
)
|
||||
|
||||
|
||||
def iso_system_name(volume_label: str, filename: str) -> str:
|
||||
"""«Windows 10», «Ubuntu»… или "", если систему по образу не узнать."""
|
||||
guess = guess_os_type(volume_label, filename)
|
||||
if guess:
|
||||
return "Windows 11" if guess == "windows11" else "Windows 10"
|
||||
words = set(re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper()).split())
|
||||
return next((title for key, title in DISTROS if key in words), "")
|
||||
|
||||
|
||||
def cloud_image(image_id: str) -> dict[str, Any] | None:
|
||||
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
|
||||
|
||||
|
||||
@@ -242,6 +242,17 @@ def port_service(port: int) -> str:
|
||||
return WELL_KNOWN_PORTS.get(int(port or 0), "")
|
||||
|
||||
|
||||
MONTHS_GENITIVE = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
|
||||
|
||||
|
||||
def short_date(timestamp: float, now: datetime | None = None) -> str:
|
||||
"""«27 сентября», для прошлых лет — «27 сентября 2025»."""
|
||||
moment = datetime.fromtimestamp(timestamp)
|
||||
now = now or datetime.now()
|
||||
text = f"{moment.day} {MONTHS_GENITIVE[moment.month - 1]}"
|
||||
return text if moment.year == now.year else f"{text} {moment.year}"
|
||||
|
||||
|
||||
def format_bytes(value: int | float) -> str:
|
||||
value = float(value or 0)
|
||||
for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"):
|
||||
|
||||
@@ -333,6 +333,25 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
.option input:checked + .option-body .option-check { border: 5px solid var(--brand); }
|
||||
.option-title { font-weight: 650; font-size: 14px; }
|
||||
.option-text { color: var(--text-2); font-size: 12.5px; }
|
||||
/* Выбор образа в форме создания машины */
|
||||
.image-picker { display: grid; gap: 10px; }
|
||||
.image-list { display: grid; gap: 8px; max-height: 360px; overflow-y: auto; padding: 2px; }
|
||||
.image-option { position: relative; display: block; cursor: pointer; }
|
||||
.image-option[hidden] { display: none; }
|
||||
.image-option input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.image-option-body { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s, background .15s; }
|
||||
.image-option:hover .image-option-body { border-color: var(--border-strong); }
|
||||
.image-option input:checked + .image-option-body { border-color: var(--brand); background: color-mix(in srgb, var(--brand-soft) 55%, var(--surface)); box-shadow: 0 0 0 4px var(--focus); }
|
||||
.image-option input:focus-visible + .image-option-body { box-shadow: 0 0 0 4px var(--focus); }
|
||||
.image-option input:checked + .image-option-body .option-check { border: 5px solid var(--brand); }
|
||||
.image-option .grow { flex: 1; display: grid; gap: 2px; min-width: 0; }
|
||||
.image-title { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-meta { color: var(--text-3); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@media (max-width: 560px) {
|
||||
.image-option .list-icon { display: none; }
|
||||
.image-title, .image-meta { white-space: normal; overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
/* Расписание копий: день недели и список машин видны, только когда нужны (без :has — видны всегда). */
|
||||
.schedule-form:has(input[name=frequency][value=daily]:checked) [data-when=weekly],
|
||||
.schedule-form:has(input[name=machines][value=all]:checked) [data-when=chosen] { display: none; }
|
||||
|
||||
@@ -435,7 +435,8 @@
|
||||
const field = (name) => form.elements[name];
|
||||
const value = (name) => { const el = field(name); if (!el) return ''; if (el instanceof RadioNodeList) return el.value; return el.value; };
|
||||
const setSummary = (key, text) => $$(`[data-summary="${key}"]`).forEach((el) => { el.textContent = text || '—'; });
|
||||
const optionText = (select) => select && select.selectedIndex >= 0 ? select.options[select.selectedIndex].dataset.label || select.options[select.selectedIndex].text : '';
|
||||
// Образы выбираются карточками (радиокнопки): берём отмеченную.
|
||||
const picked = (name) => $(`input[name="${name}"]:checked`, form);
|
||||
|
||||
function sync() {
|
||||
const source = value('source_type');
|
||||
@@ -454,9 +455,7 @@
|
||||
// Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name.
|
||||
const login = field('ci_user');
|
||||
if (login && login.dataset.autoLogin === '1') {
|
||||
const image = field('disk_image_path');
|
||||
const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null;
|
||||
login.value = option?.dataset.login || 'admin';
|
||||
login.value = picked('disk_image_path')?.dataset.login || 'admin';
|
||||
}
|
||||
const preset = value('preset');
|
||||
const custom = preset === 'custom';
|
||||
@@ -469,7 +468,7 @@
|
||||
$$('[data-custom-resources] input', form).forEach((input) => { input.readOnly = !custom; });
|
||||
const memory = Number(value('memory')) || 0;
|
||||
setSummary('name', value('name'));
|
||||
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
|
||||
setSummary('source', picked(source === 'disk_image' ? 'disk_image_path' : 'iso_path')?.dataset.label || '');
|
||||
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
|
||||
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
|
||||
setSummary('disk', `${value('disk_size')} ГБ`);
|
||||
@@ -480,9 +479,7 @@
|
||||
}
|
||||
// Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками.
|
||||
const guessOs = () => {
|
||||
const select = field('iso_path');
|
||||
const option = select && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
|
||||
const guess = option?.dataset.os || '';
|
||||
const guess = picked('iso_path')?.dataset.os || '';
|
||||
const hint = $('[data-os-hint]', form);
|
||||
if (form.dataset.osManual) return;
|
||||
const radio = $(`input[name=os_type][value="${guess || 'linux'}"]`, form);
|
||||
@@ -495,6 +492,17 @@
|
||||
if (event.target.name === 'iso_path') guessOs();
|
||||
sync();
|
||||
});
|
||||
// Поиск по образам, когда их много.
|
||||
$$('[data-image-picker]', form).forEach((picker) => {
|
||||
const search = $('[data-image-search]', picker);
|
||||
if (!search) return;
|
||||
search.addEventListener('input', () => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
let shown = 0;
|
||||
$$('.image-option', picker).forEach((item) => { item.hidden = Boolean(query) && !item.dataset.search.includes(query); shown += item.hidden ? 0 : 1; });
|
||||
$('[data-image-empty]', picker).hidden = shown > 0;
|
||||
});
|
||||
});
|
||||
guessOs();
|
||||
sync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% from "_ui.html" import icon %}
|
||||
{#- Выбор образа карточками. Ожидает: picker_name (имя поля), picker_items (list_iso_files или list_disk_image_files),
|
||||
picker_selected (путь выбранного), picker_icon. Поиск появляется, когда образов больше шести. -#}
|
||||
<div class="image-picker" data-image-picker>
|
||||
{% if picker_items|length > 6 %}
|
||||
<input class="input" type="search" placeholder="Найти образ по названию" aria-label="Найти образ" data-image-search>
|
||||
{% endif %}
|
||||
<div class="image-list" role="radiogroup">
|
||||
{% for item in picker_items %}
|
||||
{% set title = item.title or item.name %}
|
||||
<label class="image-option" data-search="{{ (title ~ ' ' ~ item.name ~ ' ' ~ (item.system or ''))|lower }}">
|
||||
<input type="radio" name="{{ picker_name }}" value="{{ item.path }}" data-label="{{ title }}" data-os="{{ item.os_type or '' }}" data-login="{{ item.login or '' }}" {% if picker_selected == item.path or (not picker_selected and loop.first) %}checked{% endif %}>
|
||||
<span class="image-option-body">
|
||||
<span class="list-icon brand">{{ icon(picker_icon) }}</span>
|
||||
<span class="grow">
|
||||
<span class="image-title">{{ title }}</span>
|
||||
<span class="image-meta">{% if item.title %}{{ item.name }} · {% endif %}{{ item.size_label or item.size }}{% if item.date_label %} · загружен {{ item.date_label }}{% endif %}</span>
|
||||
</span>
|
||||
{% if item.system %}<span class="tag">{{ item.system }}</span>{% endif %}
|
||||
<span class="option-check"></span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="subtle small" data-image-empty hidden>Образов с таким названием нет.</p>
|
||||
</div>
|
||||
@@ -47,12 +47,10 @@
|
||||
</div>
|
||||
|
||||
<div class="field" data-show-when="source_type=iso">
|
||||
<label for="iso_path">Установочный образ</label>
|
||||
<span class="label">Установочный образ</span>
|
||||
{% if isos %}
|
||||
<select class="select" id="iso_path" name="iso_path">
|
||||
{% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" data-os="{{ iso.os_type }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
|
||||
</select>
|
||||
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
|
||||
{% with picker_name = 'iso_path', picker_items = isos, picker_selected = form.iso_path, picker_icon = 'disc-3' %}{% include "_image_picker.html" %}{% endwith %}
|
||||
<span class="hint">Образы хранятся на сервере отдельно от машин, один образ можно ставить сколько угодно раз. Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field" data-show-when="source_type=iso">
|
||||
@@ -69,11 +67,9 @@
|
||||
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
|
||||
</div>
|
||||
<div class="field" data-show-when="source_type=disk_image">
|
||||
<label for="disk_image_path">Образ системы</label>
|
||||
<span class="label">Образ системы</span>
|
||||
{% if disk_images %}
|
||||
<select class="select" id="disk_image_path" name="disk_image_path">
|
||||
{% for image in disk_images %}<option value="{{ image.path }}" data-label="{{ image.title or image.name }}" data-login="{{ image.login }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.title or image.name }} · {{ image.size }}</option>{% endfor %}
|
||||
</select>
|
||||
{% with picker_name = 'disk_image_path', picker_items = disk_images, picker_selected = form.disk_image_path, picker_icon = 'hard-drive' %}{% include "_image_picker.html" %}{% endwith %}
|
||||
<span class="hint">Образ будет скопирован — оригинал останется для других машин.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Ссылка в новой задаче
Block a user