4 Commits

8 changed files with 194 additions and 82 deletions

View File

@@ -7,14 +7,43 @@ services:
image: git.cube2.ru/erjemin/2026-etpgrf-site:latest image: git.cube2.ru/erjemin/2026-etpgrf-site:latest
# Перезапускать всегда (если упал или сервер перезагрузился) # Перезапускать всегда (если упал или сервер перезагрузился)
restart: always restart: always
# Метка для Watchtower, чтобы он обновлял только этот контейнер # Метка для Watchtower, чтобы он обновлял только этот контейнер
labels: labels:
- "com.centurylinklabs.watchtower.scope=etpgrf" - "com.centurylinklabs.watchtower.scope=etpgrf"
# Запускаем миграции, потом collectstatic, потом сервер # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
command: sh -c "python etpgrf_site/manage.py migrate --noinput && python etpgrf_site/manage.py collectstatic --noinput && gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi" # При первом старте, временно запускаем как root, чтобы контейнер мог создать файл БД в хостовом томе
# ser: "0:0"
# command: >
# sh -c "mkdir -p /app/data &&
# chown -R 1000:1000 /app/data 2>/dev/null || true &&
# chmod -R 0775 /app/data 2>/dev/null || true &&
# python etpgrf_site/manage.py migrate --noinput &&
# python etpgrf_site/manage.py collectstatic --noinput &&
# gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
#
# После первого запуска, на хосте убедиться что файл БД создан. Возвращаем docker-compose.yml (это тот фал который
# вы сейчас читаете). После останавливаем контейнеры: и сменить владельца папки data. Для этого останавливаем контейнеры:
# `sudo docker-compose -f stop`
# возвращаем docker-compose.ym в исходный вид (этот файл). И снова запускаем:
# `sudo docker-compose -f docker-compose.prod.yml up -d`
# Теперь нам нужно узнать UID/GID пользователя внутри контейнера, выполнив на хосте команду:
# `sudo docker exec -it etpgrf-site-etpgrf-backend-1 id`
# Увидим что-то типа `uid=999(appuser) gid=999(appuser) groups=999(appuser)`. После этого сменить владельца папки
# data на хосте:
# `sudo chown -R 999:999 ./data`
# С папкой media можно сделать то же самое, если там будут проблемы с правами.
# `sudo chown -R 999:999 ./media`
#
# А обычно запускаем в безопасном режиме. Просто миграции, потом collectstatic, потом сервер
command: >
sh -c "python etpgrf_site/manage.py migrate --noinput &&
python etpgrf_site/manage.py collectstatic --noinput &&
gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
# command: sh -c "python etpgrf_site/manage.py collectstatic --noinput && gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
volumes: volumes:
# База данных (папка data должна быть создана на хосте) # База данных (папка data должна быть создана на хосте)
- ./data:/app/data - ./data:/app/data
@@ -22,10 +51,10 @@ services:
- static_volume:/app/public/static_collected - static_volume:/app/public/static_collected
# Медиа (папка media должна быть создана на хосте) # Медиа (папка media должна быть создана на хосте)
- ./media:/app/public/media - ./media:/app/public/media
env_file: env_file:
- .env - .env
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@@ -36,26 +65,21 @@ services:
etpgrf-nginx: etpgrf-nginx:
image: nginx:1.25-alpine image: nginx:1.25-alpine
restart: always restart: always
# Метка для Watchtower (хотя nginx:alpine обновляется редко, но пусть будет)
labels:
- "com.centurylinklabs.watchtower.scope=etpgrf"
volumes: volumes:
# Конфиг берем из репозитория # Конфиг берем из репозитория
- ./config/nginx/etpgrf--internal-nginx.conf:/etc/nginx/nginx.conf:ro - ./config/nginx/etpgrf--internal-nginx.conf:/etc/nginx/nginx.conf:ro
- static_volume:/app/public/static_collected - static_volume:/app/public/static_collected
- ./media:/app/public/media - ./media:/app/public/media
# Внешний порт. Если у тебя на хосте уже есть Nginx (прокси), # Внешний порт. Если у тебя на хосте уже есть Nginx (прокси),
# то можно пробросить на 127.0.0.1:8000 или использовать внутреннюю сеть. # то можно пробросить на 127.0.0.1:8000 или использовать внутреннюю сеть.
# Но пока оставим так: # Но пока оставим так:
ports: ports:
- "127.0.0.1:8080:80" # Используем 8080, чтобы не конфликтовать с Portainer (8000) или основным Nginx (80) - "127.0.0.1:8080:80" # Используем 8080, чтобы не конфликтовать с Portainer (8000) или основным Nginx (80)
depends_on: depends_on:
- etpgrf-backend - etpgrf-backend
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@@ -75,8 +99,8 @@ services:
# Ограничиваем область видимости только этим проектом # Ограничиваем область видимости только этим проектом
- WATCHTOWER_SCOPE=etpgrf - WATCHTOWER_SCOPE=etpgrf
# Если нужно указать реестр явно (обычно watchtower сам понимает из имени образа) # Если нужно указать реестр явно (обычно watchtower сам понимает из имени образа)
# - WATCHTOWER_REGISTRY_URL=git.cube2.ru # - WATCHTOWER_REGISTRY_URL=git.cube2.ru
command: --interval 300 --cleanup # Проверять каждые 5 минут command: --interval 1800 --cleanup # Проверять каждые 30 минут
volumes: volumes:

