mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
feat(hub): add production deploy pipeline and initial seed
This commit is contained in:
1 parent
9d93387806
commit
b61e0a8b6d
15 files changed
+1065
-22
No files matched your search
@@ -1,4 +1,7 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
@@ -8,5 +11,7 @@ dist
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
data
|
||||
backups
|
||||
design
|
||||
docs
|
||||
+4
-5
@@ -3,9 +3,9 @@ COMPOSE_PROJECT_NAME=edevs_hub
|
||||
HUB_ENV=local
|
||||
HUB_DEBUG=true
|
||||
HUB_SECRET_KEY=change-me-only-for-local-development
|
||||
HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost,pay.localhost,chat.localhost
|
||||
HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5174,http://localhost:5175
|
||||
HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175
|
||||
HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost
|
||||
HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5175
|
||||
HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175
|
||||
|
||||
# Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из
|
||||
# HUB_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key().
|
||||
@@ -42,8 +42,7 @@ POSTGRES_PORT=5432
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
INTERNAL_UI_PORT=5173
|
||||
CHECKOUT_PORT=5174
|
||||
WEB_CHAT_PORT=5175
|
||||
BACKEND_PORT=8010
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:8010/api/v1
|
||||
VITE_API_BASE_URL=http://localhost:8010
|
||||
@@ -0,0 +1,52 @@
|
||||
COMPOSE_PROJECT_NAME=edevs_hub
|
||||
|
||||
# Filled by GitLab deploy job or by hand on the server.
|
||||
HUB_BACKEND_IMAGE=registry.example.com/edevs/hub/backend:change-me
|
||||
HUB_FRONTEND_IMAGE=registry.example.com/edevs/hub/frontend:change-me
|
||||
HUB_FRONTEND_HOST_PORT=8080
|
||||
|
||||
HUB_ENV=production
|
||||
HUB_DEBUG=false
|
||||
HUB_SECRET_KEY=change-me-long-random-secret
|
||||
HUB_FIELD_ENCRYPTION_KEY=change-me-fernet-key
|
||||
|
||||
HUB_ALLOWED_HOSTS=hub.edevs.tech
|
||||
HUB_CSRF_TRUSTED_ORIGINS=https://hub.edevs.tech
|
||||
HUB_CORS_ALLOWED_ORIGINS=https://hub.edevs.tech
|
||||
INTERNAL_UI_BASE_URL=https://hub.edevs.tech
|
||||
|
||||
HUB_COOKIE_SECURE=true
|
||||
HUB_SSL_REDIRECT=true
|
||||
HUB_HSTS_SECONDS=31536000
|
||||
HUB_COOKIE_SAMESITE=Lax
|
||||
|
||||
POSTGRES_DB=edevs_hub
|
||||
POSTGRES_USER=edevs_hub
|
||||
POSTGRES_PASSWORD=change-me-db-password
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
HUB_AI_PROVIDER=openrouter
|
||||
HUB_OPENROUTER_API_KEY=
|
||||
HUB_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
HUB_AI_REQUEST_TIMEOUT=30
|
||||
HUB_AI_MAX_RETRIES=2
|
||||
HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
|
||||
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST=
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_USE_TLS=true
|
||||
DEFAULT_FROM_EMAIL=Edevs Hub <no-reply@edevs.tech>
|
||||
|
||||
# Required only for the first production seed when no OWNER exists yet.
|
||||
# The seed command never resets an existing user's password.
|
||||
HUB_SEED_OWNER_EMAIL=
|
||||
HUB_SEED_OWNER_PASSWORD=
|
||||
HUB_SEED_OWNER_NAME=
|
||||
|
||||
HUB_GUNICORN_WORKERS=3
|
||||
HUB_GUNICORN_TIMEOUT=60
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
stages:
|
||||
- validate
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
DOCKER_DRIVER: overlay2
|
||||
BACKEND_IMAGE: "$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA"
|
||||
FRONTEND_IMAGE: "$CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHA"
|
||||
BACKEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/backend:latest"
|
||||
FRONTEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/frontend:latest"
|
||||
|
||||
backend:test:
|
||||
stage: test
|
||||
image: python:3.12-slim
|
||||
services:
|
||||
- name: pgvector/pgvector:pg16
|
||||
alias: postgres
|
||||
- name: redis:7-alpine
|
||||
alias: redis
|
||||
variables:
|
||||
HUB_DEBUG: "true"
|
||||
HUB_SECRET_KEY: "ci-test-secret"
|
||||
HUB_FIELD_ENCRYPTION_KEY: ""
|
||||
POSTGRES_DB: "edevs_hub_test"
|
||||
POSTGRES_USER: "edevs_hub"
|
||||
POSTGRES_PASSWORD: "edevs_hub"
|
||||
POSTGRES_HOST: "postgres"
|
||||
POSTGRES_PORT: "5432"
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
before_script:
|
||||
- cd apps/backend
|
||||
- python -m pip install --upgrade pip
|
||||
- pip install -r requirements.txt
|
||||
script:
|
||||
- python manage.py check
|
||||
- python manage.py test
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
frontend:build:
|
||||
stage: validate
|
||||
image: node:22-alpine
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npm run typecheck
|
||||
- npm run build
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
images:build:
|
||||
stage: build
|
||||
image: docker:27
|
||||
services:
|
||||
- name: docker:27-dind
|
||||
alias: docker
|
||||
needs:
|
||||
- backend:test
|
||||
- frontend:build
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- docker build -f apps/backend/Dockerfile.production -t "$BACKEND_IMAGE" -t "$BACKEND_LATEST_IMAGE" .
|
||||
- docker build -f deploy/docker/frontend.Dockerfile -t "$FRONTEND_IMAGE" -t "$FRONTEND_LATEST_IMAGE" .
|
||||
- docker push "$BACKEND_IMAGE"
|
||||
- docker push "$FRONTEND_IMAGE"
|
||||
- |
|
||||
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
|
||||
docker push "$BACKEND_LATEST_IMAGE"
|
||||
docker push "$FRONTEND_LATEST_IMAGE"
|
||||
fi
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
|
||||
deploy:production:
|
||||
stage: deploy
|
||||
image: alpine:3.20
|
||||
needs:
|
||||
- images:build
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- chmod 700 ~/.ssh
|
||||
- printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
||||
script:
|
||||
- ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "sudo mkdir -p '$DEPLOY_PATH' && sudo chown '$DEPLOY_USER':'$DEPLOY_USER' '$DEPLOY_PATH'"
|
||||
- scp -P "${DEPLOY_PORT:-22}" compose.production.yaml "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/compose.production.yaml"
|
||||
- |
|
||||
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "printf '%s\n' \
|
||||
'HUB_BACKEND_IMAGE=$BACKEND_IMAGE' \
|
||||
'HUB_FRONTEND_IMAGE=$FRONTEND_IMAGE' \
|
||||
> '$DEPLOY_PATH/.env.release'"
|
||||
- |
|
||||
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && \
|
||||
echo '$CI_REGISTRY_PASSWORD' | sudo docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin && \
|
||||
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml pull && \
|
||||
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml up -d && \
|
||||
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py migrate --noinput && \
|
||||
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py collectstatic --noinput && \
|
||||
sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py seed_hub_initial_data"
|
||||
environment:
|
||||
name: production
|
||||
url: https://hub.edevs.tech
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
when: manual
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system hub && adduser --system --ingroup hub hub
|
||||
|
||||
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt
|
||||
|
||||
COPY apps/backend /app/apps/backend
|
||||
COPY content /app/content
|
||||
|
||||
RUN chown -R hub:hub /app
|
||||
|
||||
WORKDIR /app/apps/backend
|
||||
|
||||
USER hub
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "hub_backend.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60"]
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from django.core.management.base import CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from hub_platform.ai import documents as document_service
|
||||
from hub_platform.ai import indexing
|
||||
from hub_platform.ai.models import (
|
||||
DocumentScope,
|
||||
InclusionMode,
|
||||
KnowledgeCategory,
|
||||
KnowledgeDocument,
|
||||
KnowledgeDocumentVersion,
|
||||
PromptCategory,
|
||||
PromptDocument,
|
||||
PromptDocumentVersion,
|
||||
)
|
||||
from hub_platform.products.models import Product
|
||||
|
||||
DocumentKind = Literal["prompt", "knowledge"]
|
||||
_SECTION_RE = re.compile(r"^===\s*(?P<code>[a-z0-9-]+)\s*===\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContentSpec:
|
||||
kind: DocumentKind
|
||||
title: str
|
||||
category: str
|
||||
product_code: str | None = None
|
||||
inclusion_mode: str = InclusionMode.RETRIEVAL
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportResult:
|
||||
prompts_created: int = 0
|
||||
prompt_versions_created: int = 0
|
||||
knowledge_created: int = 0
|
||||
knowledge_versions_created: int = 0
|
||||
fragments_created: int = 0
|
||||
skipped_sections: int = 0
|
||||
|
||||
def add(self, other: ImportResult) -> ImportResult:
|
||||
return ImportResult(
|
||||
prompts_created=self.prompts_created + other.prompts_created,
|
||||
prompt_versions_created=self.prompt_versions_created + other.prompt_versions_created,
|
||||
knowledge_created=self.knowledge_created + other.knowledge_created,
|
||||
knowledge_versions_created=self.knowledge_versions_created
|
||||
+ other.knowledge_versions_created,
|
||||
fragments_created=self.fragments_created + other.fragments_created,
|
||||
skipped_sections=self.skipped_sections + other.skipped_sections,
|
||||
)
|
||||
|
||||
|
||||
PROMPT_CATEGORY_BY_PREFIX = {
|
||||
"system": PromptCategory.SYSTEM,
|
||||
"qualify": PromptCategory.QUALIFICATION,
|
||||
"sales": PromptCategory.SALES_BEHAVIOR,
|
||||
"handoff": PromptCategory.OPERATOR_HANDOFF,
|
||||
}
|
||||
|
||||
KNOWLEDGE_CATEGORY_BY_SUFFIX = {
|
||||
"overview": KnowledgeCategory.OVERVIEW,
|
||||
"products": KnowledgeCategory.OVERVIEW,
|
||||
"audience": KnowledgeCategory.AUDIENCE,
|
||||
"tariffs": KnowledgeCategory.COMMERCIAL,
|
||||
"catalog": KnowledgeCategory.COMMERCIAL,
|
||||
"technical": KnowledgeCategory.TECHNICAL,
|
||||
"faq": KnowledgeCategory.FAQ,
|
||||
"objections": KnowledgeCategory.OBJECTIONS,
|
||||
"limitations": KnowledgeCategory.LIMITATIONS,
|
||||
}
|
||||
|
||||
MANDATORY_KNOWLEDGE_CODES = {
|
||||
"company-overview",
|
||||
"company-products",
|
||||
"foxray-overview",
|
||||
"foxray-tariffs",
|
||||
"firepage-overview",
|
||||
"firepage-catalog",
|
||||
}
|
||||
|
||||
PRODUCT_CODE_BY_PREFIX = {
|
||||
"foxray": "foxray",
|
||||
"firepage": "firepage",
|
||||
}
|
||||
|
||||
CONTENT_SOURCES = (
|
||||
"content/ai-content-company-filled.md",
|
||||
"content/ai-content-firepage-filled.md",
|
||||
"content/ai-content-foxray-filled.md",
|
||||
)
|
||||
|
||||
|
||||
def parse_filled_content(text: str) -> dict[str, str]:
|
||||
matches = list(_SECTION_RE.finditer(text))
|
||||
sections: dict[str, str] = {}
|
||||
for index, match in enumerate(matches):
|
||||
code = match.group("code")
|
||||
start = match.end()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
content = text[start:end].strip()
|
||||
if content:
|
||||
sections[code] = content
|
||||
return sections
|
||||
|
||||
|
||||
def infer_content_spec(code: str) -> ContentSpec | None:
|
||||
parts = code.split("-")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
prompt_category = PROMPT_CATEGORY_BY_PREFIX.get(parts[0])
|
||||
product_code = PRODUCT_CODE_BY_PREFIX.get(parts[-1])
|
||||
if prompt_category:
|
||||
return ContentSpec(
|
||||
kind="prompt",
|
||||
title=code,
|
||||
category=prompt_category,
|
||||
product_code=product_code,
|
||||
)
|
||||
knowledge_category = KNOWLEDGE_CATEGORY_BY_SUFFIX.get(parts[-1])
|
||||
if knowledge_category is None:
|
||||
return None
|
||||
return ContentSpec(
|
||||
kind="knowledge",
|
||||
title=code,
|
||||
category=knowledge_category,
|
||||
product_code=PRODUCT_CODE_BY_PREFIX.get(parts[0]),
|
||||
inclusion_mode=InclusionMode.MANDATORY
|
||||
if code in MANDATORY_KNOWLEDGE_CODES
|
||||
else InclusionMode.RETRIEVAL,
|
||||
)
|
||||
|
||||
|
||||
def _next_version(version_model, document) -> int:
|
||||
latest = version_model.objects.filter(document=document).order_by("-version").first()
|
||||
return latest.version + 1 if latest else 1
|
||||
|
||||
|
||||
def _latest_published(version_model, document):
|
||||
return (
|
||||
version_model.objects.filter(document=document, status="PUBLISHED")
|
||||
.order_by("-version")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _resolve_product(*, organization, product_code: str | None) -> Product | None:
|
||||
if product_code is None:
|
||||
return None
|
||||
try:
|
||||
return Product.objects.get(organization=organization, code=product_code)
|
||||
except Product.DoesNotExist as error:
|
||||
raise CommandError(
|
||||
f"Product '{product_code}' is required before importing AI content"
|
||||
) from error
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _import_prompt(
|
||||
*, organization, author, code: str, content: str, spec: ContentSpec
|
||||
) -> ImportResult:
|
||||
product = _resolve_product(organization=organization, product_code=spec.product_code)
|
||||
scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL
|
||||
document, created = PromptDocument.objects.update_or_create(
|
||||
organization=organization,
|
||||
product=product,
|
||||
code=code,
|
||||
defaults={
|
||||
"title": spec.title,
|
||||
"category": spec.category,
|
||||
"scope": scope,
|
||||
"is_enabled": True,
|
||||
},
|
||||
)
|
||||
latest = _latest_published(PromptDocumentVersion, document)
|
||||
if latest and latest.content == content:
|
||||
return ImportResult(prompts_created=int(created))
|
||||
version = PromptDocumentVersion.objects.create(
|
||||
document=document,
|
||||
version=_next_version(PromptDocumentVersion, document),
|
||||
content=content,
|
||||
created_by=author,
|
||||
)
|
||||
document_service.publish_version(version=version)
|
||||
return ImportResult(prompts_created=int(created), prompt_versions_created=1)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _import_knowledge(
|
||||
*, organization, author, code: str, content: str, spec: ContentSpec
|
||||
) -> ImportResult:
|
||||
product = _resolve_product(organization=organization, product_code=spec.product_code)
|
||||
scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL
|
||||
document, created = KnowledgeDocument.objects.update_or_create(
|
||||
organization=organization,
|
||||
product=product,
|
||||
code=code,
|
||||
defaults={
|
||||
"title": spec.title,
|
||||
"category": spec.category,
|
||||
"scope": scope,
|
||||
"is_enabled": True,
|
||||
"inclusion_mode": spec.inclusion_mode,
|
||||
},
|
||||
)
|
||||
latest = _latest_published(KnowledgeDocumentVersion, document)
|
||||
if latest and latest.content == content:
|
||||
return ImportResult(knowledge_created=int(created))
|
||||
version = KnowledgeDocumentVersion.objects.create(
|
||||
document=document,
|
||||
version=_next_version(KnowledgeDocumentVersion, document),
|
||||
content=content,
|
||||
created_by=author,
|
||||
)
|
||||
document_service.publish_version(version=version)
|
||||
fragments = indexing.reindex_knowledge_version(version)
|
||||
return ImportResult(
|
||||
knowledge_created=int(created),
|
||||
knowledge_versions_created=1,
|
||||
fragments_created=len(fragments),
|
||||
)
|
||||
|
||||
|
||||
def ensure_channel_system_prompts(*, organization, author) -> ImportResult:
|
||||
from hub_platform.channels.models import Channel
|
||||
|
||||
result = ImportResult()
|
||||
for channel in Channel.objects.filter(organization=organization).select_related("product"):
|
||||
if not channel.system_prompt.strip():
|
||||
continue
|
||||
code = f"system-{channel.product.code}" if channel.product_id else "system-edevs"
|
||||
product = channel.product if channel.product_id else None
|
||||
if PromptDocument.objects.filter(
|
||||
organization=organization, product=product, code=code
|
||||
).exists():
|
||||
continue
|
||||
document = PromptDocument.objects.create(
|
||||
organization=organization,
|
||||
product=product,
|
||||
code=code,
|
||||
title=code,
|
||||
category=PromptCategory.SYSTEM,
|
||||
scope=DocumentScope.PRODUCT if product else DocumentScope.GLOBAL,
|
||||
is_enabled=True,
|
||||
)
|
||||
version = PromptDocumentVersion.objects.create(
|
||||
document=document,
|
||||
version=1,
|
||||
content=channel.system_prompt,
|
||||
created_by=author,
|
||||
)
|
||||
document_service.publish_version(version=version)
|
||||
result = result.add(ImportResult(prompts_created=1, prompt_versions_created=1))
|
||||
return result
|
||||
|
||||
|
||||
def import_ai_content(*, base_dir: Path, organization, author) -> ImportResult:
|
||||
result = ImportResult()
|
||||
for relative_path in CONTENT_SOURCES:
|
||||
path = base_dir / relative_path
|
||||
if not path.exists():
|
||||
continue
|
||||
for code, content in parse_filled_content(path.read_text(encoding="utf-8")).items():
|
||||
spec = infer_content_spec(code)
|
||||
if spec is None:
|
||||
result = result.add(ImportResult(skipped_sections=1))
|
||||
continue
|
||||
if spec.kind == "prompt":
|
||||
section_result = _import_prompt(
|
||||
organization=organization,
|
||||
author=author,
|
||||
code=code,
|
||||
content=content,
|
||||
spec=spec,
|
||||
)
|
||||
else:
|
||||
section_result = _import_knowledge(
|
||||
organization=organization,
|
||||
author=author,
|
||||
code=code,
|
||||
content=content,
|
||||
spec=spec,
|
||||
)
|
||||
result = result.add(section_result)
|
||||
return result
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from hub_platform.ai.models import (
|
||||
AIAgent,
|
||||
ChannelAIRelease,
|
||||
DocumentScope,
|
||||
DocumentStatus,
|
||||
KnowledgeDocumentVersion,
|
||||
PromptDocumentVersion,
|
||||
ReleaseKnowledgeVersion,
|
||||
ReleasePromptVersion,
|
||||
ReleaseStatus,
|
||||
)
|
||||
from hub_platform.ai.releases import publish_release
|
||||
from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel
|
||||
from hub_platform.identity.models import HumanUser, Organization
|
||||
|
||||
|
||||
def _release_version_ids(release: ChannelAIRelease) -> tuple[set[int], set[int]]:
|
||||
knowledge_ids = {link.knowledge_version_id for link in release.knowledge_versions.all()}
|
||||
prompt_ids = {link.prompt_version_id for link in release.prompt_versions.all()}
|
||||
return knowledge_ids, prompt_ids
|
||||
|
||||
|
||||
def _next_release_version(channel: Channel) -> int:
|
||||
latest = ChannelAIRelease.objects.filter(channel=channel).order_by("-version").first()
|
||||
return latest.version + 1 if latest else 1
|
||||
|
||||
|
||||
def _published_knowledge_versions(channel: Channel) -> list[KnowledgeDocumentVersion]:
|
||||
queryset = KnowledgeDocumentVersion.objects.filter(
|
||||
document__organization=channel.organization,
|
||||
document__is_enabled=True,
|
||||
status=DocumentStatus.PUBLISHED,
|
||||
)
|
||||
if channel.product_id:
|
||||
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter(
|
||||
document__product=channel.product
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL)
|
||||
return list(queryset.distinct().order_by("document__code", "-version"))
|
||||
|
||||
|
||||
def _published_prompt_versions(channel: Channel) -> list[PromptDocumentVersion]:
|
||||
queryset = PromptDocumentVersion.objects.filter(
|
||||
document__organization=channel.organization,
|
||||
document__is_enabled=True,
|
||||
status=DocumentStatus.PUBLISHED,
|
||||
)
|
||||
if channel.product_id:
|
||||
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter(
|
||||
document__product=channel.product
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL)
|
||||
selected: dict[str, PromptDocumentVersion] = {}
|
||||
for version in (
|
||||
queryset.select_related("document").distinct().order_by("document__category", "-version")
|
||||
):
|
||||
category = version.document.category
|
||||
current = selected.get(category)
|
||||
if current is None:
|
||||
selected[category] = version
|
||||
continue
|
||||
current_priority = int(bool(current.document.product_id))
|
||||
candidate_priority = int(bool(version.document.product_id))
|
||||
if (candidate_priority, version.version) > (current_priority, current.version):
|
||||
selected[category] = version
|
||||
return list(selected.values())
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _create_release_snapshot(
|
||||
*, channel: Channel, agent: AIAgent, author: HumanUser | None
|
||||
) -> ChannelAIRelease:
|
||||
release = ChannelAIRelease.objects.create(
|
||||
channel=channel,
|
||||
version=_next_release_version(channel),
|
||||
status=ReleaseStatus.DRAFT,
|
||||
model=agent.model,
|
||||
model_params=agent.model_params,
|
||||
allowed_tools=agent.allowed_tools,
|
||||
limits=agent.limits,
|
||||
retrieval_index_version="",
|
||||
notes="Production seed release",
|
||||
created_by=author,
|
||||
)
|
||||
ReleaseKnowledgeVersion.objects.bulk_create(
|
||||
[
|
||||
ReleaseKnowledgeVersion(release=release, knowledge_version=version)
|
||||
for version in _published_knowledge_versions(channel)
|
||||
]
|
||||
)
|
||||
ReleasePromptVersion.objects.bulk_create(
|
||||
[
|
||||
ReleasePromptVersion(release=release, prompt_version=version)
|
||||
for version in _published_prompt_versions(channel)
|
||||
]
|
||||
)
|
||||
return release
|
||||
|
||||
|
||||
def ensure_agents_and_releases(
|
||||
*, organization: Organization, author: HumanUser | None
|
||||
) -> tuple[int, int]:
|
||||
agents_created = 0
|
||||
releases_created = 0
|
||||
for channel in Channel.objects.filter(organization=organization).order_by("code"):
|
||||
agent, agent_created = AIAgent.objects.update_or_create(
|
||||
channel=channel,
|
||||
defaults={
|
||||
"name": f"{channel.name} Agent",
|
||||
"is_active": True,
|
||||
"model": channel.model or DEFAULT_CHANNEL_MODEL,
|
||||
},
|
||||
)
|
||||
agents_created += int(agent_created)
|
||||
draft = _create_release_snapshot(channel=channel, agent=agent, author=author)
|
||||
desired = _release_version_ids(draft)
|
||||
current = (
|
||||
ChannelAIRelease.objects.filter(channel=channel, status=ReleaseStatus.PUBLISHED)
|
||||
.prefetch_related("knowledge_versions", "prompt_versions")
|
||||
.order_by("-version")
|
||||
.first()
|
||||
)
|
||||
if current and current.model == agent.model and _release_version_ids(current) == desired:
|
||||
draft.delete()
|
||||
continue
|
||||
publish_release(release=draft)
|
||||
releases_created += 1
|
||||
return agents_created, releases_created
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.ai.content_importer import ensure_channel_system_prompts, import_ai_content
|
||||
from hub_platform.ai.seed_releases import ensure_agents_and_releases
|
||||
from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
from hub_platform.identity.models import (
|
||||
Department,
|
||||
EmployeeProfile,
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
)
|
||||
from hub_platform.products.models import Product, ProductDepartment
|
||||
|
||||
STYLE = (
|
||||
" Пиши простым текстом для мессенджера: без markdown-разметки, короткими абзацами, "
|
||||
"на русском, по делу. Не проводи оплату и не обещай условия вне базы знаний."
|
||||
)
|
||||
|
||||
CHANNEL_SPECS = (
|
||||
{
|
||||
"code": "edevs",
|
||||
"name": "Edevs — главный сайт",
|
||||
"product_code": None,
|
||||
"system_prompt": "Ты — AI-ассистент компании Edevs на её главном сайте." + STYLE,
|
||||
},
|
||||
{
|
||||
"code": "foxray-sales",
|
||||
"name": "FoxRay — продажи",
|
||||
"product_code": "foxray",
|
||||
"system_prompt": "Ты — AI sales-ассистент продукта FoxRay для ортодонтов." + STYLE,
|
||||
},
|
||||
{
|
||||
"code": "firepage-sales",
|
||||
"name": "FirePage — продажи",
|
||||
"product_code": "firepage",
|
||||
"system_prompt": "Ты — AI sales-ассистент продукта FirePage: готовые нишевые сайты."
|
||||
+ STYLE,
|
||||
},
|
||||
)
|
||||
|
||||
PRODUCT_SPECS = (
|
||||
{
|
||||
"code": "firepage",
|
||||
"name": "FirePage",
|
||||
"site_url": "https://firepage.ru",
|
||||
"summary": "Готовые нишевые сайты на собственной CMS с разовой лицензией.",
|
||||
},
|
||||
{
|
||||
"code": "foxray",
|
||||
"name": "FoxRay",
|
||||
"site_url": "https://foxray.pro",
|
||||
"summary": "Онлайн-сервис для цефалометрического анализа ТРГ.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoreSeedResult:
|
||||
organization: Organization
|
||||
sales_department: Department
|
||||
owner: HumanUser | None
|
||||
created_owner: bool
|
||||
|
||||
|
||||
def _option_or_env(options: dict, option_name: str, env_name: str) -> str:
|
||||
return str(options.get(option_name) or os.environ.get(env_name, "")).strip()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> CoreSeedResult:
|
||||
organization, _ = Organization.objects.update_or_create(
|
||||
slug="edevs",
|
||||
defaults={"name": "ООО «ЭДЕВС»", "timezone": "Europe/Moscow", "currency": "RUB"},
|
||||
)
|
||||
sales_department, _ = Department.objects.update_or_create(
|
||||
organization=organization,
|
||||
code="sales",
|
||||
defaults={"name": "Продажи"},
|
||||
)
|
||||
for spec in PRODUCT_SPECS:
|
||||
product, _ = Product.objects.update_or_create(
|
||||
organization=organization,
|
||||
code=spec["code"],
|
||||
defaults={
|
||||
"name": spec["name"],
|
||||
"site_url": spec["site_url"],
|
||||
"summary": spec["summary"],
|
||||
},
|
||||
)
|
||||
ProductDepartment.objects.get_or_create(product=product, department=sales_department)
|
||||
|
||||
owner = None
|
||||
created_owner = False
|
||||
existing_owner = (
|
||||
organization.employees.filter(role=EmployeeRole.OWNER).select_related("user").first()
|
||||
)
|
||||
if owner_email:
|
||||
normalized_email = HumanUser.objects.normalize_email(owner_email)
|
||||
owner, created_owner = HumanUser.objects.get_or_create(
|
||||
email=normalized_email,
|
||||
defaults={"full_name": owner_name, "is_staff": True, "is_superuser": True},
|
||||
)
|
||||
if created_owner:
|
||||
validate_password(owner_password, user=owner)
|
||||
owner.set_password(owner_password)
|
||||
owner.save(update_fields=["password"])
|
||||
changed_fields = []
|
||||
if owner_name and owner.full_name != owner_name:
|
||||
owner.full_name = owner_name
|
||||
changed_fields.append("full_name")
|
||||
if not owner.is_staff:
|
||||
owner.is_staff = True
|
||||
changed_fields.append("is_staff")
|
||||
if not owner.is_superuser:
|
||||
owner.is_superuser = True
|
||||
changed_fields.append("is_superuser")
|
||||
if changed_fields:
|
||||
owner.save(update_fields=changed_fields)
|
||||
EmployeeProfile.objects.update_or_create(
|
||||
user=owner,
|
||||
defaults={
|
||||
"organization": organization,
|
||||
"role": EmployeeRole.OWNER,
|
||||
"department": sales_department,
|
||||
"totp_required": False,
|
||||
},
|
||||
)
|
||||
elif existing_owner:
|
||||
owner = existing_owner.user
|
||||
else:
|
||||
raise CommandError(
|
||||
"OWNER is required for the first production seed. Set HUB_SEED_OWNER_EMAIL and "
|
||||
"HUB_SEED_OWNER_PASSWORD, or pass --owner-email and --owner-password."
|
||||
)
|
||||
|
||||
record_audit_event(
|
||||
organization=organization,
|
||||
actor=owner,
|
||||
action="identity.production_seed_applied",
|
||||
object_type="Organization",
|
||||
object_id=str(organization.id),
|
||||
payload={"created_owner": created_owner, "applied_at": timezone.now().isoformat()},
|
||||
)
|
||||
return CoreSeedResult(organization, sales_department, owner, created_owner)
|
||||
|
||||
|
||||
def _seed_channels(*, organization: Organization, department: Department) -> int:
|
||||
created = 0
|
||||
for spec in CHANNEL_SPECS:
|
||||
product = (
|
||||
Product.objects.get(organization=organization, code=spec["product_code"])
|
||||
if spec["product_code"]
|
||||
else None
|
||||
)
|
||||
_, was_created = Channel.objects.update_or_create(
|
||||
organization=organization,
|
||||
code=spec["code"],
|
||||
defaults={
|
||||
"name": spec["name"],
|
||||
"department": department,
|
||||
"product": product,
|
||||
"model": DEFAULT_CHANNEL_MODEL,
|
||||
"system_prompt": spec["system_prompt"],
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
created += int(was_created)
|
||||
return created
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed production Hub reference data, AI content and channel releases. Idempotent."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--owner-email", default="")
|
||||
parser.add_argument("--owner-password", default="")
|
||||
parser.add_argument("--owner-name", default="")
|
||||
|
||||
def handle(self, *args: object, **options: object) -> None:
|
||||
owner_email = _option_or_env(options, "owner_email", "HUB_SEED_OWNER_EMAIL")
|
||||
owner_password = _option_or_env(options, "owner_password", "HUB_SEED_OWNER_PASSWORD")
|
||||
owner_name = _option_or_env(options, "owner_name", "HUB_SEED_OWNER_NAME")
|
||||
if (
|
||||
owner_email
|
||||
and not owner_password
|
||||
and not Organization.objects.filter(employees__role=EmployeeRole.OWNER).exists()
|
||||
):
|
||||
raise CommandError("HUB_SEED_OWNER_PASSWORD is required when creating the first OWNER.")
|
||||
|
||||
core = _seed_core(
|
||||
owner_email=owner_email, owner_password=owner_password, owner_name=owner_name
|
||||
)
|
||||
call_command("seed_catalog", verbosity=0)
|
||||
channels_created = _seed_channels(
|
||||
organization=core.organization, department=core.sales_department
|
||||
)
|
||||
content_result = import_ai_content(
|
||||
base_dir=Path(__file__).resolve().parents[6],
|
||||
organization=core.organization,
|
||||
author=core.owner,
|
||||
)
|
||||
content_result = content_result.add(
|
||||
ensure_channel_system_prompts(organization=core.organization, author=core.owner)
|
||||
)
|
||||
agents_created, releases_created = ensure_agents_and_releases(
|
||||
organization=core.organization, author=core.owner
|
||||
)
|
||||
|
||||
owner_state = "created" if core.created_owner else "ready"
|
||||
prompt_stats = (
|
||||
f"{content_result.prompts_created}/{content_result.prompt_versions_created} versions"
|
||||
)
|
||||
knowledge_stats = (
|
||||
f"{content_result.knowledge_created}/"
|
||||
f"{content_result.knowledge_versions_created} versions"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"initial data seeded: "
|
||||
f"owner={owner_state}, "
|
||||
f"channels +{channels_created}, "
|
||||
f"agents +{agents_created}, "
|
||||
f"releases +{releases_created}, "
|
||||
f"prompts +{prompt_stats}, "
|
||||
f"knowledge +{knowledge_stats}, "
|
||||
f"fragments +{content_result.fragments_created}, "
|
||||
f"skipped {content_result.skipped_sections}"
|
||||
)
|
||||
)
|
||||
@@ -1,4 +1,12 @@
|
||||
const API_BASE = "http://localhost:8010";
|
||||
const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
|
||||
function resolveApiUrl(path: string): string {
|
||||
if (!configuredApiBase) return path;
|
||||
if (configuredApiBase.endsWith("/api/v1") && path.startsWith("/api/v1/")) {
|
||||
return `${configuredApiBase}${path.slice("/api/v1".length)}`;
|
||||
}
|
||||
return `${configuredApiBase}${path}`;
|
||||
}
|
||||
|
||||
function getCookie(name: string): string {
|
||||
const cookie = document.cookie
|
||||
@@ -15,7 +23,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("X-CSRFToken", getCookie("csrftoken"));
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
const response = await fetch(resolveApiUrl(path), {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers,
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,73 @@
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
|
||||
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.production
|
||||
command: >
|
||||
sh -c "gunicorn hub_backend.wsgi:application
|
||||
--bind 0.0.0.0:8000
|
||||
--workers $${HUB_GUNICORN_WORKERS:-3}
|
||||
--timeout $${HUB_GUNICORN_TIMEOUT:-60}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live/', timeout=3)",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
worker:
|
||||
image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.production
|
||||
command: python manage.py run_worker
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: ${HUB_FRONTEND_IMAGE:?HUB_FRONTEND_IMAGE is required}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${HUB_FRONTEND_HOST_PORT:-8080}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json tsconfig.base.json ./
|
||||
COPY apps/internal-ui/package.json ./apps/internal-ui/package.json
|
||||
COPY apps/web-chat/package.json ./apps/web-chat/package.json
|
||||
COPY packages/ui/package.json ./packages/ui/package.json
|
||||
COPY packages/contracts/package.json ./packages/contracts/package.json
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
RUN npm ci
|
||||
|
||||
COPY apps/internal-ui ./apps/internal-ui
|
||||
COPY apps/web-chat ./apps/web-chat
|
||||
COPY packages ./packages
|
||||
|
||||
ARG VITE_API_BASE_URL=
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
RUN npm --workspace @edevs/internal-ui run build
|
||||
RUN npm --workspace @edevs/web-chat run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY deploy/nginx/frontend.production.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/apps/internal-ui/dist /usr/share/nginx/html
|
||||
COPY --from=builder /app/apps/web-chat/dist /usr/share/nginx/html/chat
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,57 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
location = /chat-widget.js {
|
||||
proxy_pass http://backend:8000/chat-widget.js;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://backend:8000/admin/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8000/static/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /chat/ {
|
||||
try_files $uri $uri/ /chat/index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name hub.edevs.tech;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hub.edevs.tech;
|
||||
|
||||
# Set these paths after issuing the certificate with certbot.
|
||||
ssl_certificate /etc/letsencrypt/live/hub.edevs.tech/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hub.edevs.tech/privkey.pem;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
}
|
||||
@@ -30,18 +30,3 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name chat.localhost;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://web-chat:5175;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user