diff --git a/backend/main.py b/backend/main.py index 169db04..f47d0df 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/backend/models.py b/backend/models.py index 5d4ecdc..a8f7810 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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, + ) + diff --git a/frontend/ui.py b/frontend/ui.py index 507bb61..5176340 100644 --- a/frontend/ui.py +++ b/frontend/ui.py @@ -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,10 +365,126 @@ 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("Инфраструктура АС/ФП")