View File

@@ -7,11 +7,6 @@ from django.contrib.staticfiles.storage import staticfiles_storage
urlpatterns = [ urlpatterns = [
path(route='adm-in/', view=admin.site.urls), path(route='adm-in/', view=admin.site.urls),
path(
route="favicon.ico",
view=RedirectView.as_view(url=staticfiles_storage.url("favicon.ico")),
name="favicon",
),
path(route='', view=include('typograph.urls')), path(route='', view=include('typograph.urls')),
] ]

View File

@@ -45,9 +45,9 @@
{# ШАПКА и главное меню #} {# ШАПКА и главное меню #}
<nav id="main-navbar" class="navbar navbar-expand-lg mb-4"> <nav id="main-navbar" class="navbar navbar-expand-lg mb-4">
<div class="container"> <div class="container p-0">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<img id="logo-img" class="logo-img" src="" <img id="logo-img" class="logo-img p-0 m-0" src=""
data-src-light="{% static 'svg/logo-etpgrf-site-light.svg' %}" data-src-light="{% static 'svg/logo-etpgrf-site-light.svg' %}"
data-src-light-compact="{% static 'svg/logo-etpgrf-site-light-compact.svg' %}" data-src-light-compact="{% static 'svg/logo-etpgrf-site-light-compact.svg' %}"
data-src-dark="{% static 'svg/logo-etpgrf-site-dark.svg' %}" data-src-dark="{% static 'svg/logo-etpgrf-site-dark.svg' %}"
@@ -64,12 +64,12 @@
{# Футер #} {# Футер #}
<footer class="footer mt-auto py-2 mt-4"> <footer class="footer mt-auto py-2 mt-4">
<div class="container d-flex justify-content-between align-items-center"> <div class="container d-flex justify-content-between align-items-center">
<span class="text-muted small nowrap">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}.</span> <span class="text-muted small nowrap me-2">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}.</span>
<span class="text-muted small nowrap"><i class="bi bi-tags me-1" title="Версия библиотеки etpgrf / Версия сайта"></i>v0.1.3 / v0.1.3</span> <nobr class="text-muted small mx-2"><i class="bi bi-tags me-1" title="Версия библиотеки etpgrf / Версия сайта"></i>v0.1.3 / v0.1.4</nobr>
{# Сводная статистика (HTMX) #} {# Сводная статистика (HTMX) #}
<span class="text-muted small" hx-get="{% url 'stats_summary' %}" hx-trigger="load"> <span class="text-muted small ms-2" hx-get="{% url 'stats_summary' %}" hx-trigger="load">
... ...
</span> </span>

View File

@@ -274,9 +274,12 @@
<label class="form-label fw-bold small text-muted ls-1 mb-0"> <label class="form-label fw-bold small text-muted ls-1 mb-0">
<i class="bi bi-code-slash me-1"></i> Результат обработки: <i class="bi bi-code-slash me-1"></i> Результат обработки:
</label> </label>
<button id="btn-copy" class="btn btn-sm btn-outline-secondary d-none" title="Копировать в буфер обмена"> <div class="d-flex align-items-center">
<i class="bi bi-clipboard me-1"></i> Копировать в&nbsp;буфер обмена <span id="processing-time" class="small text-muted me-3 nowrap"></span>
</button> <button id="btn-copy" class="btn btn-sm btn-outline-secondary d-none" title="Копировать в буфер обмена">
<i class="bi bi-clipboard me-1"></i> Копировать в&nbsp;буфер обмена
</button>
</div>
</div> </div>
<div id="cm-result-wrapper" class="result-box p-0"></div> <div id="cm-result-wrapper" class="result-box p-0"></div>
<div id="result-area" style="display: none;"></div> <div id="result-area" style="display: none;"></div>

View File

@@ -1,12 +1,16 @@
<span class="me-3" title="Просмотров"> {% load typograph_extras %}
<i class="bi bi-eye me-1"></i>{{ views }} <nobr class="ms-3 float-end" title="Скопировано в буфер текстов/символов">
</span> <i class="bi bi-clipboard-check me-1"></i>{{ copied|humanize_num }} / {{ chars_copied|humanize_num }}
<span class="me-3 nowrap" title="На вход обработано текстов/символов"> </nobr>
<i class="bi bi-box-arrow-in-right me-1"></i>{{ processed }}/{{ chars_in }} <nobr class="ms-3 float-end" title="На выход получено символов">
</span> <i class="bi bi-box-arrow-right me-1"></i>{{ chars_out|humanize_num }}
<span class="me-3" title="На выход получено символов"> </nobr>
<i class="bi bi-box-arrow-right me-1"></i>{{ chars_out }} <nobr class="ms-3 float-end" title="На вход обработано текстов/символов">
</span> <i class="bi bi-box-arrow-in-right me-1"></i>{{ processed|humanize_num }} / {{ chars_in|humanize_num }}
<span class="nowrap" title="Скопировано в буфер текстов/символов"> </nobr>
<i class="bi bi-clipboard-check me-1"></i>{{ copied }}/{{ chars_copied }} <nobr class="ms-3 float-end" title="Просмотров">
</span> <i class="bi bi-eye me-1"></i>{{ views|humanize_num }}
</nobr>

View File

@@ -0,0 +1,45 @@
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter(name='humanize_num')
def humanize_num(value):
"""
Форматирует число с тонкими пробелами в качестве разделителя тысяч
и сокращает большие числа до M (миллионы) или k (тысячи).
Примеры:
1234 -> 1&thinsp;234
1234567 -> 1,2M
"""
try:
num = int(value)
if num > 1_000_000_000:
val = num / 1_000_000_000
suffix = "&thinsp;B"
elif num > 1_000_000:
val = num / 1_000_000
suffix = "&thinsp;M"
elif num > 1_000:
val = num / 1_000
suffix = "&thinsp;k"
else:
# Больше 1B -- форматируем с пробелами
return mark_safe(f"{num:,}".replace(",", "&thinsp;"))
# Форматируем float:
# {:,.1f} - разделитель тысяч (запятая) и 1 знак после точки
# 1234567.89 -> "1,234,567.9"
formatted = f"{val:,.2f}"
# Меняем английскую запятую (разделитель тысяч) на тонкий пробел
# Меняем английскую точку (десятичный разделитель) на запятую
# Но тут проблема: replace делает все сразу.
# "1,234.5" -> replace(",", " ") -> "1 234.5" -> replace(".", ",") -> "1 234,5"
formatted = formatted.replace(",", "&thinsp;").replace(".", ",")
return mark_safe(f"{formatted}{suffix}")
except (ValueError, TypeError):
return value

View File

@@ -7,6 +7,7 @@ from etpgrf.typograph import Typographer
from etpgrf.layout import LayoutProcessor from etpgrf.layout import LayoutProcessor
from etpgrf.hyphenation import Hyphenator from etpgrf.hyphenation import Hyphenator
from .models import DailyStat from .models import DailyStat
import time
def index(request): def index(request):
@@ -23,35 +24,28 @@ def index(request):
def get_stats_summary(request): def get_stats_summary(request):
"""Возвращает сводную статистику.""" """Возвращает сводную статистику."""
# Убираем try...except для отладки try:
stats = DailyStat.objects.aggregate( stats = DailyStat.objects.aggregate(
views=Sum('index_views'), views=Sum('index_views'),
processed=Sum('process_requests'), processed=Sum('process_requests'),
copied=Sum('copy_count'), copied=Sum('copy_count'),
chars_in=Sum('chars_in'), chars_in=Sum('chars_in'),
chars_out=Sum('chars_out'), chars_out=Sum('chars_out'),
chars_copied=Sum('chars_copied') chars_copied=Sum('chars_copied')
) )
# print("Aggregated stats:", stats) # DEBUG
context = {
# Функция для форматирования чисел с сокращениями (M, k) 'views': stats['views'] or 0,
def format_large_number(num): 'processed': stats['processed'] or 0,
if num > 1_000_000: 'copied': stats['copied'] or 0,
return f"{num / 1_000_000:.3f}M".replace(".", ",") 'chars_in': stats['chars_in'] or 0,
elif num > 1_000: 'chars_out': stats['chars_out'] or 0,
return f"{num / 1_000:.2f}k".replace(".", ",") 'chars_copied': stats['chars_copied'] or 0,
return str(num) }
context = { return render(request, 'typograph/stats_summary.html', context)
'views': f"{(stats['views'] or 0):,}".replace(",", "&thinsp;"), except Exception:
'processed': f"{(stats['processed'] or 0):,}".replace(",", "&thinsp;"), return HttpResponse("...")
'copied': f"{(stats['copied'] or 0):,}".replace(",", "&thinsp;"),
'chars_in': format_large_number(stats['chars_in'] or 0),
'chars_out': format_large_number(stats['chars_out'] or 0),
'chars_copied': format_large_number(stats['chars_copied'] or 0),
}
return render(request, 'typograph/stats_summary.html', context)
@require_POST @require_POST
@@ -138,39 +132,71 @@ def process_text(request):
'sanitizer': sanitizer_option, 'sanitizer': sanitizer_option,
} }
# --- ДИАГНОСТИКА --- # Обрабатываем текст с замером времени
# print("Typographer options:", options) start_time = time.perf_counter()
# ------------------- # Создаем экземпляр типографа и передаем настройки в него
# Создаем экземпляр типографа
typo = Typographer(**options) typo = Typographer(**options)
# Обрабатываем текст в Типографе
# Обрабатываем текст
processed = typo.process(text) processed = typo.process(text)
end_time = time.perf_counter()
duration_ms = (end_time - start_time) * 1000
# --- СБОР СТАТИСТИКИ --- # --- СБОР СТАТИСТИКИ ---
try: try:
today = timezone.now().date() today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today) stat, created = DailyStat.objects.get_or_create(date=today)
# Обновляем атомарные поля # 1. Атомарное обновление счетчиков
DailyStat.objects.filter(pk=stat.pk).update( DailyStat.objects.filter(pk=stat.pk).update(
process_requests=F('process_requests') + 1, process_requests=F('process_requests') + 1,
chars_in=F('chars_in') + len(text), chars_in=F('chars_in') + len(text),
chars_out=F('chars_out') + len(processed), chars_out=F('chars_out') + len(processed),
# total_processing_time_ms мы пока не считаем, чтобы не усложнять total_processing_time_ms=F('total_processing_time_ms') + duration_ms
) )
# JSON с настройками пока не пишем, чтобы не усложнять (как договаривались) # 2. Обновление JSON с настройками (не атомарно, а значит при высокой нагрузке возможны потери данных)
# Перечитываем объект, чтобы получить актуальный JSON
stat.refresh_from_db()
current_stats = stat.settings_stats
def inc_stat(key, value):
k = f"{key}:{value}"
current_stats[k] = current_stats.get(k, 0) + 1
# Собираем статистику по опциям
# langs может быть строкой или списком
lang_val = options['langs']
if isinstance(lang_val, list):
lang_val = lang_val[0] if lang_val else 'ru'
inc_stat('lang', lang_val)
inc_stat('mode', options['mode'])
if options['quotes']: inc_stat('feature', 'quotes')
if layout_enabled: inc_stat('feature', 'layout')
if options['unbreakables']: inc_stat('feature', 'unbreakables')
if hyphenation_enabled: inc_stat('feature', 'hyphenation')
if options['symbols']: inc_stat('feature', 'symbols')
if hanging_enabled: inc_stat('feature', 'hanging')
if sanitizer_enabled: inc_stat('feature', 'sanitizer')
stat.settings_stats = current_stats
stat.save(update_fields=['settings_stats'])
except Exception as e: except Exception as e:
print(f"Stat error: {e}") print(f"Stat error: {e}")
# ----------------------- # -----------------------
return render( response = render(
request, request,
template_name='typograph/result_fragment.html', template_name='typograph/result_fragment.html',
context={'processed_text': processed} context={'processed_text': processed}
) )
# Добавляем заголовок с временем обработки (с запятой вместо точки)
response['X-Processing-Time'] = f"{duration_ms:.4f}".replace('.', ',')
return response
return HttpResponse(status=405) return HttpResponse(status=405)

