Files
devops-portal/frontend/ui.py
2026-04-10 00:01:42 +03:00

291 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from datetime import datetime, timezone
import requests
import streamlit as st
# В духе https://github.com/streamlit/demo-ai-assistant — крупный герой, заголовок в ряд с действием
st.set_page_config(
page_title="DevOps AI Assistant",
page_icon="",
layout="wide",
initial_sidebar_state="expanded",
)
_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:
value = os.getenv(name)
return value if value else default
default_backend_base_url = _env("BACKEND_BASE_URL", "http://backend:8000")
def _normalize_base_url(value: str) -> str:
v = (value or "").strip().rstrip("/")
if v.endswith("/generate"):
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.markdown("#### Настройки")
backend_base_url = _normalize_base_url(st.text_input("Backend URL", default_backend_base_url))
model = st.selectbox(
"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 = []
if "token" not in st.session_state:
st.session_state.token = None
if "asfp_info" not in st.session_state:
st.session_state.asfp_info = {
"name": "",
"owner": "",
"contacts": "",
"description": "",
}
def _push_history(*, platform: str, model: str, prompt: str, status: str, config: str, error: str | None):
st.session_state.history.insert(
0,
{
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
"platform": platform,
"model": model,
"prompt": prompt,
"status": status,
"error": error,
"config": config,
},
)
st.session_state.history = st.session_state.history[:50]
def _auth_headers():
if not st.session_state.token:
return {}
return {"Authorization": f"Bearer {st.session_state.token}"}
def _detail_or_text(resp: requests.Response) -> str:
try:
payload = resp.json()
if isinstance(payload, dict) and payload.get("detail"):
return str(payload["detail"])
except Exception:
pass
return resp.text
def _show_account():
_title_bar(show_logout=bool(st.session_state.token))
st.caption("Войдите или создайте учётную запись, чтобы открыть разделы портала.")
# Два блока с рамкой — ближе к «карточному» виду демо
c1, c2 = st.columns(2, gap="large")
with c1:
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:
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",
json={"email": login_email, "password": login_password},
timeout=30,
)
if r.status_code >= 400:
st.error(f"Ошибка входа: {_detail_or_text(r)}")
else:
st.session_state.token = r.json().get("access_token")
st.success("Успешный вход.")
st.rerun()
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
st.divider()
if st.session_state.token:
try:
r = requests.get(f"{backend_base_url}/auth/me", headers=_auth_headers(), timeout=20)
if r.status_code >= 400:
st.session_state.token = None
st.warning("Сессия истекла или токен неверный. Войдите заново.")
st.rerun()
else:
with st.expander("Профиль", expanded=False):
st.json(r.json())
except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}")
# 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(
[
"Сведения об АС/ФП",
"Автономные внедрения",
"Инфраструктура АС/ФП",
"Инструменты",
"Новости",
]
)
with tab_info:
st.subheader("Сведения об АС/ФП")
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("Автономные внедрения")
with st.container(border=True):
st.info(
"Сюда можно добавить список внедрений, статус, даты, ответственных и ссылки на релизы/тикеты."
)
with tab_infra:
st.subheader("Инфраструктура АС/ФП")
with st.container(border=True):
st.info(
"Сюда можно добавить окружения, кластера, сервисы, ресурсы, сетевые зависимости и т.д."
)
with tab_tools:
st.subheader("Инструменты")
st.caption(f"Backend: `{backend_base_url}`")
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", 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()
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,
)
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("Пожалуйста, введите описание.")
with tab_news:
st.subheader("Новости")
with st.container(border=True):
st.info(
"Релизы, инциденты, регламентные работы, обновления инструментов — сюда можно вывести ленту позже."
)
st.divider()
st.button(
"&nbsp;:small[:gray[:material/balance: О портале]]",
type="tertiary",
help="DevOps AI Assistant — внутренний портал.",
)