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

852 lines
38 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_stages = []
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 _new_autonomous_stage() -> dict:
return {
"id": uuid.uuid4().hex,
"name": "",
"description": "",
"checks": [],
"phases": [],
}
def _mermaid_stages(stages: list) -> str:
if not stages:
return "flowchart LR\n E[Нет этапов]"
lines = ["flowchart LR"]
for i, s in enumerate(stages):
sn = (s.get("name") or f"Этап {i + 1}").replace('"', "'").replace("\n", " ")[:40]
nph = len(s.get("phases") or [])
lines.append(f' S{i}["{sn}\\n({nph} фаз)"]')
for i in range(len(stages) - 1):
lines.append(f" S{i} --> S{i + 1}")
return "\n".join(lines)
def _collect_phases_for_save(phases: list, ver: int, sid: str) -> 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_{sid}_{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_{sid}_{pid}_{ver}", ph.get("name", "")),
"description": st.session_state.get(f"ap_pd_{sid}_{pid}_{ver}", ph.get("description", "")),
"jenkins": {
"job": st.session_state.get(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", "")),
"server_url": st.session_state.get(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", "")),
"parameters": {str(k): str(v) for k, v in params_obj.items()},
},
}
)
return out, None
def _collect_stages_for_save(stages: list, ver: int) -> tuple[list | None, str | None]:
out_stages = []
for stg in stages:
sid = stg["id"]
default_ch = json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2)
raw_ch = st.session_state.get(f"st_ch_{sid}_{ver}", default_ch)
try:
checks_raw = json.loads(raw_ch) if str(raw_ch).strip() else []
if not isinstance(checks_raw, list):
raise TypeError("нужен JSON-массив")
checks = []
for c in checks_raw:
if not isinstance(c, dict):
continue
checks.append(
{
"variable": str(c.get("variable", "")),
"expected": str(c.get("expected", "")),
}
)
except Exception as e:
return None, f"Этап «{stg.get('name') or sid}»: проверки перехода — {e}"
ph_list, err = _collect_phases_for_save(stg.get("phases") or [], ver, sid)
if err:
return None, err
out_stages.append(
{
"id": sid,
"name": st.session_state.get(f"st_nm_{sid}_{ver}", stg.get("name", "")),
"description": st.session_state.get(f"st_dc_{sid}_{ver}", stg.get("description", "")),
"checks": checks,
"phases": ph_list,
}
)
return out_stages, None
def _parse_gate_checks_session(sid: str, ver: int, stg: dict) -> tuple[list | None, str | None]:
default_ch = json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2)
raw_ch = st.session_state.get(f"st_ch_{sid}_{ver}", default_ch)
try:
checks_raw = json.loads(raw_ch) if str(raw_ch).strip() else []
if not isinstance(checks_raw, list):
raise TypeError("нужен JSON-массив")
checks = []
for c in checks_raw:
if not isinstance(c, dict):
continue
checks.append(
{
"variable": str(c.get("variable", "")),
"expected": str(c.get("expected", "")),
}
)
return checks, None
except Exception as e:
return None, str(e)
def _collect_flat_jenkins_params_for_stage(stg: dict, ver: int, sid: str) -> dict[str, str]:
"""Все параметры JSON из фаз этапа (последняя фаза перекрывает ключи предыдущих при совпадении имён)."""
out: dict[str, str] = {}
for ph in stg.get("phases") or []:
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_{sid}_{pid}_{ver}", default_pj)
try:
params_obj = json.loads(raw) if str(raw).strip() else {}
if isinstance(params_obj, dict):
for k, v in params_obj.items():
out[str(k)] = str(v)
except Exception:
pass
return out
def _phase_for_jenkins_trigger(ph: dict, ver: int, sid: str) -> tuple[dict | None, str | None]:
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_{sid}_{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"Параметры Jenkins: {e}"
job = st.session_state.get(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", "")).strip()
if not job:
return None, "Укажите Job / путь."
surl = st.session_state.get(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", "")).strip()
return (
{
"server_url": surl,
"job": job,
"parameters": {str(k): str(v) for k, v in params_obj.items()},
},
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_stages = []
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 ""
stages = data.get("stages") or []
for stg in stages:
if not isinstance(stg, dict):
continue
stg.setdefault("id", uuid.uuid4().hex)
stg.setdefault("name", "")
stg.setdefault("description", "")
stg.setdefault("checks", [])
stg.setdefault("phases", [])
for ph in stg.get("phases") or []:
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_stages = [s for s in stages if isinstance(s, 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(
"Иерархия: **конвейер → этапы (стенды) → фазы**. На этапе задаются **проверки** (переменная и ожидаемое значение) "
"для перехода к следующему этапу; внутри этапа фазы идут по порядку, с вызовом **Jenkins**. "
"См. DPM / Orchestra R ([Хабр](https://habr.com/ru/companies/sberbank/articles/855556/), "
"[DPM](https://sbertech.ru/documents/platform-v-devops-pipeline-management/4.0.0))."
)
ver = int(st.session_state.get("autonomous_pl_version", 0))
stages: list = st.session_state.autonomous_stages
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="Один черновик на пользователя в Postgres.",
)
with c_head2:
if st.button("Добавить этап (стенд)", use_container_width=True, key="ap_add_stage"):
stages.append(_new_autonomous_stage())
st.session_state.autonomous_stages = stages
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_stages(stages)}\n```")
for sidx, stg in enumerate(stages):
sid = stg["id"]
stg.setdefault("phases", [])
stg.setdefault("checks", [])
st.session_state.setdefault(f"st_nm_{sid}_{ver}", stg.get("name", ""))
st.session_state.setdefault(f"st_dc_{sid}_{ver}", stg.get("description", ""))
st.session_state.setdefault(
f"st_ch_{sid}_{ver}",
json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2),
)
st_title = st.session_state.get(f"st_nm_{sid}_{ver}", "") or f"Этап {sidx + 1}"
with st.expander(f"{st_title} (стенд / этап)", expanded=(sidx == len(stages) - 1)):
st.markdown("**Этап** — логический стенд; здесь же задаются **условия перехода** на следующий этап.")
st.text_input("Наименование этапа / стенда", key=f"st_nm_{sid}_{ver}")
st.text_area("Описание этапа", key=f"st_dc_{sid}_{ver}", height=56)
st.markdown("**Проверки для перехода на следующий этап**")
st.caption(
"JSON-массив `variable` / `expected`. Ниже задаются фактические значения; они объединяются с параметрами Jenkins фаз этого этапа (ручной JSON перекрывает параметры)."
)
st.text_area(
"Проверки (JSON)",
key=f"st_ch_{sid}_{ver}",
height=100,
help='Пример: [{"variable":"DEPLOY_STATUS","expected":"OK"},{"variable":"ENV","expected":"dev"}]',
)
st.session_state.setdefault(f"st_val_{sid}_{ver}", "{}")
st.markdown("**Фактические значения для сравнения**")
st.text_area(
"Значения переменных (JSON-объект)",
key=f"st_val_{sid}_{ver}",
height=88,
help='Например: {"DEPLOY_STATUS":"OK","ENV":"dev"}. Пустые строки можно добить шаблоном.',
)
gv1, gv2 = st.columns(2)
with gv1:
if st.button("Шаблон ключей из проверок", key=f"st_tpl_{sid}_{ver}", use_container_width=True):
chk, e2 = _parse_gate_checks_session(sid, ver, stg)
if e2:
st.error(f"Проверки: {e2}")
else:
tpl = {
str(c.get("variable", "")).strip(): ""
for c in (chk or [])
if str(c.get("variable", "")).strip()
}
st.session_state[f"st_val_{sid}_{ver}"] = json.dumps(
tpl, ensure_ascii=False, indent=2
)
st.rerun()
with gv2:
if st.button(
"Проверить условия перехода",
key=f"st_vg_{sid}_{ver}",
type="primary",
use_container_width=True,
):
chk, e2 = _parse_gate_checks_session(sid, ver, stg)
if e2:
st.error(f"Проверки: {e2}")
elif not chk or not any(str(c.get("variable", "")).strip() for c in chk):
st.info("Проверки не заданы (или без имён переменных) — условия перехода не блокируются.")
else:
raw_val = st.session_state.get(f"st_val_{sid}_{ver}", "{}")
try:
user_vals = json.loads(raw_val) if str(raw_val).strip() else {}
if not isinstance(user_vals, dict):
raise TypeError("нужен JSON-объект")
except Exception as ex:
st.error(f"Значения переменных: {ex}")
user_vals = None
if user_vals is not None:
jenk = _collect_flat_jenkins_params_for_stage(stg, ver, sid)
merged = {**jenk, **{str(k): v for k, v in user_vals.items()}}
try:
vr = requests.post(
f"{backend_base_url}/pipelines/autonomous/validate-gates",
json={"checks": chk, "values": merged},
headers=_auth_headers(),
timeout=30,
)
if vr.status_code >= 400:
st.error(_detail_or_text(vr))
else:
data = vr.json()
if data.get("ok"):
nxt = "следующий этап" if sidx < len(stages) - 1 else "завершение конвейера"
st.success(f"Все проверки пройдены. Можно переходить к: {nxt}.")
else:
st.error("Проверки не пройдены.")
for f in data.get("failures") or []:
r = f.get("reason")
var = f.get("variable", "")
if r == "missing":
st.warning(f"• `{var}`: переменная отсутствует среди значений.")
elif r == "mismatch":
st.warning(
f"• `{var}`: ожидалось `{f.get('expected')}`, "
f"фактически `{f.get('actual')}`."
)
else:
st.warning(f"{f}")
except Exception as ex:
st.error(str(ex))
if st.button("Добавить фазу на этом этапе", key=f"ap_add_ph_{sid}_{ver}", use_container_width=True):
stg.setdefault("phases", []).append(_new_autonomous_phase())
st.session_state.autonomous_stages = stages
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
phases = stg.get("phases") or []
for pidx, ph in enumerate(phases):
pid = ph["id"]
j = ph.setdefault("jenkins", {})
j.setdefault("parameters", {})
st.session_state.setdefault(f"ap_pn_{sid}_{pid}_{ver}", ph.get("name", ""))
st.session_state.setdefault(f"ap_pd_{sid}_{pid}_{ver}", ph.get("description", ""))
st.session_state.setdefault(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", ""))
st.session_state.setdefault(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", ""))
st.session_state.setdefault(
f"ap_pj_{sid}_{pid}_{ver}",
json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2),
)
title_ph = st.session_state.get(f"ap_pn_{sid}_{pid}_{ver}", "") or f"Фаза {pidx + 1}"
ph_expanded = sidx == len(stages) - 1 and pidx == len(phases) - 1
with st.expander(f" ◆ Фаза: {title_ph}", expanded=ph_expanded):
st.markdown("**Фаза (low-code)**")
st.text_input("Название", key=f"ap_pn_{sid}_{pid}_{ver}")
st.text_area("Описание", key=f"ap_pd_{sid}_{pid}_{ver}", height=68)
st.divider()
st.markdown("**Jenkins**")
st.text_input("Job / путь", key=f"ap_jj_{sid}_{pid}_{ver}", placeholder="folder/subfolder/job-name")
st.text_input("Jenkins URL (если не дефолтный)", key=f"ap_js_{sid}_{pid}_{ver}")
st.text_area("Параметры (JSON)", key=f"ap_pj_{sid}_{pid}_{ver}", height=100)
b1, b2, b3 = st.columns(3)
with b1:
if st.button("Фаза вверх", key=f"ap_up_{sid}_{pid}_{ver}", disabled=pidx == 0):
phases[pidx - 1], phases[pidx] = phases[pidx], phases[pidx - 1]
st.session_state.autonomous_stages = stages
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
with b2:
if st.button(
"Фаза вниз",
key=f"ap_dn_{sid}_{pid}_{ver}",
disabled=pidx >= len(phases) - 1,
):
phases[pidx + 1], phases[pidx] = phases[pidx], phases[pidx + 1]
st.session_state.autonomous_stages = stages
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
with b3:
if st.button("Удалить фазу", key=f"ap_rm_{sid}_{pid}_{ver}"):
stg["phases"] = [p for p in phases if p["id"] != pid]
st.session_state.autonomous_stages = stages
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
if st.button(
"Запустить Jenkins",
key=f"ap_run_{sid}_{pid}_{ver}",
use_container_width=True,
):
pj, jerr = _phase_for_jenkins_trigger(ph, ver, sid)
if jerr:
st.error(jerr)
else:
with st.spinner("Запрос к Jenkins API…"):
try:
jr = requests.post(
f"{backend_base_url}/jenkins/trigger",
json=pj,
headers=_auth_headers(),
timeout=120,
)
if jr.status_code >= 400:
st.error(_detail_or_text(jr))
else:
info = jr.json()
loc = info.get("location") or ""
st.success(
f"Сборка в очереди. {('Queue: ' + loc) if loc else ''}"
)
except Exception as ex:
st.error(str(ex))
sb1, sb2, sb3 = st.columns(3)
with sb1:
if st.button("Этап вверх", key=f"st_up_{sid}_{ver}", disabled=sidx == 0):
stages[sidx - 1], stages[sidx] = stages[sidx], stages[sidx - 1]
st.session_state.autonomous_stages = stages
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
with sb2:
if st.button(
"Этап вниз",
key=f"st_dn_{sid}_{ver}",
disabled=sidx >= len(stages) - 1,
):
stages[sidx + 1], stages[sidx] = stages[sidx], stages[sidx + 1]
st.session_state.autonomous_stages = stages
st.session_state.autonomous_pl_version = ver + 1
st.rerun()
with sb3:
if st.button("Удалить этап", key=f"st_rm_{sid}_{ver}"):
st.session_state.autonomous_stages = [s for s in stages if s["id"] != sid]
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_stages_for_save(stages, ver)
if err:
st.error(err)
else:
try:
body = {
"title": st.session_state.get("ap_global_title", ""),
"stages": 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_stages = data.get("stages", [])
if "ap_global_title" in st.session_state:
del st.session_state["ap_global_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` — сохранение. Проверки: `POST /pipelines/autonomous/validate-gates`. "
"Jenkins: `POST /jenkins/trigger` + env credentials."
)
with st.expander("Экспорт YAML (черновик)", expanded=False):
snap, err = _collect_stages_for_save(stages, ver)
if err:
st.warning(err)
else:
doc = {"title": st.session_state.get("ap_global_title", ""), "stages": 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 — внутренний портал.",
)