View File

@@ -21,6 +21,7 @@ import {
const resultWrapper = document.getElementById('cm-result-wrapper'); const resultWrapper = document.getElementById('cm-result-wrapper');
const btnCopy = document.getElementById('btn-copy'); const btnCopy = document.getElementById('btn-copy');
const sourceTextarea = document.querySelector('textarea[name="text"]'); const sourceTextarea = document.querySelector('textarea[name="text"]');
const processingTimeSpan = document.getElementById('processing-time');
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
function getTheme() { function getTheme() {
@@ -100,6 +101,17 @@ document.body.addEventListener('htmx:afterSwap', function (evt) {
if (btnCopy) { if (btnCopy) {
btnCopy.classList.remove('d-none'); btnCopy.classList.remove('d-none');
} }
// Показываем время обработки из заголовка
if (processingTimeSpan) {
const time = evt.detail.xhr.getResponseHeader('X-Processing-Time');
if (time) {
processingTimeSpan.innerHTML = `<i class="bi bi-cpu me-1"></i>${time}&thinsp;ms`;
processingTimeSpan.style.display = 'inline';
} else {
processingTimeSpan.style.display = 'none';
}
}
} }
}); });
@@ -109,6 +121,9 @@ if (sourceTextarea) {
if (btnCopy) { if (btnCopy) {
btnCopy.classList.add('d-none'); btnCopy.classList.add('d-none');
} }
if (processingTimeSpan) {
processingTimeSpan.innerText = '';
}
// Сбрасываем редактор на плейсхолдер // Сбрасываем редактор на плейсхолдер
if (resultView.state.doc.toString() !== PLACEHOLDER_TEXT) { if (resultView.state.doc.toString() !== PLACEHOLDER_TEXT) {
resultView.dispatch({ resultView.dispatch({