mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-06-10 00:52:46 +00:00
v2.5.0: align key controls and add IP limits
This commit is contained in:
@@ -53,6 +53,7 @@ USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
||||
LANG_RE = re.compile(r"^(en|ru)$")
|
||||
SENSITIVE_CONFIG_KEYS = {"secret"}
|
||||
BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$")
|
||||
MAX_UNIQUE_IP_LIMIT = 1000000
|
||||
TRAFFIC_WINDOWS = {
|
||||
"15m": 15 * 60,
|
||||
"1h": 60 * 60,
|
||||
@@ -197,6 +198,38 @@ def read_telemt_users() -> dict[str, str]:
|
||||
return users
|
||||
|
||||
|
||||
def read_toml_int_table(table: str) -> dict[str, int]:
|
||||
if not TELEMT_CONFIG.exists():
|
||||
return {}
|
||||
values: dict[str, int] = {}
|
||||
section = f"[{table}]"
|
||||
in_table = False
|
||||
for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = raw.strip()
|
||||
if line == section:
|
||||
in_table = True
|
||||
continue
|
||||
if in_table and line.startswith("["):
|
||||
break
|
||||
if not in_table or not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
name, value = line.split("=", 1)
|
||||
name = parse_toml_key(name)
|
||||
if not USER_RE.match(name):
|
||||
continue
|
||||
raw_value = value.strip().split("#", 1)[0].strip().strip('"').strip("'")
|
||||
try:
|
||||
number = int(raw_value)
|
||||
except ValueError:
|
||||
continue
|
||||
values[name] = max(0, number)
|
||||
return values
|
||||
|
||||
|
||||
def read_user_max_unique_ips() -> dict[str, int]:
|
||||
return read_toml_int_table("access.user_max_unique_ips")
|
||||
|
||||
|
||||
def read_disabled_users() -> dict[str, str]:
|
||||
raw = load_json(DISABLED_USERS_FILE, {}) or {}
|
||||
if not isinstance(raw, dict):
|
||||
@@ -227,11 +260,12 @@ def write_disabled_users(users: dict[str, str]) -> None:
|
||||
def read_user_records() -> dict[str, dict[str, Any]]:
|
||||
active = read_telemt_users()
|
||||
disabled = read_disabled_users()
|
||||
ip_limits = read_user_max_unique_ips()
|
||||
records: dict[str, dict[str, Any]] = {}
|
||||
for name, secret in disabled.items():
|
||||
records[name] = {"secret": secret, "enabled": False}
|
||||
records[name] = {"secret": secret, "enabled": False, "max_unique_ips": ip_limits.get(name, 0)}
|
||||
for name, secret in active.items():
|
||||
records[name] = {"secret": secret, "enabled": True}
|
||||
records[name] = {"secret": secret, "enabled": True, "max_unique_ips": ip_limits.get(name, 0)}
|
||||
return records
|
||||
|
||||
|
||||
@@ -243,6 +277,25 @@ def _ordered_user_lines(users: dict[str, str]) -> list[str]:
|
||||
return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names]
|
||||
|
||||
|
||||
def _ordered_user_int_lines(values: dict[str, int]) -> list[str]:
|
||||
positive: dict[str, int] = {}
|
||||
for name, value in values.items():
|
||||
name_s = str(name)
|
||||
if not USER_RE.match(name_s):
|
||||
continue
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if number > 0:
|
||||
positive[name_s] = number
|
||||
names = []
|
||||
if "main" in positive:
|
||||
names.append("main")
|
||||
names.extend(sorted(n for n in positive if n != "main"))
|
||||
return [f'{quote_toml_key(name)} = {positive[name]}' for name in names]
|
||||
|
||||
|
||||
def parse_toml_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if len(key) >= 2 and key[0] == key[-1] == '"':
|
||||
@@ -293,6 +346,55 @@ def write_telemt_users(users: dict[str, str]) -> None:
|
||||
tmp.replace(TELEMT_CONFIG)
|
||||
|
||||
|
||||
def write_toml_int_table(table: str, values: dict[str, int]) -> None:
|
||||
TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else []
|
||||
rendered = _ordered_user_int_lines(values)
|
||||
header = f"[{table}]"
|
||||
out: list[str] = []
|
||||
in_table = False
|
||||
found = False
|
||||
|
||||
for raw in lines:
|
||||
if raw.strip() == header:
|
||||
found = True
|
||||
in_table = True
|
||||
if rendered:
|
||||
out.append(raw)
|
||||
out.extend(rendered)
|
||||
continue
|
||||
if in_table and raw.strip().startswith("["):
|
||||
in_table = False
|
||||
if in_table:
|
||||
continue
|
||||
out.append(raw)
|
||||
|
||||
if not found and rendered:
|
||||
if out and out[-1].strip():
|
||||
out.append("")
|
||||
out.append(header)
|
||||
out.extend(rendered)
|
||||
|
||||
tmp = TELEMT_CONFIG.with_name(TELEMT_CONFIG.name + ".tmp")
|
||||
tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(TELEMT_CONFIG)
|
||||
|
||||
|
||||
def write_user_max_unique_ips(values: dict[str, int]) -> None:
|
||||
write_toml_int_table("access.user_max_unique_ips", values)
|
||||
|
||||
|
||||
def normalize_max_unique_ips(value: Any) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("max_unique_ips must be an integer") from None
|
||||
if number < 0 or number > MAX_UNIQUE_IP_LIMIT:
|
||||
raise ValueError(f"max_unique_ips must be between 0 and {MAX_UNIQUE_IP_LIMIT}")
|
||||
return number
|
||||
|
||||
|
||||
def restart_service(name: str) -> bool:
|
||||
code, _, _ = run(["systemctl", "restart", name], timeout=25)
|
||||
if code != 0:
|
||||
@@ -1071,6 +1173,7 @@ def user_payload(
|
||||
name: str,
|
||||
secret: str,
|
||||
enabled: bool = True,
|
||||
max_unique_ips: int = 0,
|
||||
include_runtime: bool = False,
|
||||
traffic_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -1080,6 +1183,7 @@ def user_payload(
|
||||
"link": proxy_link(secret),
|
||||
"main": name == "main",
|
||||
"enabled": bool(enabled),
|
||||
"max_unique_ips": _int_value(max_unique_ips),
|
||||
}
|
||||
if traffic_snapshot:
|
||||
item["traffic"] = {
|
||||
@@ -1177,7 +1281,13 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
items = []
|
||||
for name in sorted(users, key=lambda item: (item != "main", item)):
|
||||
record = users[name]
|
||||
items.append(user_payload(name, record["secret"], record["enabled"], traffic_snapshot=latest.get(name)))
|
||||
items.append(user_payload(
|
||||
name,
|
||||
record["secret"],
|
||||
record["enabled"],
|
||||
record.get("max_unique_ips", 0),
|
||||
traffic_snapshot=latest.get(name),
|
||||
))
|
||||
self.send_json({"ok": True, "data": items})
|
||||
elif path.startswith("/api/users/") and path.endswith("/qr"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/qr")])
|
||||
@@ -1231,7 +1341,14 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
record = users[name]
|
||||
self.send_json({"ok": True, "data": user_payload(name, record["secret"], record["enabled"], include_runtime=True, traffic_snapshot=latest_user_stats().get(name))})
|
||||
self.send_json({"ok": True, "data": user_payload(
|
||||
name,
|
||||
record["secret"],
|
||||
record["enabled"],
|
||||
record.get("max_unique_ips", 0),
|
||||
include_runtime=True,
|
||||
traffic_snapshot=latest_user_stats().get(name),
|
||||
)})
|
||||
elif path == "/api/backups":
|
||||
self.send_json({"ok": True, "data": list_backups()})
|
||||
elif path == "/api/backups/schedule":
|
||||
@@ -1296,7 +1413,38 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_error_json(500, f"failed to save config: {exc}")
|
||||
return
|
||||
restart_requested = request_service_restart("telemt")
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, True), "restart": {"mode": "async", "requested": restart_requested}})
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, True, 0), "restart": {"mode": "async", "requested": restart_requested}})
|
||||
elif path.startswith("/api/users/") and path.endswith("/max-ips"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/max-ips")])
|
||||
try:
|
||||
limit = normalize_max_unique_ips(body.get("max_unique_ips"))
|
||||
except ValueError as exc:
|
||||
self.send_error_json(400, str(exc))
|
||||
return
|
||||
try:
|
||||
with FileLock(USER_LOCK_FILE):
|
||||
records = read_user_records()
|
||||
if name not in records:
|
||||
self.send_error_json(404, "user not found")
|
||||
return
|
||||
limits = read_user_max_unique_ips()
|
||||
if limit > 0:
|
||||
limits[name] = limit
|
||||
else:
|
||||
limits.pop(name, None)
|
||||
write_user_max_unique_ips(limits)
|
||||
record = read_user_records()[name]
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, f"failed to save config: {exc}")
|
||||
return
|
||||
restart_requested = request_service_restart("telemt")
|
||||
self.send_json({"ok": True, "data": user_payload(
|
||||
name,
|
||||
record["secret"],
|
||||
record["enabled"],
|
||||
record.get("max_unique_ips", 0),
|
||||
traffic_snapshot=latest_user_stats().get(name),
|
||||
), "restart": {"mode": "async", "requested": restart_requested}})
|
||||
elif path.startswith("/api/users/") and path.endswith("/enabled"):
|
||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")])
|
||||
if name == "main":
|
||||
@@ -1327,7 +1475,7 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_error_json(500, f"failed to save config: {exc}")
|
||||
return
|
||||
restart_requested = request_service_restart("telemt")
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, enabled), "restart": {"mode": "async", "requested": restart_requested}})
|
||||
self.send_json({"ok": True, "data": user_payload(name, secret, enabled, records[name].get("max_unique_ips", 0)), "restart": {"mode": "async", "requested": restart_requested}})
|
||||
elif path == "/api/backups":
|
||||
ok, result = create_backup()
|
||||
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
|
||||
@@ -1399,8 +1547,11 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
active.pop(name, None)
|
||||
disabled.pop(name, None)
|
||||
limits = read_user_max_unique_ips()
|
||||
limits.pop(name, None)
|
||||
write_telemt_users(active)
|
||||
write_disabled_users(disabled)
|
||||
write_user_max_unique_ips(limits)
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, f"failed to save config: {exc}")
|
||||
return
|
||||
|
||||
@@ -57,6 +57,9 @@ const i18n = {
|
||||
tableSecret: "Secret",
|
||||
tableLink: "Link",
|
||||
tableTraffic: "Traffic",
|
||||
ipLimit: "IP limit",
|
||||
ipLimitHint: "0 = unlimited",
|
||||
saveIpLimit: "OK",
|
||||
tableTrafficDelta: "Traffic delta",
|
||||
tableTrafficTotal: "Total",
|
||||
tableActions: "Actions",
|
||||
@@ -165,6 +168,7 @@ const i18n = {
|
||||
languageSaved: "Language saved",
|
||||
keyEnabled: "Key enabled",
|
||||
keyDisabled: "Key disabled",
|
||||
ipLimitSaved: "IP limit saved",
|
||||
visualTitle: "Port 443 map",
|
||||
visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.",
|
||||
port443Checked: "checked",
|
||||
@@ -276,6 +280,9 @@ const i18n = {
|
||||
tableSecret: "Секрет",
|
||||
tableLink: "Ссылка",
|
||||
tableTraffic: "Трафик",
|
||||
ipLimit: "Лимит IP",
|
||||
ipLimitHint: "0 = безлимит",
|
||||
saveIpLimit: "OK",
|
||||
tableTrafficDelta: "Прирост трафика",
|
||||
tableTrafficTotal: "Всего",
|
||||
tableActions: "Действия",
|
||||
@@ -384,6 +391,7 @@ const i18n = {
|
||||
languageSaved: "Язык сохранён",
|
||||
keyEnabled: "Ключ включён",
|
||||
keyDisabled: "Ключ отключён",
|
||||
ipLimitSaved: "Лимит IP сохранён",
|
||||
visualTitle: "Карта порта 443",
|
||||
visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.",
|
||||
port443Checked: "проверено",
|
||||
@@ -1138,6 +1146,7 @@ function renderUsers() {
|
||||
const traffic = user.traffic || {};
|
||||
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
|
||||
const activeIps = Number(traffic.active_unique_ips) || 0;
|
||||
const maxUniqueIps = Number.isFinite(Number(user.max_unique_ips)) ? Math.max(0, Number(user.max_unique_ips)) : 0;
|
||||
return `
|
||||
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""} ${selected ? "selected-row" : ""}" data-select-user-traffic="${escapeAttr(user.name)}" aria-selected="${selected ? "true" : "false"}">
|
||||
<td data-label="${escapeAttr(t("tableUser"))}">
|
||||
@@ -1161,14 +1170,25 @@ function renderUsers() {
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableTraffic"))}">
|
||||
<div class="traffic-cell">
|
||||
<strong>${escapeHtml(trafficTotal)}</strong>
|
||||
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
|
||||
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
|
||||
<div class="traffic-main">
|
||||
<span>
|
||||
<strong>${escapeHtml(trafficTotal)}</strong>
|
||||
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
|
||||
</span>
|
||||
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
|
||||
</div>
|
||||
<form class="ip-limit-control" data-ip-limit-form="${escapeAttr(user.name)}" title="${escapeAttr(t("ipLimitHint"))}">
|
||||
<span>${escapeHtml(t("ipLimit"))}</span>
|
||||
<input type="number" min="0" max="1000000" step="1" value="${escapeAttr(maxUniqueIps)}" data-ip-limit-input="${escapeAttr(user.name)}" aria-label="${escapeAttr(t("ipLimit"))}: ${escapeAttr(user.name)}">
|
||||
<button class="soft" type="submit" data-ip-limit-save="${escapeAttr(user.name)}">${escapeHtml(t("saveIpLimit"))}</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="${escapeAttr(t("tableActions"))}" class="actions">
|
||||
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
|
||||
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
|
||||
<td data-label="${escapeAttr(t("tableActions"))}">
|
||||
<div class="action-buttons">
|
||||
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
|
||||
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`; }).join("");
|
||||
@@ -1394,6 +1414,32 @@ async function setUserEnabled(name, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setUserMaxUniqueIps(name, value) {
|
||||
const limit = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(limit) || limit < 0 || limit > 1000000) {
|
||||
toast(t("ipLimitHint"));
|
||||
return;
|
||||
}
|
||||
const form = $$("[data-ip-limit-form]").find((item) => item.dataset.ipLimitForm === name);
|
||||
const controls = form ? Array.from(form.querySelectorAll("input, button")) : [];
|
||||
controls.forEach((control) => { control.disabled = true; });
|
||||
try {
|
||||
const data = await api(`/api/users/${encodeURIComponent(name)}/max-ips`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ max_unique_ips: limit }),
|
||||
});
|
||||
state.users = state.users.map((user) => user.name === name ? { ...user, max_unique_ips: data.max_unique_ips } : user);
|
||||
renderUsers();
|
||||
addEvent(t("ipLimitSaved"), `${name}: ${data.max_unique_ips}`);
|
||||
toast(t("changesApplyInBackground"));
|
||||
setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400);
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
controls.forEach((control) => { control.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
const btn = $("#createBackupBtn");
|
||||
btn.disabled = true;
|
||||
@@ -1590,6 +1636,7 @@ document.addEventListener("click", async (eventObj) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventObj.target.closest("input, select, textarea, label, form")) return;
|
||||
const row = eventObj.target.closest("[data-select-user-traffic]");
|
||||
if (!row) return;
|
||||
selectUserTraffic(row.dataset.selectUserTraffic, { scroll: true });
|
||||
@@ -1618,6 +1665,14 @@ $("#addUserForm").addEventListener("submit", (eventObj) => {
|
||||
addUser(name).catch((err) => toast(err.message));
|
||||
});
|
||||
|
||||
document.addEventListener("submit", (eventObj) => {
|
||||
const form = eventObj.target.closest("[data-ip-limit-form]");
|
||||
if (!form) return;
|
||||
eventObj.preventDefault();
|
||||
const input = form.querySelector("[data-ip-limit-input]");
|
||||
setUserMaxUniqueIps(form.dataset.ipLimitForm, input?.value || "0");
|
||||
});
|
||||
|
||||
$("#refreshBtn").addEventListener("click", refreshAll);
|
||||
$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value));
|
||||
$("#promoClose").addEventListener("click", () => {
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<table class="keys-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="tableUser">User</th>
|
||||
@@ -400,6 +400,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js?v=2.5.0-admin11" type="module"></script>
|
||||
<script src="/app.js?v=2.5.0-admin12" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -790,6 +790,24 @@ table {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.keys-table {
|
||||
min-width: 1180px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.keys-table th:nth-child(1),
|
||||
.keys-table td:nth-child(1) { width: 11%; }
|
||||
.keys-table th:nth-child(2),
|
||||
.keys-table td:nth-child(2) { width: 15%; }
|
||||
.keys-table th:nth-child(3),
|
||||
.keys-table td:nth-child(3) { width: 27%; }
|
||||
.keys-table th:nth-child(4),
|
||||
.keys-table td:nth-child(4) { width: 16%; }
|
||||
.keys-table th:nth-child(5),
|
||||
.keys-table td:nth-child(5) { width: 18%; }
|
||||
.keys-table th:nth-child(6),
|
||||
.keys-table td:nth-child(6) { width: 13%; }
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
@@ -845,16 +863,41 @@ td small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.keys-table button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.traffic-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 150px;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.traffic-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.traffic-main span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.traffic-cell strong {
|
||||
@@ -862,7 +905,32 @@ td small {
|
||||
}
|
||||
|
||||
.traffic-cell .soft {
|
||||
width: max-content;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ip-limit-control {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(54px, 72px) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ip-limit-control span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ip-limit-control input {
|
||||
width: 72px;
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.ip-limit-control button {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.user-traffic-panel {
|
||||
@@ -944,8 +1012,16 @@ td small {
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1235,6 +1311,7 @@ td small {
|
||||
}
|
||||
|
||||
table,
|
||||
.keys-table,
|
||||
thead,
|
||||
tbody,
|
||||
tr,
|
||||
@@ -1284,16 +1361,34 @@ td small {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
.action-buttons button {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
|
||||
.traffic-main,
|
||||
.ip-limit-control {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.traffic-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ip-limit-control {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ip-limit-control input,
|
||||
.ip-limit-control button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backup-item,
|
||||
.backup-schedule,
|
||||
.event,
|
||||
@@ -1304,11 +1399,13 @@ td small {
|
||||
}
|
||||
|
||||
.mini-actions,
|
||||
.action-buttons,
|
||||
.backup-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.mini-actions button,
|
||||
.action-buttons button,
|
||||
.backup-actions button {
|
||||
flex: 1 1 130px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user