chore: release v1.0.0 — каркас FastAPI, ingress Grafana, интеграции, документация
Made-with: Cursor
This commit is contained in:
33
.env.example
Normal file
33
.env.example
Normal file
@ -0,0 +1,33 @@
|
||||
# Скопируй в .env и подставь значения (файл .env в git не коммитится)
|
||||
|
||||
HTTP_ADDR=0.0.0.0:8080
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Опционально: если задан — POST /api/v1/ingress/grafana требует заголовок X-OnGuard-Secret
|
||||
# GRAFANA_WEBHOOK_SECRET=
|
||||
|
||||
# --- Grafana HTTP API (service account, не пароль admin) ---
|
||||
# URL без завершающего слэша. Токен: Grafana → Administration → Service accounts → onguard24 → Add service account token
|
||||
GRAFANA_URL=https://grafana.pvenode.ru
|
||||
# GRAFANA_SERVICE_ACCOUNT_TOKEN=
|
||||
|
||||
# --- PostgreSQL (onGuard24) ---
|
||||
DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/DBNAME?sslmode=disable
|
||||
|
||||
# Альтернатива по отдельным полям (если приложение читает так)
|
||||
# PGHOST=
|
||||
# PGPORT=5432
|
||||
# PGUSER=
|
||||
# PGPASSWORD=
|
||||
# PGDATABASE=
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# --- HashiCorp Vault ---
|
||||
VAULT_ADDR=https://vault.pvenode.ru
|
||||
# Только для dev: предпочтительно AppRole / отдельный токен с ограниченными правами, не root
|
||||
VAULT_TOKEN=
|
||||
|
||||
# --- Forgejo (Gitea API) — репозиторий, CI, тот же токен для git clone по HTTPS при необходимости ---
|
||||
# Токен: Forgejo → Settings → Applications → Generate New Token (или старый PAT)
|
||||
FORGEJO_URL=https://forgejo.pvenode.ru
|
||||
# FORGEJO_TOKEN=
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
.env
|
||||
.env.local
|
||||
*.pem
|
||||
dist/
|
||||
bin/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
.idea/
|
||||
onguard24/.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md).
|
||||
|
||||
## [1.0.0] — 2026-04-03
|
||||
|
||||
Первый зафиксированный релиз **каркаса** (scaffold).
|
||||
|
||||
### Что входит
|
||||
|
||||
- **Backend:** FastAPI, uvicorn, конфиг из `.env` (путь к `.env` от корня репозитория).
|
||||
- **БД:** PostgreSQL через asyncpg, пул, миграция `ingress_events` (сырой SQL в `onguard24/db.py`).
|
||||
- **Ingress:** `POST /api/v1/ingress/grafana` — сохранение JSON алерта в БД; опционально `X-OnGuard-Secret` + `GRAFANA_WEBHOOK_SECRET`.
|
||||
- **Статус:** `GET /`, `GET /api/v1/status` — проверки database, Vault, Grafana (SA token), Forgejo (PAT + fallback без `read:user`).
|
||||
- **Модули-заглушки:** `schedules`, `contacts`, `statusboard` под префиксом `/api/v1/modules/...`.
|
||||
- **Фронт:** Vite + React в `web/` (прокси на API).
|
||||
- **Документация:** README, `.env.example`, `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Не входит (следующие версии)
|
||||
|
||||
- Alembic / полноценные миграции.
|
||||
- Авторизация публичных API (кроме секрета webhook).
|
||||
- Бизнес-логика IRM (эскалации, дежурства, светофор) — только заготовки модулей.
|
||||
|
||||
Тег в репозитории: `v1.0.0`.
|
||||
7
Makefile
Normal file
7
Makefile
Normal file
@ -0,0 +1,7 @@
|
||||
.PHONY: run install lint
|
||||
|
||||
install:
|
||||
python3 -m venv .venv && . .venv/bin/activate && pip install -e .
|
||||
|
||||
run:
|
||||
. .venv/bin/activate && python -m uvicorn onguard24.main:app --reload --host 0.0.0.0 --port 8080
|
||||
75
README.md
Normal file
75
README.md
Normal file
@ -0,0 +1,75 @@
|
||||
# onGuard24
|
||||
|
||||
**Версия: 1.0.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo.
|
||||
|
||||
| Документ | Назначение |
|
||||
|----------|------------|
|
||||
| [CHANGELOG.md](CHANGELOG.md) | История версий |
|
||||
| [docs/VERSIONING.md](docs/VERSIONING.md) | Теги, откат к предыдущей версии |
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Структура кода, куда что класть |
|
||||
| [docs/AI_CONTEXT.md](docs/AI_CONTEXT.md) | Краткий контекст для доработок |
|
||||
|
||||
**Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24)
|
||||
|
||||
---
|
||||
|
||||
## Что уже есть (функционал v1)
|
||||
|
||||
- Запуск HTTP API (`uvicorn`), конфиг из `.env`.
|
||||
- **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana.
|
||||
- **POST `/api/v1/ingress/grafana`** — приём JSON алерта (опционально защита `X-OnGuard-Secret`).
|
||||
- **GET `/`**, **GET `/api/v1/status`** — проверки: БД, Vault, Grafana (service account), Forgejo (PAT).
|
||||
- **Модули-заглушки:** `/api/v1/modules/schedules|contacts|statusboard/`.
|
||||
- **Фронт (опционально):** `web/` — Vite + React, прокси на API.
|
||||
|
||||
Чего **ещё нет** (следующие версии): Alembic, авторизация API, доменная модель инцидентов, эскалации, фоновые задачи.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
cd onGuard24
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Скопируй `.env.example` в `.env` и заполни секреты (см. ниже).
|
||||
|
||||
```bash
|
||||
python -m uvicorn onguard24.main:app --reload --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
Или: `python -m onguard24.main` (читает `HTTP_ADDR` из `.env`).
|
||||
|
||||
## API
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| GET | `/` | HTML: проверки **database / vault / grafana / forgejo** |
|
||||
| GET | `/?format=json` | Тот же статус + ссылки в JSON |
|
||||
| GET | `/health`, `/api/v1/health` | Liveness |
|
||||
| GET | `/api/v1/status` | JSON: интеграции + БД |
|
||||
| POST | `/api/v1/ingress/grafana` | Webhook Grafana (JSON), опционально `X-OnGuard-Secret` |
|
||||
| GET | `/api/v1/modules/schedules/` | Заглушка модуля дежурств |
|
||||
| GET | `/api/v1/modules/contacts/` | Заглушка контактов |
|
||||
| GET | `/api/v1/modules/statusboard/` | Заглушка «светофора» |
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
См. `.env.example`. Основные: `DATABASE_URL`, `HTTP_ADDR`, `VAULT_*`, `GRAFANA_*`, `FORGEJO_*`, опционально `GRAFANA_WEBHOOK_SECRET`.
|
||||
|
||||
### Grafana
|
||||
|
||||
- **`GRAFANA_URL`**, **`GRAFANA_SERVICE_ACCOUNT_TOKEN`** — HTTP API (service account), не пароль пользователя.
|
||||
|
||||
### Forgejo (Gitea API)
|
||||
|
||||
- **`FORGEJO_URL`**, **`FORGEJO_TOKEN`** — PAT; рекомендуется scope **`read:user`** для полного ответа `/api/v1/user` (см. README в предыдущих версиях и `integrations/forgejo_api.py`).
|
||||
|
||||
## Фронтенд (опционально)
|
||||
|
||||
```bash
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
Vite проксирует `/api` на `http://127.0.0.1:8080` (см. `web/vite.config.ts`).
|
||||
35
docs/AI_CONTEXT.md
Normal file
35
docs/AI_CONTEXT.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Контекст для доработок (агент / разработчик)
|
||||
|
||||
Кратко, что нужно знать перед изменениями в репозитории **onGuard24**.
|
||||
|
||||
## Продукт
|
||||
|
||||
- **onGuard24** — сервис класса **IRM** (дежурства, эскалации, инциденты), модульный монолит.
|
||||
- **v1.0.0** — только каркас: API, БД, webhook Grafana, заглушки модулей, страница статусов.
|
||||
|
||||
## Стек
|
||||
|
||||
- Python **≥3.11**, **FastAPI**, **asyncpg**, **httpx**, **pydantic-settings**.
|
||||
- Фронт опционально: **Vite + React** в `web/`.
|
||||
|
||||
## Правила
|
||||
|
||||
1. **Секреты** — только `.env` (gitignore). В репо — `.env.example` без значений.
|
||||
2. **Версия** — синхронизировать: `pyproject.toml`, `onguard24/__init__.py`, тег git, `CHANGELOG.md`.
|
||||
3. **Новые модули** — пакет `onguard24/modules/`, роутер подключать в `main.py` с префиксом `/api/v1/modules/<имя>`.
|
||||
4. **Миграции БД** — пока правки в `onguard24/db.py` (константа SQL); не ломать таблицу `ingress_events` без миграционного плана.
|
||||
5. **Статус интеграций** — логика в `status_snapshot.py` и `integrations/*`.
|
||||
|
||||
## Точки входа в код
|
||||
|
||||
| Задача | Файл |
|
||||
|--------|------|
|
||||
| Новый эндпоинт | `main.py` или `modules/*.py` |
|
||||
| Настройки | `config.py` |
|
||||
| Webhook Grafana | `ingress/grafana.py` |
|
||||
| Проверка Vault/Grafana/Forgejo | `status_snapshot.py`, `integrations/` |
|
||||
| HTML главной | `root_html.py` |
|
||||
|
||||
## Откат
|
||||
|
||||
См. [VERSIONING.md](VERSIONING.md). Теги `v1.0.0`, `v1.1.0`, …
|
||||
55
docs/ARCHITECTURE.md
Normal file
55
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Архитектура onGuard24 (для разработки и доработок)
|
||||
|
||||
Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1**: HTTP, БД, ingress Grafana, проверки интеграций.
|
||||
|
||||
## Дерево пакетов
|
||||
|
||||
```
|
||||
onGuard24/
|
||||
├── onguard24/
|
||||
│ ├── main.py # FastAPI app, lifespan, маршруты верхнего уровня
|
||||
│ ├── config.py # Settings: .env из корня репозитория (не от cwd)
|
||||
│ ├── db.py # asyncpg pool, миграция ingress_events
|
||||
│ ├── status_snapshot.py # Единый сборщик JSON для /api/v1/status
|
||||
│ ├── root_html.py # HTML главной страницы со статусами
|
||||
│ ├── vaultcheck.py # Vault /v1/sys/health
|
||||
│ ├── ingress/grafana.py # POST webhook Grafana → INSERT ingress_events
|
||||
│ ├── integrations/
|
||||
│ │ ├── grafana_api.py # Grafana HTTP API (Bearer SA)
|
||||
│ │ └── forgejo_api.py # Forgejo/Gitea API (token + probe/fallback)
|
||||
│ └── modules/ # Заглушки: schedules, contacts, statusboard
|
||||
├── web/ # Vite + React (опционально)
|
||||
├── pyproject.toml
|
||||
├── CHANGELOG.md
|
||||
└── docs/
|
||||
```
|
||||
|
||||
## Поток данных (сейчас)
|
||||
|
||||
1. **Grafana** (отдельно настроенный contact point) шлёт **POST** на `/api/v1/ingress/grafana` → тело JSON пишется в **`ingress_events`**.
|
||||
2. **Параллельно** Grafana может слать в Mattermost — это вне этого репозитория (конфиг Grafana).
|
||||
3. **Статус страницы** не ходит в Grafana за алертами — только **проверка доступности API** (токен SA).
|
||||
|
||||
## Куда класть новый функционал
|
||||
|
||||
| Задача | Место |
|
||||
|--------|--------|
|
||||
| Новый HTTP-роут модуля | `onguard24/modules/<name>.py` + `include_router` в `main.py` |
|
||||
| Общая логика инцидентов / событий | позже: `onguard24/core/` или сервисный слой + события из БД |
|
||||
| Новая таблица БД | пока: SQL в `db.py` (MIGRATION_00N); позже: Alembic |
|
||||
| Новая внешняя интеграция | `onguard24/integrations/<name>.py`, вызов из `status_snapshot` при необходимости |
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Все секреты только через **переменные окружения** / `.env` (файл **не в git**). Список ключей — `.env.example`.
|
||||
|
||||
## Зависимости между компонентами
|
||||
|
||||
- `status_snapshot.build(request)` читает `request.app.state.pool` и `request.app.state.settings` (устанавливаются в `lifespan`).
|
||||
- Модули **не** зависят друг от друга; общий контракт позже можно ввести через **таблицы БД** и **внутренние события** (ещё не реализовано).
|
||||
|
||||
## Известные ограничения v1
|
||||
|
||||
- Нет единой модели «инцидент» в БД — только сырой ingest в `ingress_events`.
|
||||
- Нет очереди/воркеров для эскалаций.
|
||||
- Нет auth на GET `/api/v1/status` (только для внутренней сети / за reverse proxy с ограничением).
|
||||
43
docs/VERSIONING.md
Normal file
43
docs/VERSIONING.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Версии и откат
|
||||
|
||||
## Соглашение
|
||||
|
||||
| Что | Как |
|
||||
|-----|-----|
|
||||
| Версия в коде | `pyproject.toml` → `[project] version`, `onguard24/__init__.py` → `__version__` |
|
||||
| Git-теги | Аннотированные: `v1.0.0`, `v1.1.0` (`git tag -a v1.0.0 -m "..."`) |
|
||||
| Ветка по умолчанию | `main` |
|
||||
|
||||
Семвер ориентир:
|
||||
|
||||
- **MAJOR** — несовместимые изменения API или схемы БД.
|
||||
- **MINOR** — новые фичи, обратная совместимость.
|
||||
- **PATCH** — исправления без смены контракта.
|
||||
|
||||
## Откат к предыдущей версии
|
||||
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v1.0.0 # или нужный тег
|
||||
# отладка / hotfix от тега
|
||||
```
|
||||
|
||||
Новая ветка от старого тега:
|
||||
|
||||
```bash
|
||||
git checkout -b hotfix/v1.0.1 v1.0.0
|
||||
```
|
||||
|
||||
Сравнение версий:
|
||||
|
||||
```bash
|
||||
git log v1.0.0..main --oneline
|
||||
git diff v1.0.0..main
|
||||
```
|
||||
|
||||
## Выпуск новой версии (чеклист)
|
||||
|
||||
1. Обновить `CHANGELOG.md`, `pyproject.toml`, `onguard24/__init__.py`.
|
||||
2. `git commit -m "chore: release vX.Y.Z"`.
|
||||
3. `git tag -a vX.Y.Z -m "Release vX.Y.Z"`.
|
||||
4. `git push origin main --tags`.
|
||||
3
onguard24/__init__.py
Normal file
3
onguard24/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""onGuard24 — модульный монолит (ядро + модули)."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
37
onguard24/config.py
Normal file
37
onguard24/config.py
Normal file
@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# .env рядом с корнём проекта (не зависит от текущей директории при запуске uvicorn)
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(_PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(_PROJECT_ROOT / ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
http_addr: str = Field(default="0.0.0.0:8080", validation_alias="HTTP_ADDR")
|
||||
database_url: str = Field(default="", validation_alias="DATABASE_URL")
|
||||
grafana_webhook_secret: str = Field(default="", validation_alias="GRAFANA_WEBHOOK_SECRET")
|
||||
# HTTP API (service account): Grafana → Administration → Service accounts → токен
|
||||
grafana_url: str = Field(default="", validation_alias="GRAFANA_URL")
|
||||
grafana_service_account_token: str = Field(
|
||||
default="",
|
||||
validation_alias="GRAFANA_SERVICE_ACCOUNT_TOKEN",
|
||||
)
|
||||
vault_addr: str = Field(default="", validation_alias="VAULT_ADDR")
|
||||
vault_token: str = Field(default="", validation_alias="VAULT_TOKEN")
|
||||
# Forgejo (Gitea-совместимый API): Settings → Applications → токен, или при создании PAT
|
||||
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
||||
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
||||
log_level: str = Field(default="info", validation_alias="LOG_LEVEL")
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
32
onguard24/db.py
Normal file
32
onguard24/db.py
Normal file
@ -0,0 +1,32 @@
|
||||
import asyncpg
|
||||
|
||||
from onguard24.config import Settings
|
||||
|
||||
|
||||
def normalize_dsn(url: str) -> str:
|
||||
if url.startswith("postgres://"):
|
||||
return url.replace("postgres://", "postgresql://", 1)
|
||||
return url
|
||||
|
||||
|
||||
async def create_pool(settings: Settings) -> asyncpg.Pool | None:
|
||||
if not settings.database_url.strip():
|
||||
return None
|
||||
dsn = normalize_dsn(settings.database_url.strip())
|
||||
return await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=10)
|
||||
|
||||
|
||||
MIGRATION_001 = """
|
||||
CREATE TABLE IF NOT EXISTS ingress_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source text NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
body jsonb NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ingress_events_received_at_idx ON ingress_events (received_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
async def migrate(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(MIGRATION_001)
|
||||
1
onguard24/ingress/__init__.py
Normal file
1
onguard24/ingress/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Входящие интеграции (Grafana и др.)."""
|
||||
43
onguard24/ingress/grafana.py
Normal file
43
onguard24/ingress/grafana.py
Normal file
@ -0,0 +1,43 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["ingress"])
|
||||
|
||||
|
||||
async def get_pool(request: Request):
|
||||
return getattr(request.app.state, "pool", None)
|
||||
|
||||
|
||||
@router.post("/ingress/grafana", status_code=202)
|
||||
async def grafana_webhook(
|
||||
request: Request,
|
||||
pool=Depends(get_pool),
|
||||
x_onguard_secret: str | None = Header(default=None, alias="X-OnGuard-Secret"),
|
||||
):
|
||||
settings = request.app.state.settings
|
||||
if settings.grafana_webhook_secret and x_onguard_secret != settings.grafana_webhook_secret:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
raw = await request.body()
|
||||
if len(raw) > 1_000_000:
|
||||
raise HTTPException(status_code=400, detail="body too large")
|
||||
try:
|
||||
body = json.loads(raw.decode() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
body = {}
|
||||
|
||||
if pool is None:
|
||||
logger.warning("ingress: database not configured, event not persisted")
|
||||
return Response(status_code=202)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO ingress_events (source, body) VALUES ($1, $2::jsonb)",
|
||||
"grafana",
|
||||
json.dumps(body),
|
||||
)
|
||||
return Response(status_code=202)
|
||||
1
onguard24/integrations/__init__.py
Normal file
1
onguard24/integrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Внешние интеграции (Grafana, Vault, …)."""
|
||||
103
onguard24/integrations/forgejo_api.py
Normal file
103
onguard24/integrations/forgejo_api.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Forgejo / Gitea HTTP API: Authorization: token <secret>."""
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def probe(base_url: str, token: str) -> dict:
|
||||
"""
|
||||
Проверка токена. Сначала GET /api/v1/user (нужен scope read:user).
|
||||
При 403 из‑за scope — fallback: GET /api/v1/admin/config (часто доступен с write:admin).
|
||||
"""
|
||||
if not base_url.strip() or not token.strip():
|
||||
return {"status": "error", "detail": "forgejo url or token empty"}
|
||||
|
||||
base = base_url.rstrip("/")
|
||||
h = _auth(token)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client:
|
||||
ru = await client.get(f"{base}/api/v1/user", headers=h)
|
||||
if ru.status_code == 200:
|
||||
try:
|
||||
u = ru.json()
|
||||
except Exception:
|
||||
u = {}
|
||||
login = u.get("login")
|
||||
out: dict = {
|
||||
"status": "ok",
|
||||
"url": base,
|
||||
"api": "authenticated",
|
||||
"scope": "read:user",
|
||||
}
|
||||
if login:
|
||||
out["login"] = login
|
||||
return out
|
||||
|
||||
if ru.status_code == 403:
|
||||
for path, name in (
|
||||
("/api/v1/admin/config", "admin_config"),
|
||||
("/api/v1/notifications?limit=1", "notifications"),
|
||||
):
|
||||
rx = await client.get(f"{base}{path}", headers=h)
|
||||
if rx.status_code == 200:
|
||||
return {
|
||||
"status": "ok",
|
||||
"url": base,
|
||||
"api": "authenticated",
|
||||
"scope_note": (
|
||||
"в токене нет scope read:user — в Forgejo включи read:user у PAT "
|
||||
"или создай новый токен с read:user, чтобы отображался login"
|
||||
),
|
||||
"fallback": name,
|
||||
}
|
||||
|
||||
body = (ru.text or "")[:500]
|
||||
return {
|
||||
"status": "error",
|
||||
"url": base,
|
||||
"detail": f"http {ru.status_code}: {body}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "detail": str(e), "url": base_url.rstrip("/")}
|
||||
|
||||
|
||||
async def ping(base_url: str, token: str) -> tuple[bool, str | None]:
|
||||
"""Обёртка для совместимости: True если status ok."""
|
||||
r = await probe(base_url, token)
|
||||
if r.get("status") == "ok":
|
||||
return True, None
|
||||
return False, r.get("detail", "unknown")
|
||||
|
||||
|
||||
async def get_user(base_url: str, token: str) -> tuple[dict | None, str | None]:
|
||||
base = base_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client:
|
||||
r = await client.get(f"{base}/api/v1/user", headers=_auth(token))
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
if r.status_code != 200:
|
||||
return None, f"http {r.status_code}"
|
||||
try:
|
||||
return r.json(), None
|
||||
except Exception:
|
||||
return None, "invalid json"
|
||||
|
||||
|
||||
async def health_public(base_url: str) -> tuple[bool, str | None]:
|
||||
base = base_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, verify=True) as client:
|
||||
r = await client.get(f"{base}/api/v1/version")
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
if r.status_code == 200:
|
||||
return True, None
|
||||
return False, f"http {r.status_code}"
|
||||
58
onguard24/integrations/grafana_api.py
Normal file
58
onguard24/integrations/grafana_api.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""HTTP API Grafana: service account token (Bearer), не пароль пользователя."""
|
||||
|
||||
import httpx
|
||||
|
||||
# Минимальный набор для проверки и будущих вызовов (алерты, папки и т.д.).
|
||||
|
||||
|
||||
async def ping(base_url: str, token: str) -> tuple[bool, str | None]:
|
||||
if not base_url.strip() or not token.strip():
|
||||
return False, "grafana url or token empty"
|
||||
base = base_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client:
|
||||
r = await client.get(
|
||||
f"{base}/api/org",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
if r.status_code == 200:
|
||||
return True, None
|
||||
body = (r.text or "")[:300]
|
||||
return False, f"http {r.status_code}: {body}"
|
||||
|
||||
|
||||
async def get_signed_in_user(base_url: str, token: str) -> tuple[dict | None, str | None]:
|
||||
"""GET /api/user — удобно убедиться, что токен от service account."""
|
||||
base = base_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client:
|
||||
r = await client.get(
|
||||
f"{base}/api/user",
|
||||
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
||||
)
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
if r.status_code != 200:
|
||||
return None, f"http {r.status_code}"
|
||||
try:
|
||||
return r.json(), None
|
||||
except Exception:
|
||||
return None, "invalid json"
|
||||
|
||||
|
||||
async def health_live(base_url: str) -> tuple[bool, str | None]:
|
||||
"""GET /api/health — без авторизации, проверка что инстанс отвечает."""
|
||||
base = base_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, verify=True) as client:
|
||||
r = await client.get(f"{base}/api/health")
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
if r.status_code == 200:
|
||||
return True, None
|
||||
return False, f"http {r.status_code}"
|
||||
108
onguard24/main.py
Normal file
108
onguard24/main.py
Normal file
@ -0,0 +1,108 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import HTMLResponse, Response
|
||||
|
||||
from onguard24.config import get_settings
|
||||
from onguard24.db import create_pool, migrate
|
||||
from onguard24.ingress import grafana as grafana_ingress
|
||||
from onguard24.modules import contacts, schedules, statusboard
|
||||
from onguard24.root_html import render_root_page
|
||||
from onguard24.status_snapshot import build as build_status
|
||||
from onguard24 import __version__ as app_version
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
log = logging.getLogger("onguard24")
|
||||
|
||||
|
||||
def parse_addr(http_addr: str) -> tuple[str, int]:
|
||||
s = http_addr.strip()
|
||||
if s.startswith(":"):
|
||||
return "0.0.0.0", int(s[1:])
|
||||
if ":" in s:
|
||||
h, p = s.rsplit(":", 1)
|
||||
return (h or "0.0.0.0"), int(p)
|
||||
return "0.0.0.0", int(s)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
pool = await create_pool(settings)
|
||||
if pool:
|
||||
await migrate(pool)
|
||||
app.state.pool = pool
|
||||
app.state.settings = settings
|
||||
log.info("onGuard24 started, db=%s", "ok" if pool else "disabled")
|
||||
yield
|
||||
if pool:
|
||||
await pool.close()
|
||||
log.info("database pool closed")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="onGuard24", version=app_version, lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
async def root(request: Request):
|
||||
if request.query_params.get("format") == "json":
|
||||
data = await build_status(request)
|
||||
data["links"] = {
|
||||
"docs": "/docs",
|
||||
"openapi": "/openapi.json",
|
||||
"health": "/health",
|
||||
"status_api": "/api/v1/status",
|
||||
}
|
||||
return data
|
||||
return HTMLResponse(await render_root_page(request))
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon():
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get("/health")
|
||||
@app.get("/api/v1/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "onGuard24"}
|
||||
|
||||
@app.get("/api/v1/status")
|
||||
async def status(request: Request):
|
||||
return await build_status(request)
|
||||
|
||||
app.include_router(grafana_ingress.router, prefix="/api/v1")
|
||||
app.include_router(schedules.router, prefix="/api/v1/modules/schedules")
|
||||
app.include_router(contacts.router, prefix="/api/v1/modules/contacts")
|
||||
app.include_router(statusboard.router, prefix="/api/v1/modules/statusboard")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
host, port = parse_addr(settings.http_addr)
|
||||
lvl = settings.log_level.upper()
|
||||
uvicorn.run(
|
||||
"onguard24.main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level=lvl.lower() if lvl in {"DEBUG", "INFO", "WARNING", "ERROR"} else "info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
1
onguard24/modules/__init__.py
Normal file
1
onguard24/modules/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Подключаемые модули onGuard24."""
|
||||
12
onguard24/modules/contacts.py
Normal file
12
onguard24/modules/contacts.py
Normal file
@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["module-contacts"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def contacts_root():
|
||||
return {
|
||||
"module": "contacts",
|
||||
"status": "stub",
|
||||
"note": "люди, группы, каналы доставки",
|
||||
}
|
||||
12
onguard24/modules/schedules.py
Normal file
12
onguard24/modules/schedules.py
Normal file
@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["module-schedules"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def schedules_root():
|
||||
return {
|
||||
"module": "schedules",
|
||||
"status": "stub",
|
||||
"note": "календарь и смены — следующий этап",
|
||||
}
|
||||
13
onguard24/modules/statusboard.py
Normal file
13
onguard24/modules/statusboard.py
Normal file
@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["module-statusboard"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def statusboard_root():
|
||||
return {
|
||||
"module": "statusboard",
|
||||
"status": "stub",
|
||||
"note": "светофор по сервисам — агрегация по алертам",
|
||||
"demo": [],
|
||||
}
|
||||
88
onguard24/root_html.py
Normal file
88
onguard24/root_html.py
Normal file
@ -0,0 +1,88 @@
|
||||
import html
|
||||
import json
|
||||
|
||||
from onguard24.status_snapshot import build
|
||||
|
||||
|
||||
def _row(name: str, value: object) -> str:
|
||||
label = html.escape(name)
|
||||
if value == "disabled":
|
||||
badge = '<span class="badge muted">не настроено</span>'
|
||||
return f"<tr><th>{label}</th><td>{badge}</td></tr>"
|
||||
|
||||
if isinstance(value, dict):
|
||||
st = value.get("status", "?")
|
||||
if st == "ok":
|
||||
badge = '<span class="badge ok">OK</span>'
|
||||
elif st == "reachable":
|
||||
badge = '<span class="badge warn">доступен</span>'
|
||||
elif st == "error":
|
||||
badge = '<span class="badge err">ошибка</span>'
|
||||
else:
|
||||
badge = f'<span class="badge muted">{html.escape(str(st))}</span>'
|
||||
extra = {k: v for k, v in value.items() if k != "status"}
|
||||
detail_html = ""
|
||||
if extra:
|
||||
detail_html = (
|
||||
f'<tr class="sub"><td colspan="2"><pre class="detail">'
|
||||
f"{html.escape(json.dumps(extra, ensure_ascii=False, indent=2))}"
|
||||
f"</pre></td></tr>"
|
||||
)
|
||||
return f"<tr><th>{label}</th><td>{badge}</td></tr>{detail_html}"
|
||||
|
||||
badge = html.escape(str(value))
|
||||
return f"<tr><th>{label}</th><td>{badge}</td></tr>"
|
||||
|
||||
|
||||
async def render_root_page(request) -> str:
|
||||
data = await build(request)
|
||||
rows = ""
|
||||
for key in ("database", "vault", "grafana", "forgejo"):
|
||||
if key in data:
|
||||
rows += _row(key, data[key])
|
||||
|
||||
payload = html.escape(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>onGuard24</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, sans-serif; margin: 2rem; background: #fafafa; color: #18181b; }}
|
||||
h1 {{ margin-top: 0; }}
|
||||
.badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 6px; font-size: 0.85rem; font-weight: 600; }}
|
||||
.ok {{ background: #dcfce7; color: #166534; }}
|
||||
.warn {{ background: #fef9c3; color: #854d0e; }}
|
||||
.err {{ background: #fee2e2; color: #991b1b; }}
|
||||
.muted {{ background: #e4e4e7; color: #52525b; }}
|
||||
table {{ border-collapse: collapse; width: 100%; max-width: 56rem; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px #0001; }}
|
||||
th {{ text-align: left; padding: 0.75rem 1rem; width: 10rem; vertical-align: top; border-bottom: 1px solid #e4e4e7; }}
|
||||
td {{ padding: 0.75rem 1rem; border-bottom: 1px solid #e4e4e7; }}
|
||||
tr.sub .detail {{ margin: 0; font-size: 0.8rem; max-height: 10rem; overflow: auto; }}
|
||||
.links a {{ margin-right: 1rem; }}
|
||||
.json {{ margin-top: 2rem; max-width: 56rem; }}
|
||||
.json pre {{ background: #18181b; color: #e4e4e7; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.8rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>onGuard24</h1>
|
||||
<p class="links">
|
||||
<a href="/docs">Swagger</a>
|
||||
<a href="/openapi.json">OpenAPI</a>
|
||||
<a href="/health">/health</a>
|
||||
<a href="/api/v1/status">JSON статус</a>
|
||||
</p>
|
||||
<h2>Проверки доступа</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="json">
|
||||
<h3>Полный ответ <code>/api/v1/status</code></h3>
|
||||
<pre>{payload}</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
77
onguard24/status_snapshot.py
Normal file
77
onguard24/status_snapshot.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""Единая сборка ответа /api/v1/status (БД, Vault, Grafana, Forgejo)."""
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from onguard24.config import Settings
|
||||
from onguard24.integrations import forgejo_api, grafana_api
|
||||
from onguard24.vaultcheck import ping as vault_ping
|
||||
|
||||
|
||||
async def build(request: Request) -> dict:
|
||||
out: dict = {"service": "onGuard24"}
|
||||
pool = getattr(request.app.state, "pool", None)
|
||||
settings: Settings = request.app.state.settings
|
||||
|
||||
if pool:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.fetchval("SELECT 1")
|
||||
out["database"] = {"status": "ok"}
|
||||
except Exception as e:
|
||||
out["database"] = {"status": "error", "detail": str(e)}
|
||||
else:
|
||||
out["database"] = "disabled"
|
||||
|
||||
if settings.vault_addr and settings.vault_token:
|
||||
ok, err = await vault_ping(settings.vault_addr, settings.vault_token)
|
||||
if ok:
|
||||
out["vault"] = {"status": "ok", "url": settings.vault_addr}
|
||||
else:
|
||||
out["vault"] = {"status": "error", "detail": err, "url": settings.vault_addr}
|
||||
else:
|
||||
out["vault"] = "disabled"
|
||||
|
||||
gu = settings.grafana_url.strip()
|
||||
if not gu:
|
||||
out["grafana"] = "disabled"
|
||||
elif settings.grafana_service_account_token.strip():
|
||||
ok, err = await grafana_api.ping(gu, settings.grafana_service_account_token)
|
||||
if ok:
|
||||
user, _ = await grafana_api.get_signed_in_user(gu, settings.grafana_service_account_token)
|
||||
entry: dict = {"status": "ok", "url": gu, "api": "authenticated"}
|
||||
if user:
|
||||
login = user.get("login") or user.get("email")
|
||||
if login:
|
||||
entry["service_account_login"] = login
|
||||
out["grafana"] = entry
|
||||
else:
|
||||
out["grafana"] = {"status": "error", "detail": err, "url": gu}
|
||||
else:
|
||||
live_ok, live_err = await grafana_api.health_live(gu)
|
||||
if live_ok:
|
||||
out["grafana"] = {
|
||||
"status": "reachable",
|
||||
"url": gu,
|
||||
"detail": "задай GRAFANA_SERVICE_ACCOUNT_TOKEN для вызовов API",
|
||||
}
|
||||
else:
|
||||
out["grafana"] = {"status": "error", "detail": live_err, "url": gu}
|
||||
|
||||
fj = settings.forgejo_url.strip()
|
||||
if not fj:
|
||||
out["forgejo"] = "disabled"
|
||||
elif settings.forgejo_token.strip():
|
||||
entry_fj = await forgejo_api.probe(fj, settings.forgejo_token)
|
||||
out["forgejo"] = entry_fj
|
||||
else:
|
||||
pub_ok, pub_err = await forgejo_api.health_public(fj)
|
||||
if pub_ok:
|
||||
out["forgejo"] = {
|
||||
"status": "reachable",
|
||||
"url": fj,
|
||||
"detail": "задай FORGEJO_TOKEN (Personal Access Token в Forgejo)",
|
||||
}
|
||||
else:
|
||||
out["forgejo"] = {"status": "error", "detail": pub_err, "url": fj}
|
||||
|
||||
return out
|
||||
18
onguard24/vaultcheck.py
Normal file
18
onguard24/vaultcheck.py
Normal file
@ -0,0 +1,18 @@
|
||||
import httpx
|
||||
|
||||
|
||||
async def ping(addr: str, token: str) -> tuple[bool, str | None]:
|
||||
if not addr or not token:
|
||||
return False, "vault addr or token empty"
|
||||
base = addr.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0, verify=True) as client:
|
||||
r = await client.get(
|
||||
f"{base}/v1/sys/health",
|
||||
headers={"X-Vault-Token": token},
|
||||
)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
if r.status_code in (200, 429, 503, 501):
|
||||
return True, None
|
||||
return False, f"http {r.status_code}"
|
||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
@ -0,0 +1,28 @@
|
||||
[project]
|
||||
name = "onguard24"
|
||||
version = "1.0.0"
|
||||
description = "onGuard24 — модульный сервис (аналог IRM)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
"asyncpg>=0.30.0",
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
onguard = "onguard24.main:run"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["onguard24"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>onGuard24</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
web/package.json
Normal file
22
web/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "onguard24-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
32
web/src/App.tsx
Normal file
32
web/src/App.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Status = Record<string, unknown>;
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/status")
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then(setStatus)
|
||||
.catch((e) => setErr(String(e)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 640 }}>
|
||||
<h1>onGuard24</h1>
|
||||
<p>Модульный монолит: API на FastAPI, модули — заглушки.</p>
|
||||
<h2>Статус</h2>
|
||||
{err && <p style={{ color: "crimson" }}>Ошибка: {err}</p>}
|
||||
{status && (
|
||||
<pre style={{ background: "#f4f4f5", padding: "1rem", borderRadius: 8 }}>
|
||||
{JSON.stringify(status, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<p style={{ marginTop: "2rem", color: "#666" }}>
|
||||
Бэкенд: <code>uvicorn onguard24.main:app --port 8080</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
web/src/index.css
Normal file
5
web/src/index.css
Normal file
@ -0,0 +1,5 @@
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
color: #18181b;
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-js",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
web/tsconfig.node.json
Normal file
10
web/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
web/vite.config.ts
Normal file
13
web/vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://127.0.0.1:8080", changeOrigin: true },
|
||||
"/health": { target: "http://127.0.0.1:8080", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user