This commit is contained in:
2026-04-10 00:01:42 +03:00
parent 1b8c03d6c3
commit f01e3b9040
4 changed files with 161 additions and 95 deletions

View File

@@ -12,10 +12,10 @@ from sqlalchemy.orm import Session
from backend.db import get_db
from backend.models import User
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
bearer = HTTPBearer(auto_error=False)
AUTH_VERSION = "argon2-2026-04-07"
AUTH_VERSION = "argon2-bcrypt-compat-2026-04-07"
def _env(name: str, default: str) -> str:
@@ -48,6 +48,10 @@ def verify_password(password: str, password_hash: str) -> bool:
) from e
def password_needs_update(password_hash: str) -> bool:
return pwd_context.needs_update(password_hash)
def create_access_token(*, user_id: str) -> str:
now = datetime.now(timezone.utc)
payload = {

View File

@@ -27,6 +27,7 @@ from backend.auth import ( # noqa: E402
create_access_token,
get_current_user,
hash_password,
password_needs_update,
verify_password,
)
from backend.db import engine, get_db # noqa: E402
@@ -130,6 +131,11 @@ def login(req: LoginRequest, db: Session = Depends(get_db)):
if not user.is_active:
raise HTTPException(status_code=403, detail="User is inactive")
if password_needs_update(user.password_hash):
user.password_hash = hash_password(req.password)
db.add(user)
db.commit()
token = create_access_token(user_id=str(user.id))
return {"access_token": token, "token_type": "bearer"}
except HTTPException:

View File

@@ -6,5 +6,7 @@ sqlalchemy
psycopg[binary]
passlib[argon2]
argon2-cffi
passlib[bcrypt]
bcrypt
python-jose[cryptography]
pydantic[email]

View File

@@ -4,10 +4,21 @@ from datetime import datetime, timezone
import requests
import streamlit as st
st.set_page_config(page_title="DevOps AI Assistant", page_icon="🚀")
# В духе https://github.com/streamlit/demo-ai-assistant — крупный герой, заголовок в ряд с действием
st.set_page_config(
page_title="DevOps AI Assistant",
page_icon="",
layout="wide",
initial_sidebar_state="expanded",
)
st.title("DevOps AI Assistant")
st.markdown("Портал с данными и инструментами для АС/ФП.")
_HERO_HTML = """
<style>
.portal-hero-wrap { margin: 0 0 0.25rem 0; }
.portal-hero { font-size: clamp(2.5rem, 6vw, 3.5rem); line-height: 1; margin: 0; font-weight: 400; }
</style>
<div class="portal-hero-wrap"><div class="portal-hero">❉</div></div>
"""
def _env(name: str, default: str) -> str:
@@ -24,14 +35,36 @@ def _normalize_base_url(value: str) -> str:
v = v[: -len("/generate")]
return v.rstrip("/")
def _title_bar(*, show_logout: bool):
st.html(_HERO_HTML)
row = st.container(horizontal=True, vertical_alignment="bottom")
with row:
st.title("DevOps AI Assistant", anchor=False)
if show_logout:
def _logout():
st.session_state.token = None
st.rerun()
st.button(
"Выйти",
icon=":material/logout:",
on_click=_logout,
help="Выйти из портала",
)
with st.sidebar:
st.header("Настройки")
st.markdown("#### Настройки")
backend_base_url = _normalize_base_url(st.text_input("Backend URL", default_backend_base_url))
model = st.selectbox(
"LLM Модель",
"LLM модель",
["deepseek-r1:1.5b", "qwen2.5-coder:1.5b", "llama3.1:8b", "Arena Model"],
)
platform = st.radio("Платформа", ["PV DOT CI", "PV DOT CD", "DPM CI", "DPM CD"])
st.divider()
st.caption("Портал АС/ФП · конфигурации в разделе «Инструменты»")
if "history" not in st.session_state:
st.session_state.history = []
@@ -67,6 +100,7 @@ def _auth_headers():
return {}
return {"Authorization": f"Bearer {st.session_state.token}"}
def _detail_or_text(resp: requests.Response) -> str:
try:
payload = resp.json()
@@ -78,35 +112,37 @@ def _detail_or_text(resp: requests.Response) -> str:
def _show_account():
st.subheader("Аутентификация")
_title_bar(show_logout=bool(st.session_state.token))
st.caption("Войдите или создайте учётную запись, чтобы открыть разделы портала.")
c1, c2 = st.columns(2)
# Два блока с рамкой — ближе к «карточному» виду демо
c1, c2 = st.columns(2, gap="large")
with c1:
st.markdown("**Регистрация**")
reg_email = st.text_input("Email", key="reg_email")
reg_full_name = st.text_input("Имя (опционально)", key="reg_full_name")
reg_password = st.text_input("Пароль", type="password", key="reg_password")
if st.button("Создать аккаунт", key="btn_register"):
try:
r = requests.post(
f"{backend_base_url}/auth/register",
json={"email": reg_email, "password": reg_password, "full_name": reg_full_name or None},
timeout=30,
)
if r.status_code >= 400:
st.error(f"Ошибка регистрации: {_detail_or_text(r)}")
else:
st.success("Аккаунт создан. Теперь можно войти.")
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
with st.container(border=True):
st.markdown("**Регистрация**")
reg_email = st.text_input("Email", key="reg_email")
reg_full_name = st.text_input("Имя (опционально)", key="reg_full_name")
reg_password = st.text_input("Пароль", type="password", key="reg_password")
if st.button("Создать аккаунт", key="btn_register", use_container_width=True):
try:
r = requests.post(
f"{backend_base_url}/auth/register",
json={"email": reg_email, "password": reg_password, "full_name": reg_full_name or None},
timeout=30,
)
if r.status_code >= 400:
st.error(f"Ошибка регистрации: {_detail_or_text(r)}")
else:
st.success("Аккаунт создан. Теперь можно войти.")
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
with c2:
st.markdown("**Вход**")
login_email = st.text_input("Email ", key="login_email")
login_password = st.text_input("Пароль ", type="password", key="login_password")
login_col_a, login_col_b = st.columns([1, 1])
with login_col_a:
if st.button("Войти", type="primary", key="btn_login"):
with st.container(border=True):
st.markdown("**Вход**")
login_email = st.text_input("Email ", key="login_email")
login_password = st.text_input("Пароль ", type="password", key="login_password")
if st.button("Войти", type="primary", key="btn_login", use_container_width=True):
try:
r = requests.post(
f"{backend_base_url}/auth/login",
@@ -121,10 +157,6 @@ def _show_account():
st.rerun()
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
with login_col_b:
if st.button("Выйти", disabled=not bool(st.session_state.token), key="btn_logout"):
st.session_state.token = None
st.rerun()
st.divider()
if st.session_state.token:
@@ -135,16 +167,20 @@ def _show_account():
st.warning("Сессия истекла или токен неверный. Войдите заново.")
st.rerun()
else:
st.json(r.json())
with st.expander("Профиль", expanded=False):
st.json(r.json())
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
# Auth wall: пока пользователь не вошёл — показываем только экран входа/регистрации.
# Auth wall
if not st.session_state.token:
_show_account()
st.stop()
# После входа — тот же заголовок + вкладки
_title_bar(show_logout=True)
st.caption("Портал с данными и инструментами для АС/ФП.")
tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
[
@@ -158,79 +194,97 @@ tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
with tab_info:
st.subheader("Сведения об АС/ФП")
st.caption("Черновик формы. Дальше можно привязать к Postgres и хранить как сущность.")
st.session_state.asfp_info["name"] = st.text_input("Название", st.session_state.asfp_info["name"])
st.session_state.asfp_info["owner"] = st.text_input("Владелец", st.session_state.asfp_info["owner"])
st.session_state.asfp_info["contacts"] = st.text_input("Контакты", st.session_state.asfp_info["contacts"])
st.session_state.asfp_info["description"] = st.text_area(
"Описание", st.session_state.asfp_info["description"], height=120
)
st.divider()
st.json(st.session_state.asfp_info)
st.caption("Черновик формы. Дальше можно привязать к Postgres.")
with st.container(border=True):
st.session_state.asfp_info["name"] = st.text_input("Название", st.session_state.asfp_info["name"])
st.session_state.asfp_info["owner"] = st.text_input("Владелец", st.session_state.asfp_info["owner"])
st.session_state.asfp_info["contacts"] = st.text_input("Контакты", st.session_state.asfp_info["contacts"])
st.session_state.asfp_info["description"] = st.text_area(
"Описание", st.session_state.asfp_info["description"], height=120
)
with st.expander("JSON", expanded=False):
st.json(st.session_state.asfp_info)
with tab_autonomous:
st.subheader("Автономные внедрения")
st.info("Сюда можно добавить список внедрений, статус, даты, ответственных и ссылки на релизы/тикеты.")
with st.container(border=True):
st.info(
"Сюда можно добавить список внедрений, статус, даты, ответственных и ссылки на релизы/тикеты."
)
with tab_infra:
st.subheader("Инфраструктура АС/ФП")
st.info("Сюда можно добавить окружения, кластера, сервисы, ресурсы, сетевые зависимости, секреты и т.д.")
with st.container(border=True):
st.info(
"Сюда можно добавить окружения, кластера, сервисы, ресурсы, сетевые зависимости и т.д."
)
with tab_tools:
st.subheader("Инструменты")
st.caption(f"Backend URL: {backend_base_url}")
st.caption(f"Backend: `{backend_base_url}`")
st.markdown("### Pipeline generator")
user_input = st.text_area(
"Опишите пайплайн (например: 'Python app with pytest and docker build')",
height=150,
key="generator_prompt",
)
with st.container(border=True):
st.markdown("#### Pipeline generator")
user_input = st.text_area(
"Опишите пайплайн (например: 'Python app with pytest and docker build')",
height=150,
key="generator_prompt",
)
col_a, col_b = st.columns([1, 3])
with col_a:
generate_clicked = st.button("Сгенерировать конфигурацию", type="primary")
with col_b:
st.caption("Подсказка: чем конкретнее требования, тем валиднее YAML.")
col_a, col_b = st.columns([1, 3])
with col_a:
generate_clicked = st.button("Сгенерировать", type="primary", use_container_width=True)
with col_b:
st.caption("Чем конкретнее требования, тем стабильнее YAML.")
if generate_clicked:
if user_input.strip():
with st.spinner("LLM думает..."):
try:
res = requests.post(
f"{backend_base_url}/generate",
json={"description": user_input, "platform": platform, "model": model},
headers=_auth_headers(),
timeout=90,
)
res.raise_for_status()
data = res.json()
if generate_clicked:
if user_input.strip():
with st.spinner("LLM думает..."):
try:
res = requests.post(
f"{backend_base_url}/generate",
json={"description": user_input, "platform": platform, "model": model},
headers=_auth_headers(),
timeout=90,
)
res.raise_for_status()
data = res.json()
status = data.get("status", "error")
config = data.get("config", "")
err = data.get("validation_error")
status = data.get("status", "error")
config = data.get("config", "")
err = data.get("validation_error")
_push_history(
platform=platform,
model=model,
prompt=user_input,
status=status,
config=config,
error=err,
)
_push_history(
platform=platform,
model=model,
prompt=user_input,
status=status,
config=config,
error=err,
)
if status == "ok":
st.success("Конфигурация валидна.")
else:
st.error(f"Ошибка валидации: {err}")
if status == "ok":
st.success("Конфигурация валидна.")
else:
st.error(f"Ошибка валидации: {err}")
st.code(config, language="yaml")
st.download_button("Скачать .yml", config, file_name="pipeline.yml")
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
else:
st.warning("Пожалуйста, введите описание.")
st.code(config, language="yaml")
st.download_button("Скачать .yml", config, file_name="pipeline.yml")
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
else:
st.warning("Пожалуйста, введите описание.")
with tab_news:
st.subheader("Новости")
st.info("Сюда можно вывести новости/изменения: релизы, инциденты, регламентные работы, обновления инструментов.")
with st.container(border=True):
st.info(
"Релизы, инциденты, регламентные работы, обновления инструментов — сюда можно вывести ленту позже."
)
st.divider()
st.button(
"&nbsp;:small[:gray[:material/balance: О портале]]",
type="tertiary",
help="DevOps AI Assistant — внутренний портал.",
)