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

565 lines
24 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 json
import os
import uuid
from datetime import datetime, timezone
import requests
import streamlit as st
import yaml
# В духе 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.session_state.chat_messages = []
st.session_state.chat_hydrated = False
st.session_state.autonomous_pl_loaded = False
st.session_state.autonomous_phases = []
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("Портал АС/ФП · чат и генератор используют выбранную LLM модель.")
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": "",
}
if "chat_messages" not in st.session_state:
st.session_state.chat_messages = []
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 _new_autonomous_phase() -> dict:
return {
"id": uuid.uuid4().hex,
"name": "",
"description": "",
"jenkins": {"job": "", "server_url": "", "parameters": {}},
}
def _mermaid_autonomous(phases: list) -> str:
if not phases:
return "flowchart LR\n E[Нет фаз]"
lines = ["flowchart LR"]
for i, p in enumerate(phases):
label = (p.get("name") or f"Фаза {i + 1}").replace('"', "'").replace("\n", " ")[:48]
lines.append(f' P{i}["{label}"]')
for i in range(len(phases) - 1):
lines.append(f" P{i} --> P{i + 1}")
return "\n".join(lines)
def _collect_autonomous_for_save(phases: list, ver: int) -> tuple[list | None, str | None]:
out = []
for ph in phases:
pid = ph["id"]
j = ph.get("jenkins") or {}
default_pj = json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2)
raw = st.session_state.get(f"ap_pj_{pid}_{ver}", default_pj)
try:
params_obj = json.loads(raw) if str(raw).strip() else {}
if not isinstance(params_obj, dict):
raise TypeError("нужен JSON-объект { \"ключ\": \"значение\" }")
except Exception as e:
return None, f"Фаза «{ph.get('name') or pid}»: параметры Jenkins — {e}"
out.append(
{
"id": pid,
"name": st.session_state.get(f"ap_pn_{pid}_{ver}", ph.get("name", "")),
"description": st.session_state.get(f"ap_pd_{pid}_{ver}", ph.get("description", "")),
"jenkins": {
"job": st.session_state.get(f"ap_jj_{pid}_{ver}", j.get("job", "")),
"server_url": st.session_state.get(f"ap_js_{pid}_{ver}", j.get("server_url", "")),
"parameters": {str(k): str(v) for k, v in params_obj.items()},
},
}
)
return out, None
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.session_state.chat_messages = []
st.session_state.chat_hydrated = False
st.session_state.autonomous_pl_loaded = False
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()
# Подтянуть историю чата из Postgres (один раз после входа / смены пользователя)
if not st.session_state.get("chat_hydrated"):
try:
hr = requests.get(
f"{backend_base_url}/chat/history",
headers=_auth_headers(),
params={"limit": 500},
timeout=30,
)
if hr.ok:
st.session_state.chat_messages = hr.json().get("messages", [])
except Exception:
pass
st.session_state.chat_hydrated = True
if not st.session_state.get("autonomous_pl_loaded"):
st.session_state.autonomous_phases = []
st.session_state.autonomous_pl_title = ""
try:
ar = requests.get(
f"{backend_base_url}/pipelines/autonomous",
headers=_auth_headers(),
timeout=30,
)
if ar.ok:
data = ar.json()
st.session_state.autonomous_pl_title = data.get("title") or ""
phases = data.get("phases") or []
for ph in phases:
if not isinstance(ph, dict):
continue
ph.setdefault("id", uuid.uuid4().hex)
ph.setdefault("jenkins", {})
ph["jenkins"].setdefault("job", "")
ph["jenkins"].setdefault("server_url", "")
ph["jenkins"].setdefault("parameters", {})
st.session_state.autonomous_phases = [p for p in phases if isinstance(p, dict)]
except Exception:
pass
st.session_state.autonomous_pl_loaded = True
st.session_state.autonomous_pl_version = int(st.session_state.get("autonomous_pl_version", 0)) + 1
st.session_state.ap_global_title = st.session_state.get("autonomous_pl_title", "")
# После входа — тот же заголовок + вкладки
_title_bar(show_logout=True)
st.caption("Портал с данными и инструментами для АС/ФП.")
tab_chat, tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
[
"AI",
"Сведения об АС/ФП",
"Автономные внедрения",
"Инфраструктура АС/ФП",
"Инструменты",
"Новости",
]
)
with tab_chat:
st.subheader("AI")
st.caption(
f"Диалог через `{backend_base_url}/chat` → Ollama. Модель: **{model}**. "
"Переписка сохраняется в базе для вашего пользователя."
)
_, head_b = st.columns([4, 1])
with head_b:
def _clear_chat():
try:
requests.delete(
f"{backend_base_url}/chat/history",
headers=_auth_headers(),
timeout=30,
)
except Exception:
pass
st.session_state.chat_messages = []
st.rerun()
st.button("Очистить чат", on_click=_clear_chat, use_container_width=True, key="btn_clear_chat")
# Только лента сообщений внутри рамки; st.chat_input — снаружи, чтобы строка была внизу экрана
with st.container(border=True):
for m in st.session_state.chat_messages:
with st.chat_message(m["role"]):
st.markdown(m["content"].replace("$", r"\$"))
user_text = st.chat_input("Напишите сообщение…", key="chat_input_portal")
if user_text:
st.session_state.chat_messages.append({"role": "user", "content": user_text})
reply = ""
with st.spinner("Ollama отвечает…"):
try:
r = requests.post(
f"{backend_base_url}/chat",
json={
"model": model,
"messages": st.session_state.chat_messages,
},
headers=_auth_headers(),
timeout=120,
)
if r.status_code >= 400:
reply = f"**Ошибка:** {_detail_or_text(r)}"
else:
reply = r.json().get("content") or ""
except Exception as e:
reply = f"**Ошибка сети:** {e}"
st.session_state.chat_messages.append({"role": "assistant", "content": reply})
st.rerun()
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("Автономные внедрения")
st.caption(
"Low-code конвейер: фазы идут **слева направо**; под каждой фазой задаётся вызов **Jenkins** и параметры. "
"Идея близка к DPM / Orchestra R ([статья на Хабре](https://habr.com/ru/companies/sberbank/articles/855556/), "
"[документация Platform V DPM](https://sbertech.ru/documents/platform-v-devops-pipeline-management/4.0.0))."
)
ver = int(st.session_state.get("autonomous_pl_version", 0))
phases: list = st.session_state.autonomous_phases
c_head1, c_head2, c_head3 = st.columns([2, 1, 1])
with c_head1:
if "ap_global_title" not in st.session_state:
st.session_state.ap_global_title = st.session_state.get("autonomous_pl_title", "")
st.text_input(
"Название конвейера",
key="ap_global_title",
help="Сохраняется вместе с фазами в базе (один черновик на пользователя).",
)
with c_head2:
if st.button("Добавить фазу", use_container_width=True, key="ap_add_phase"):
phases.append(_new_autonomous_phase())
st.session_state.autonomous_phases = phases
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
with c_head3:
if st.button("Перезагрузить с сервера", use_container_width=True, key="ap_reload"):
st.session_state.autonomous_pl_loaded = False
st.rerun()
with st.expander("Схема потока (Mermaid)", expanded=False):
st.markdown(f"```mermaid\n{_mermaid_autonomous(phases)}\n```")
for idx, ph in enumerate(phases):
pid = ph["id"]
j = ph.setdefault("jenkins", {})
j.setdefault("parameters", {})
st.session_state.setdefault(f"ap_pn_{pid}_{ver}", ph.get("name", ""))
st.session_state.setdefault(f"ap_pd_{pid}_{ver}", ph.get("description", ""))
st.session_state.setdefault(f"ap_jj_{pid}_{ver}", j.get("job", ""))
st.session_state.setdefault(f"ap_js_{pid}_{ver}", j.get("server_url", ""))
st.session_state.setdefault(
f"ap_pj_{pid}_{ver}",
json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2),
)
title_ph = st.session_state.get(f"ap_pn_{pid}_{ver}", "") or f"Фаза {idx + 1}"
with st.expander(f"{title_ph}", expanded=(idx == len(phases) - 1)):
st.markdown("**Фаза (low-code)**")
st.text_input("Название", key=f"ap_pn_{pid}_{ver}")
st.text_area("Описание / что проверяется на этапе", key=f"ap_pd_{pid}_{ver}", height=68)
st.divider()
st.markdown("**Вызов Jenkins**")
st.caption("Укажите job (путь в Jenkins) и при необходимости базовый URL контроллера; параметры уйдут в job как build parameters.")
st.text_input("Job / путь", key=f"ap_jj_{pid}_{ver}", placeholder="folder/subfolder/job-name")
st.text_input("Jenkins URL (если не дефолтный)", key=f"ap_js_{pid}_{ver}", placeholder="https://jenkins.example.com/")
st.text_area(
"Параметры (JSON-объект)",
key=f"ap_pj_{pid}_{ver}",
height=120,
help='Например: {"BRANCH": "main", "ENV": "staging"}',
)
b1, b2, b3 = st.columns(3)
with b1:
if st.button("Вверх", key=f"ap_up_{pid}_{ver}", disabled=idx == 0):
phases[idx - 1], phases[idx] = phases[idx], phases[idx - 1]
st.session_state.autonomous_phases = phases
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
with b2:
if st.button("Вниз", key=f"ap_dn_{pid}_{ver}", disabled=idx >= len(phases) - 1):
phases[idx + 1], phases[idx] = phases[idx], phases[idx + 1]
st.session_state.autonomous_phases = phases
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
with b3:
if st.button("Удалить фазу", key=f"ap_rm_{pid}_{ver}"):
st.session_state.autonomous_phases = [p for p in phases if p["id"] != pid]
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
save_col1, save_col2 = st.columns([1, 2])
with save_col1:
if st.button("Сохранить в базу", type="primary", use_container_width=True, key="ap_save_db"):
to_save, err = _collect_autonomous_for_save(phases, ver)
if err:
st.error(err)
else:
try:
body = {
"title": st.session_state.get("ap_global_title", ""),
"phases": to_save,
}
r = requests.put(
f"{backend_base_url}/pipelines/autonomous",
json=body,
headers=_auth_headers(),
timeout=60,
)
if r.status_code >= 400:
st.error(_detail_or_text(r))
else:
data = r.json()
st.session_state.autonomous_pl_title = data.get("title", "")
st.session_state.autonomous_phases = data.get("phases", [])
st.session_state.ap_global_title = data.get("title", "")
st.session_state.autonomous_pl_version = ver + 1
st.success("Сохранено.")
st.rerun()
except Exception as e:
st.error(str(e))
with save_col2:
st.caption("Сохранение: `PUT /pipelines/autonomous` → таблица `autonomous_pipelines` (Postgres).")
with st.expander("Экспорт YAML (черновик)", expanded=False):
snap, err = _collect_autonomous_for_save(phases, ver)
if err:
st.warning(err)
else:
doc = {"title": st.session_state.get("ap_global_title", ""), "phases": snap}
st.code(yaml.safe_dump(doc, allow_unicode=True, sort_keys=False), language="yaml")
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 — внутренний портал.",
)