add low-code

This commit is contained in:
2026-04-10 00:47:53 +03:00
parent d30b5614f4
commit 2d30fe258b
3 changed files with 298 additions and 6 deletions

View File

@@ -5,7 +5,7 @@ import os
import requests
import yaml
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
@@ -39,7 +39,7 @@ from backend.auth import ( # noqa: E402
verify_password,
)
from backend.db import engine, get_db # noqa: E402
from backend.models import Base, User, UserChatMessage # noqa: E402
from backend.models import AutonomousPipeline, Base, User, UserChatMessage # noqa: E402
class GenerationRequest(BaseModel):
@@ -58,6 +58,24 @@ class ChatRequest(BaseModel):
messages: list[ChatMessageIn]
class JenkinsPhaseConfigIn(BaseModel):
job: str = ""
server_url: str = ""
parameters: dict[str, str] = Field(default_factory=dict)
class LowCodePhaseIn(BaseModel):
id: str
name: str = ""
description: str = ""
jenkins: JenkinsPhaseConfigIn = Field(default_factory=JenkinsPhaseConfigIn)
class AutonomousPipelineSave(BaseModel):
title: str = ""
phases: list[LowCodePhaseIn] = Field(default_factory=list)
def validate_config(config_text: str):
try:
yaml.safe_load(config_text)
@@ -169,6 +187,58 @@ def me(user: User = Depends(get_current_user)):
return {"id": str(user.id), "email": user.email, "full_name": user.full_name, "is_admin": user.is_admin}
@app.get("/pipelines/autonomous")
def get_autonomous_pipeline(
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
):
if user is None:
return {"title": "", "phases": [], "updated_at": None}
row = db.get(AutonomousPipeline, user.id)
if row is None:
return {"title": "", "phases": [], "updated_at": None}
defn = row.definition if isinstance(row.definition, dict) else {}
phases = defn.get("phases", [])
return {
"title": row.title,
"phases": phases,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
@app.put("/pipelines/autonomous")
def put_autonomous_pipeline(
body: AutonomousPipelineSave,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
):
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
try:
row = db.get(AutonomousPipeline, user.id)
dumped = [p.model_dump() for p in body.phases]
if row is None:
row = AutonomousPipeline(
user_id=user.id,
title=body.title,
definition={"schema_version": 1, "phases": dumped},
)
db.add(row)
else:
row.title = body.title
row.definition = {"schema_version": 1, "phases": dumped}
db.commit()
db.refresh(row)
return {
"title": row.title,
"phases": row.definition.get("phases", []),
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
except SQLAlchemyError as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
@app.get("/chat/history")
def chat_history(
limit: int = 500,

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -43,3 +43,26 @@ class UserChatMessage(Base):
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, nullable=False)
class AutonomousPipeline(Base):
"""Low-code конвейер: фазы и вызов Jenkins с параметрами (один черновик на пользователя)."""
__tablename__ = "autonomous_pipelines"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
title: Mapped[str] = mapped_column(String(500), default="", nullable=False)
definition: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=lambda: {"schema_version": 1, "phases": []},
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=_now,
onupdate=_now,
nullable=False,
)

View File

@@ -1,8 +1,11 @@
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(
@@ -47,6 +50,8 @@ def _title_bar(*, show_logout: bool):
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(
@@ -115,6 +120,55 @@ def _detail_or_text(resp: requests.Response) -> str:
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("Войдите или создайте учётную запись, чтобы открыть разделы портала.")
@@ -159,6 +213,7 @@ def _show_account():
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:
@@ -199,6 +254,34 @@ if not st.session_state.get("chat_hydrated"):
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("Портал с данными и инструментами для АС/ФП.")
@@ -282,11 +365,127 @@ with tab_info:
with tab_autonomous:
st.subheader("Автономные внедрения")
with st.container(border=True):
st.info(
"Сюда можно добавить список внедрений, статус, даты, ответственных и ссылки на релизы/тикеты."
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):