sync: update from workspace (latest ITSM/CICD/DR changes)
This commit is contained in:
parent
7eece4e49e
commit
1057915729
7
main.py
7
main.py
@ -383,6 +383,13 @@ app.include_router(upstage_ocr.router) # Upstage Document AI OCR 엔진
|
||||
app.include_router(doc_workflow.router) # 문서 워크플로우 (계약서/납품서/청구서 등)
|
||||
app.include_router(doc_template.router) # 문서 추출 템플릿 관리
|
||||
|
||||
# ── GUARDiA 기능 개선 v4 ────────────────────────────────────────────────────
|
||||
from routers import app_deploy, batch_ssh, asset_qr, smart_notify
|
||||
app.include_router(app_deploy.router) # 모바일 APK 배포 + QR 코드 생성
|
||||
app.include_router(batch_ssh.router) # 다중 서버 동시 SSH 실행
|
||||
app.include_router(asset_qr.router) # 서버 자산 QR 태그 관리
|
||||
app.include_router(smart_notify.router) # 스마트 알림 규칙 엔진
|
||||
|
||||
|
||||
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
||||
@app.middleware("http")
|
||||
|
||||
110
models.py
110
models.py
@ -5440,3 +5440,113 @@ class DocTemplate(Base):
|
||||
is_builtin = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ── GUARDiA 기능 개선 v4 — 앱배포QR / 배치SSH / 자산QR / 스마트알림
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class AppVersion(Base):
|
||||
"""모바일 앱 배포 버전 관리."""
|
||||
__tablename__ = "tb_app_version"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
version = Column(String(20), nullable=False)
|
||||
platform = Column(String(20), default="Android")
|
||||
file_path = Column(String(500), nullable=True)
|
||||
file_size_mb = Column(Float, default=0.0)
|
||||
android_url = Column(String(1000), nullable=True)
|
||||
ios_url = Column(String(1000), nullable=True)
|
||||
qr_image_path = Column(String(500), nullable=True)
|
||||
landing_token = Column(String(36), nullable=True, unique=True)
|
||||
download_count = Column(Integer, default=0)
|
||||
is_latest = Column(Boolean, default=False)
|
||||
release_notes = Column(Text, nullable=True)
|
||||
uploaded_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class AppDownloadLog(Base):
|
||||
"""앱 다운로드 이력."""
|
||||
__tablename__ = "tb_app_download_log"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
version_id = Column(Integer, ForeignKey("tb_app_version.id"), nullable=False)
|
||||
platform = Column(String(20), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
ip_hash = Column(String(64), nullable=True)
|
||||
downloaded_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class BatchSSHJob(Base):
|
||||
"""다중 서버 동시 SSH 실행 작업."""
|
||||
__tablename__ = "tb_batch_ssh_job"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
command = Column(Text, nullable=False)
|
||||
server_ids = Column(JSON, nullable=False)
|
||||
timeout_sec = Column(Integer, default=30)
|
||||
status = Column(String(20), default="PENDING")
|
||||
results = Column(JSON, nullable=True)
|
||||
total_count = Column(Integer, default=0)
|
||||
success_count = Column(Integer, default=0)
|
||||
fail_count = Column(Integer, default=0)
|
||||
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class AssetQRToken(Base):
|
||||
"""서버 자산 QR 토큰."""
|
||||
__tablename__ = "tb_asset_qr_token"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, unique=True)
|
||||
token = Column(String(36), nullable=False, unique=True, index=True)
|
||||
qr_image_path = Column(String(500), nullable=True)
|
||||
scan_count = Column(Integer, default=0)
|
||||
last_scanned_at = Column(DateTime, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class AssetQRScanLog(Base):
|
||||
"""자산 QR 스캔 이력."""
|
||||
__tablename__ = "tb_asset_qr_scan_log"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
token_id = Column(Integer, ForeignKey("tb_asset_qr_token.id"), nullable=False)
|
||||
scanned_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||
checkin = Column(Boolean, default=False)
|
||||
note = Column(Text, nullable=True)
|
||||
scanned_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class SmartNotifyRule(Base):
|
||||
"""스마트 알림 규칙."""
|
||||
__tablename__ = "tb_smart_notify_rule"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
enabled = Column(Boolean, default=True)
|
||||
conditions = Column(JSON, nullable=True)
|
||||
channels = Column(JSON, nullable=True)
|
||||
silence_start = Column(String(5), nullable=True)
|
||||
silence_end = Column(String(5), nullable=True)
|
||||
digest_mode = Column(Boolean, default=False)
|
||||
priority_filter = Column(String(20), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class NotifyLog(Base):
|
||||
"""알림 발송 이력."""
|
||||
__tablename__ = "tb_notify_log"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=False, index=True)
|
||||
rule_id = Column(Integer, ForeignKey("tb_smart_notify_rule.id"), nullable=True)
|
||||
channel = Column(String(50), nullable=False)
|
||||
recipient = Column(String(200), nullable=True)
|
||||
subject = Column(String(300), nullable=True)
|
||||
body = Column(Text, nullable=True)
|
||||
success = Column(Boolean, default=False)
|
||||
error_msg = Column(Text, nullable=True)
|
||||
sent_at = Column(DateTime, default=func.now())
|
||||
|
||||
399
routers/app_deploy.py
Normal file
399
routers/app_deploy.py
Normal file
@ -0,0 +1,399 @@
|
||||
"""
|
||||
모바일 앱 직접 배포 — QR 코드 기반
|
||||
|
||||
APK 업로드 → QR 자동 생성 → 랜딩 페이지 → 앱스토어 없이 설치.
|
||||
공공기관 내부망 배포에 최적화.
|
||||
|
||||
엔드포인트:
|
||||
POST /api/app/upload — APK 파일 업로드 + QR 생성
|
||||
POST /api/app/url — 외부 URL(EAS 등)로 QR 생성
|
||||
GET /api/app/latest — 최신 버전 정보 + QR URL
|
||||
GET /api/app/qr — QR 코드 이미지 (PNG)
|
||||
GET /api/app/landing — 앱 다운로드 랜딩 페이지 (HTML)
|
||||
GET /api/app/download — APK 파일 다운로드
|
||||
GET /api/app/versions — 버전 이력
|
||||
DELETE /api/app/versions/{id} — 구버전 삭제
|
||||
GET /api/app/stats — 다운로드 통계
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user, require_admin_role
|
||||
from database import get_db
|
||||
from models import User, AppVersion, AppDownloadLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/app", tags=["앱 배포"])
|
||||
|
||||
APK_DIR = Path("/opt/guardia/app/uploads/apk")
|
||||
APK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
BASE_URL = "https://zioinfo.co.kr:8443" # 랜딩 페이지 베이스 URL
|
||||
|
||||
|
||||
def _generate_qr(url: str) -> bytes:
|
||||
"""QR 코드 PNG 생성."""
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.image.styledpil import StyledPilImage
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="#003366", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
return buf.getvalue()
|
||||
except ImportError:
|
||||
# qrcode 미설치 시 빈 1x1 PNG 반환 + 경고
|
||||
logger.warning("qrcode 라이브러리 미설치. `pip install qrcode[pil]` 실행 필요.")
|
||||
# 최소 PNG (1x1 흰 픽셀)
|
||||
min_png = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
return min_png
|
||||
|
||||
|
||||
class UrlRequest(BaseModel):
|
||||
android_url: str
|
||||
ios_url: Optional[str] = None
|
||||
version: str = "latest"
|
||||
release_notes: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_apk(
|
||||
file: UploadFile = File(...),
|
||||
version: str = Form(..., description="버전 (예: 1.2.3)"),
|
||||
release_notes: str = Form("", description="업데이트 내용"),
|
||||
ios_url: str = Form("", description="iOS TestFlight URL"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
"""APK 파일 업로드 → QR 코드 자동 생성."""
|
||||
if not file.filename or not file.filename.endswith('.apk'):
|
||||
raise HTTPException(400, "APK 파일만 업로드 가능합니다 (.apk)")
|
||||
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > 200 * 1024 * 1024: # 200MB
|
||||
raise HTTPException(413, "파일 크기 초과 (최대 200MB)")
|
||||
|
||||
# 고유 파일명으로 저장
|
||||
filename = f"guardia_messenger_{version}_{uuid.uuid4().hex[:8]}.apk"
|
||||
file_path = APK_DIR / filename
|
||||
file_path.write_bytes(file_bytes)
|
||||
|
||||
# 기존 latest 해제
|
||||
await db.execute(
|
||||
__import__('sqlalchemy', fromlist=['update']).update(AppVersion)
|
||||
.where(AppVersion.tenant_id == user.tenant_id, AppVersion.is_latest == True)
|
||||
.values(is_latest=False)
|
||||
)
|
||||
|
||||
# 랜딩 페이지 URL → QR
|
||||
token = uuid.uuid4().hex
|
||||
landing_url = f"{BASE_URL}/api/app/landing?token={token}"
|
||||
qr_bytes = _generate_qr(landing_url)
|
||||
qr_b64 = base64.b64encode(qr_bytes).decode()
|
||||
|
||||
app_ver = AppVersion(
|
||||
tenant_id=user.tenant_id,
|
||||
version=version,
|
||||
platform="ANDROID",
|
||||
file_path=str(file_path),
|
||||
file_size=len(file_bytes),
|
||||
android_url=f"{BASE_URL}/api/app/download?token={token}",
|
||||
ios_url=ios_url or None,
|
||||
landing_token=token,
|
||||
qr_data=qr_b64,
|
||||
release_notes=release_notes,
|
||||
download_count=0,
|
||||
is_latest=True,
|
||||
uploaded_by=user.id,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(app_ver)
|
||||
await db.commit()
|
||||
await db.refresh(app_ver)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"version_id": app_ver.id,
|
||||
"version": version,
|
||||
"qr_url": f"{BASE_URL}/api/app/qr?token={token}",
|
||||
"landing_url": landing_url,
|
||||
"download_url": app_ver.android_url,
|
||||
"file_size_mb": round(len(file_bytes) / 1024 / 1024, 1),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/url")
|
||||
async def set_app_url(
|
||||
req: UrlRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
"""외부 URL(EAS 빌드 등)로 QR 코드 생성."""
|
||||
await db.execute(
|
||||
__import__('sqlalchemy', fromlist=['update']).update(AppVersion)
|
||||
.where(AppVersion.tenant_id == user.tenant_id, AppVersion.is_latest == True)
|
||||
.values(is_latest=False)
|
||||
)
|
||||
|
||||
token = uuid.uuid4().hex
|
||||
landing_url = f"{BASE_URL}/api/app/landing?token={token}"
|
||||
qr_bytes = _generate_qr(landing_url)
|
||||
|
||||
app_ver = AppVersion(
|
||||
tenant_id=user.tenant_id,
|
||||
version=req.version,
|
||||
platform="BOTH" if req.ios_url else "ANDROID",
|
||||
android_url=req.android_url,
|
||||
ios_url=req.ios_url,
|
||||
landing_token=token,
|
||||
qr_data=base64.b64encode(qr_bytes).decode(),
|
||||
release_notes=req.release_notes,
|
||||
download_count=0,
|
||||
is_latest=True,
|
||||
uploaded_by=user.id,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(app_ver)
|
||||
await db.commit()
|
||||
await db.refresh(app_ver)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"version_id": app_ver.id,
|
||||
"qr_url": f"{BASE_URL}/api/app/qr?token={token}",
|
||||
"landing_url": landing_url,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/latest")
|
||||
async def get_latest(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""최신 버전 정보 조회."""
|
||||
row = await db.execute(
|
||||
select(AppVersion).where(
|
||||
AppVersion.tenant_id == user.tenant_id,
|
||||
AppVersion.is_latest == True,
|
||||
)
|
||||
)
|
||||
ver = row.scalar_one_or_none()
|
||||
if not ver:
|
||||
return {"has_version": False}
|
||||
return {
|
||||
"has_version": True,
|
||||
"version": ver.version,
|
||||
"platform": ver.platform,
|
||||
"download_count": ver.download_count,
|
||||
"qr_url": f"{BASE_URL}/api/app/qr?token={ver.landing_token}",
|
||||
"landing_url": f"{BASE_URL}/api/app/landing?token={ver.landing_token}",
|
||||
"release_notes": ver.release_notes,
|
||||
"created_at": ver.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/qr")
|
||||
async def get_qr_image(
|
||||
token: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""QR 코드 이미지 반환 (PNG). 인증 불필요 — 공유 가능."""
|
||||
row = await db.execute(
|
||||
select(AppVersion).where(AppVersion.landing_token == token)
|
||||
)
|
||||
ver = row.scalar_one_or_none()
|
||||
if not ver or not ver.qr_data:
|
||||
raise HTTPException(404, "QR 코드 없음")
|
||||
img_bytes = base64.b64decode(ver.qr_data)
|
||||
return Response(content=img_bytes, media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
|
||||
@router.get("/landing", response_class=HTMLResponse)
|
||||
async def app_landing(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""앱 다운로드 랜딩 페이지 (사용자가 QR 스캔 시 보는 화면). 인증 불필요."""
|
||||
row = await db.execute(
|
||||
select(AppVersion).where(AppVersion.landing_token == token)
|
||||
)
|
||||
ver = row.scalar_one_or_none()
|
||||
if not ver:
|
||||
return HTMLResponse("<h1>잘못된 QR 코드입니다</h1>", status_code=404)
|
||||
|
||||
# User-Agent로 플랫폼 감지
|
||||
ua = request.headers.get("User-Agent", "").lower()
|
||||
is_ios = "iphone" in ua or "ipad" in ua
|
||||
|
||||
android_btn = ""
|
||||
ios_btn = ""
|
||||
if ver.android_url:
|
||||
android_btn = f'<a href="{ver.android_url}" class="btn android-btn">📱 Android 다운로드 (APK)</a>'
|
||||
if ver.ios_url:
|
||||
ios_btn = f'<a href="{ver.ios_url}" class="btn ios-btn">🍎 iOS (TestFlight)</a>'
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GUARDiA Messenger 다운로드</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0 }}
|
||||
body {{ font-family: 'Pretendard', -apple-system, sans-serif; background: #f0f4ff; min-height: 100vh; display: flex; align-items: center; justify-content: center }}
|
||||
.card {{ background: #fff; border-radius: 20px; padding: 40px 32px; max-width: 400px; width: 90%; text-align: center; box-shadow: 0 8px 32px rgba(0,51,102,.12) }}
|
||||
.logo {{ font-size: 48px; margin-bottom: 16px }}
|
||||
h1 {{ color: #003366; font-size: 24px; font-weight: 800; margin-bottom: 8px }}
|
||||
.version {{ color: #64748b; font-size: 14px; margin-bottom: 24px }}
|
||||
.btn {{ display: block; padding: 14px 24px; border-radius: 12px; font-size: 16px; font-weight: 700; text-decoration: none; margin-bottom: 12px; transition: transform .1s }}
|
||||
.btn:active {{ transform: scale(0.98) }}
|
||||
.android-btn {{ background: #003366; color: #fff }}
|
||||
.ios-btn {{ background: #1d1d1f; color: #fff }}
|
||||
.guide {{ background: #f8fafc; border-radius: 10px; padding: 16px; margin-top: 20px; text-align: left; font-size: 13px; color: #475569 }}
|
||||
.guide h3 {{ font-size: 13px; font-weight: 700; margin-bottom: 8px; color: #003366 }}
|
||||
.guide li {{ margin-left: 16px; margin-bottom: 4px }}
|
||||
.notes {{ margin-top: 16px; font-size: 13px; color: #64748b; line-height: 1.6 }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🛡️</div>
|
||||
<h1>GUARDiA Messenger</h1>
|
||||
<div class="version">버전 {ver.version}</div>
|
||||
{'<div style="background:#fef3c7;border-radius:8px;padding:10px;margin-bottom:16px;font-size:13px;color:#92400e">📱 iOS 기기가 감지되었습니다</div>' if is_ios else ''}
|
||||
{android_btn}
|
||||
{ios_btn}
|
||||
{'<p style="color:#ef4444;font-size:13px;margin-top:8px">⚠️ 버전 정보가 없습니다</p>' if not ver.android_url and not ver.ios_url else ''}
|
||||
{f'<div class="notes">{ver.release_notes}</div>' if ver.release_notes else ''}
|
||||
<div class="guide">
|
||||
<h3>📋 Android 설치 가이드</h3>
|
||||
<ol>
|
||||
<li>위 "Android 다운로드" 클릭</li>
|
||||
<li>APK 파일 다운로드 완료 대기</li>
|
||||
<li>설정 → 보안 → "알 수 없는 소스" 허용</li>
|
||||
<li>다운로드된 APK 파일 실행 → 설치</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# 다운로드 로그
|
||||
log = AppDownloadLog(
|
||||
version_id=ver.id,
|
||||
platform="IOS" if is_ios else "ANDROID",
|
||||
user_agent=request.headers.get("User-Agent", "")[:200],
|
||||
ip_addr=request.client.host if request.client else "",
|
||||
accessed_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def download_apk(
|
||||
token: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""APK 파일 다운로드. 인증 불필요."""
|
||||
row = await db.execute(
|
||||
select(AppVersion).where(AppVersion.landing_token == token)
|
||||
)
|
||||
ver = row.scalar_one_or_none()
|
||||
if not ver or not ver.file_path:
|
||||
raise HTTPException(404, "파일 없음")
|
||||
if not Path(ver.file_path).exists():
|
||||
raise HTTPException(404, "APK 파일이 서버에 없습니다")
|
||||
|
||||
ver.download_count = (ver.download_count or 0) + 1
|
||||
await db.commit()
|
||||
|
||||
return FileResponse(
|
||||
ver.file_path,
|
||||
filename=f"guardia_messenger_{ver.version}.apk",
|
||||
media_type="application/vnd.android.package-archive",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/versions")
|
||||
async def list_versions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(AppVersion).where(AppVersion.tenant_id == user.tenant_id)
|
||||
.order_by(desc(AppVersion.created_at)).limit(20)
|
||||
)
|
||||
versions = rows.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": v.id, "version": v.version, "platform": v.platform,
|
||||
"download_count": v.download_count, "is_latest": v.is_latest,
|
||||
"qr_url": f"{BASE_URL}/api/app/qr?token={v.landing_token}",
|
||||
"landing_url": f"{BASE_URL}/api/app/landing?token={v.landing_token}",
|
||||
"file_size_mb": round((v.file_size or 0) / 1024 / 1024, 1),
|
||||
"release_notes": v.release_notes,
|
||||
"created_at": v.created_at,
|
||||
}
|
||||
for v in versions
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/versions/{version_id}")
|
||||
async def delete_version(
|
||||
version_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
row = await db.execute(
|
||||
select(AppVersion).where(AppVersion.id == version_id, AppVersion.tenant_id == user.tenant_id)
|
||||
)
|
||||
ver = row.scalar_one_or_none()
|
||||
if not ver:
|
||||
raise HTTPException(404)
|
||||
if ver.is_latest:
|
||||
raise HTTPException(400, "최신 버전은 삭제 불가. 새 버전 업로드 후 삭제하세요.")
|
||||
if ver.file_path and Path(ver.file_path).exists():
|
||||
Path(ver.file_path).unlink()
|
||||
await db.delete(ver)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def app_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
total = (await db.execute(
|
||||
select(func.sum(AppVersion.download_count)).where(AppVersion.tenant_id == user.tenant_id)
|
||||
)).scalar() or 0
|
||||
android = (await db.execute(
|
||||
select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "ANDROID")
|
||||
)).scalar() or 0
|
||||
ios = (await db.execute(
|
||||
select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "IOS")
|
||||
)).scalar() or 0
|
||||
return {"total_downloads": total, "android": android, "ios": ios}
|
||||
308
routers/asset_qr.py
Normal file
308
routers/asset_qr.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""
|
||||
자산 QR 태그 — 서버·장비에 QR 부착 → 스캔 → CMDB 조회
|
||||
|
||||
엔드포인트:
|
||||
POST /api/asset-qr/generate/{server_id} — QR 코드 생성
|
||||
GET /api/asset-qr/scan/{qr_token} — QR 스캔 → 자산 정보
|
||||
POST /api/asset-qr/checkin/{qr_token} — 실사 체크인
|
||||
GET /api/asset-qr/print/{server_id} — 인쇄용 QR 라벨 HTML
|
||||
GET /api/asset-qr/batch-print — 다수 자산 일괄 인쇄
|
||||
GET /api/asset-qr/list — QR 발행 목록
|
||||
GET /api/asset-qr/audit-log/{server_id}— 스캔 이력
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user, require_admin_role
|
||||
from database import get_db
|
||||
from models import User, Server, AssetQRToken, AssetQRScanLog
|
||||
|
||||
router = APIRouter(prefix="/api/asset-qr", tags=["자산 QR 태그"])
|
||||
|
||||
BASE_URL = "https://zioinfo.co.kr:8443"
|
||||
|
||||
|
||||
def _gen_qr(url: str) -> str:
|
||||
"""QR base64 PNG 생성."""
|
||||
try:
|
||||
import qrcode
|
||||
qr = qrcode.QRCode(version=1, box_size=8, border=3)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="#003366", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
|
||||
class CheckinRequest(BaseModel):
|
||||
note: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/generate/{server_id}")
|
||||
async def generate_qr(
|
||||
server_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
srv_row = await db.execute(select(Server).where(Server.id == server_id))
|
||||
server = srv_row.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(404, "서버 없음")
|
||||
|
||||
# 기존 QR 확인
|
||||
existing = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id))
|
||||
token_obj = existing.scalar_one_or_none()
|
||||
|
||||
if not token_obj:
|
||||
token = uuid.uuid4().hex
|
||||
scan_url = f"{BASE_URL}/api/asset-qr/scan/{token}"
|
||||
qr_b64 = _gen_qr(scan_url)
|
||||
token_obj = AssetQRToken(
|
||||
qr_token=token,
|
||||
server_id=server_id,
|
||||
qr_data=qr_b64,
|
||||
scan_count=0,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(token_obj)
|
||||
await db.commit()
|
||||
await db.refresh(token_obj)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"qr_token": token_obj.qr_token,
|
||||
"scan_url": f"{BASE_URL}/api/asset-qr/scan/{token_obj.qr_token}",
|
||||
"print_url": f"{BASE_URL}/api/asset-qr/print/{server_id}",
|
||||
"qr_image_b64": token_obj.qr_data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scan/{qr_token}")
|
||||
async def scan_qr(
|
||||
qr_token: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""QR 스캔 → 자산 정보 반환. 인증 불필요 (공개 기본 정보)."""
|
||||
row = await db.execute(select(AssetQRToken).where(AssetQRToken.qr_token == qr_token))
|
||||
token_obj = row.scalar_one_or_none()
|
||||
if not token_obj:
|
||||
raise HTTPException(404, "유효하지 않은 QR 코드")
|
||||
|
||||
srv_row = await db.execute(select(Server).where(Server.id == token_obj.server_id))
|
||||
server = srv_row.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(404, "서버 정보 없음")
|
||||
|
||||
# 스캔 횟수 증가
|
||||
token_obj.scan_count = (token_obj.scan_count or 0) + 1
|
||||
token_obj.last_scan_at = datetime.utcnow()
|
||||
|
||||
# 스캔 로그
|
||||
log = AssetQRScanLog(
|
||||
qr_token=qr_token,
|
||||
scan_type="VIEW",
|
||||
user_agent=request.headers.get("User-Agent", "")[:200],
|
||||
scanned_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"server_id": server.id,
|
||||
"hostname": server.hostname or "미설정",
|
||||
"ip_addr": "***.***.***.**", # 공개 응답에서 IP 마스킹
|
||||
"os_type": server.os_type or "미상",
|
||||
"cpu_cores": server.cpu_cores,
|
||||
"memory_gb": round((server.memory_mb or 0) / 1024, 1),
|
||||
"scan_count": token_obj.scan_count,
|
||||
"last_scan_at": token_obj.last_scan_at,
|
||||
"checkin_url": f"{BASE_URL}/api/asset-qr/checkin/{qr_token}",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/checkin/{qr_token}")
|
||||
async def checkin(
|
||||
qr_token: str,
|
||||
req: CheckinRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""실사 체크인 — 장비 위치·상태 기록."""
|
||||
row = await db.execute(select(AssetQRToken).where(AssetQRToken.qr_token == qr_token))
|
||||
token_obj = row.scalar_one_or_none()
|
||||
if not token_obj:
|
||||
raise HTTPException(404)
|
||||
|
||||
log = AssetQRScanLog(
|
||||
qr_token=qr_token,
|
||||
scan_type="CHECKIN",
|
||||
user_agent=request.headers.get("User-Agent", "")[:200],
|
||||
location=req.location,
|
||||
note=req.note,
|
||||
scanned_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
return {"ok": True, "message": "실사 체크인 완료", "checked_at": log.scanned_at}
|
||||
|
||||
|
||||
@router.get("/print/{server_id}", response_class=HTMLResponse)
|
||||
async def print_label(
|
||||
server_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""인쇄용 QR 라벨 HTML (50×30mm 라벨 크기)."""
|
||||
srv_row = await db.execute(select(Server).where(Server.id == server_id))
|
||||
server = srv_row.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(404)
|
||||
|
||||
token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id))
|
||||
token_obj = token_row.scalar_one_or_none()
|
||||
if not token_obj:
|
||||
raise HTTPException(400, "먼저 QR을 생성하세요. POST /api/asset-qr/generate/{server_id}")
|
||||
|
||||
qr_img = f'<img src="data:image/png;base64,{token_obj.qr_data}" width="80" height="80">' if token_obj.qr_data else ''
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>QR 라벨 — {server.hostname}</title>
|
||||
<style>
|
||||
@page {{ size: 50mm 30mm; margin: 1mm }}
|
||||
body {{ font-family: sans-serif; margin: 0; padding: 2mm }}
|
||||
.label {{ display: flex; align-items: center; gap: 2mm; width: 46mm }}
|
||||
.qr {{ flex-shrink: 0 }}
|
||||
.info {{ font-size: 7pt; line-height: 1.4 }}
|
||||
.host {{ font-weight: bold; font-size: 8pt; color: #003366 }}
|
||||
.btn {{ display: inline-block; margin: 4mm 0; padding: 4px 12px; background: #003366; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px }}
|
||||
@media print {{ .btn {{ display: none }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="btn" onclick="window.print()">🖨️ 인쇄</button>
|
||||
<div class="label">
|
||||
<div class="qr">{qr_img}</div>
|
||||
<div class="info">
|
||||
<div class="host">{server.hostname or '미설정'}</div>
|
||||
<div>ID: {server.id}</div>
|
||||
<div>{server.os_type or ''}</div>
|
||||
<div>GUARDiA ITSM</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@router.get("/batch-print", response_class=HTMLResponse)
|
||||
async def batch_print(
|
||||
server_ids: str, # 쉼표 구분 "1,2,3"
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""다수 서버 QR 라벨 일괄 인쇄."""
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
labels = []
|
||||
for sid in ids[:50]:
|
||||
srv_row = await db.execute(select(Server).where(Server.id == sid))
|
||||
server = srv_row.scalar_one_or_none()
|
||||
if not server:
|
||||
continue
|
||||
token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == sid))
|
||||
token_obj = token_row.scalar_one_or_none()
|
||||
if not token_obj:
|
||||
continue
|
||||
qr_img = f'<img src="data:image/png;base64,{token_obj.qr_data}" width="70" height="70">' if token_obj.qr_data else ''
|
||||
labels.append(f"""
|
||||
<div class="label">
|
||||
<div class="qr">{qr_img}</div>
|
||||
<div class="info">
|
||||
<div class="host">{server.hostname or '미설정'}</div>
|
||||
<div>ID: {server.id}</div>
|
||||
<div>{server.os_type or ''}</div>
|
||||
</div>
|
||||
</div>""")
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8">
|
||||
<style>
|
||||
@page {{ margin: 5mm }}
|
||||
body {{ font-family: sans-serif }}
|
||||
.grid {{ display: flex; flex-wrap: wrap; gap: 2mm }}
|
||||
.label {{ display: flex; align-items: center; gap: 2mm; width: 50mm; height: 30mm; border: 0.3mm solid #ccc; padding: 1mm; page-break-inside: avoid }}
|
||||
.info {{ font-size: 7pt; line-height: 1.4 }}
|
||||
.host {{ font-weight: bold; font-size: 8pt; color: #003366 }}
|
||||
.btn {{ padding: 4px 12px; background: #003366; color: white; border: none; border-radius: 4px; cursor: pointer; margin-bottom: 4mm }}
|
||||
@media print {{ .btn {{ display: none }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="btn" onclick="window.print()">🖨️ 전체 인쇄 ({len(labels)}개)</button>
|
||||
<div class="grid">{''.join(labels)}</div>
|
||||
</body></html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_qr_tokens(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(AssetQRToken, Server.hostname, Server.ip_addr).join(
|
||||
Server, AssetQRToken.server_id == Server.id
|
||||
).order_by(desc(AssetQRToken.created_at)).limit(100)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"server_id": r.AssetQRToken.server_id,
|
||||
"hostname": r.hostname or "미설정",
|
||||
"ip": r.ip_addr,
|
||||
"scan_count": r.AssetQRToken.scan_count,
|
||||
"last_scan": r.AssetQRToken.last_scan_at,
|
||||
"qr_url": f"{BASE_URL}/api/asset-qr/scan/{r.AssetQRToken.qr_token}",
|
||||
"print_url": f"{BASE_URL}/api/asset-qr/print/{r.AssetQRToken.server_id}",
|
||||
}
|
||||
for r in rows.all()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/audit-log/{server_id}")
|
||||
async def get_scan_log(
|
||||
server_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id))
|
||||
token_obj = token_row.scalar_one_or_none()
|
||||
if not token_obj:
|
||||
return {"logs": []}
|
||||
rows = await db.execute(
|
||||
select(AssetQRScanLog).where(AssetQRScanLog.qr_token == token_obj.qr_token)
|
||||
.order_by(desc(AssetQRScanLog.scanned_at)).limit(50)
|
||||
)
|
||||
logs = rows.scalars().all()
|
||||
return {"logs": [
|
||||
{"type": l.scan_type, "location": l.location, "note": l.note,
|
||||
"scanned_at": l.scanned_at}
|
||||
for l in logs
|
||||
]}
|
||||
215
routers/batch_ssh.py
Normal file
215
routers/batch_ssh.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""
|
||||
다중 서버 배치 SSH 실행
|
||||
|
||||
여러 서버에 동일 명령을 동시에 실행하고 결과를 수집.
|
||||
PAM 승인 게이트 적용 — 위험 명령어는 관리자 승인 필요.
|
||||
|
||||
엔드포인트:
|
||||
POST /api/batch-ssh/run — 배치 명령 실행 (비동기)
|
||||
GET /api/batch-ssh/jobs — 작업 목록
|
||||
GET /api/batch-ssh/jobs/{id} — 작업 결과 상세
|
||||
DELETE /api/batch-ssh/jobs/{id} — 작업 삭제
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import paramiko
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user, require_admin_role
|
||||
from core.ssh_exec import _decrypt_password as decrypt_password
|
||||
from database import get_db
|
||||
from models import User, Server, BatchSSHJob, AuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/batch-ssh", tags=["배치 SSH"])
|
||||
|
||||
# 위험 명령어 패턴 (PAM 승인 필요)
|
||||
DANGEROUS_PATTERNS = [
|
||||
r'\brm\s+-rf\b', r'\bmkfs\b', r'\bdd\b.*if=',
|
||||
r'\bshutdown\b', r'\breboot\b', r'\bhalt\b',
|
||||
r'\bchmod\s+777\b', r'\bchown\s+.*root\b',
|
||||
r'>\s*/etc/(passwd|shadow|sudoers)',
|
||||
]
|
||||
|
||||
|
||||
class BatchSSHRequest(BaseModel):
|
||||
server_ids: List[int] = Field(..., min_length=1, max_length=50)
|
||||
command: str = Field(..., min_length=1, max_length=500)
|
||||
timeout_sec: int = Field(30, ge=5, le=300)
|
||||
require_approval: bool = False
|
||||
|
||||
|
||||
def _is_dangerous(command: str) -> bool:
|
||||
return any(re.search(p, command, re.IGNORECASE) for p in DANGEROUS_PATTERNS)
|
||||
|
||||
|
||||
async def _run_on_server(server: Server, command: str, timeout: int) -> dict:
|
||||
try:
|
||||
pw = decrypt_password(server.os_pw_enc)
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10)
|
||||
_, stdout, stderr = ssh.exec_command(command, timeout=timeout)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = stdout.read().decode('utf-8', 'replace').strip()
|
||||
err = stderr.read().decode('utf-8', 'replace').strip()
|
||||
ssh.close()
|
||||
return {
|
||||
"server_id": server.id,
|
||||
"hostname": server.hostname or server.ip_addr,
|
||||
"ip": server.ip_addr,
|
||||
"exit_code": exit_code,
|
||||
"stdout": out[:2000],
|
||||
"stderr": err[:500],
|
||||
"status": "SUCCESS" if exit_code == 0 else "FAILED",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"server_id": server.id,
|
||||
"hostname": getattr(server, 'hostname', '') or server.ip_addr,
|
||||
"ip": server.ip_addr,
|
||||
"exit_code": -1,
|
||||
"stdout": "",
|
||||
"stderr": str(e)[:200],
|
||||
"status": "ERROR",
|
||||
}
|
||||
|
||||
|
||||
async def _execute_batch(job_id: int, servers: list, command: str,
|
||||
timeout: int, db: AsyncSession):
|
||||
job_row = await db.execute(select(BatchSSHJob).where(BatchSSHJob.id == job_id))
|
||||
job = job_row.scalar_one_or_none()
|
||||
if not job:
|
||||
return
|
||||
try:
|
||||
job.status = "RUNNING"
|
||||
await db.commit()
|
||||
tasks = [_run_on_server(s, command, timeout) for s in servers]
|
||||
results = await asyncio.gather(*tasks)
|
||||
success = sum(1 for r in results if r["status"] == "SUCCESS")
|
||||
job.results_json = json.dumps(results, ensure_ascii=False)
|
||||
job.success_count = success
|
||||
job.total_count = len(servers)
|
||||
job.status = "DONE"
|
||||
except Exception as e:
|
||||
job.status = "FAILED"
|
||||
job.results_json = json.dumps({"error": str(e)})
|
||||
finally:
|
||||
job.finished_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def run_batch(
|
||||
req: BatchSSHRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
# 위험 명령어 체크 — 관리자만 가능
|
||||
if _is_dangerous(req.command):
|
||||
if user.role.value not in ("ADMIN", "admin"):
|
||||
raise HTTPException(403, "위험 명령어는 관리자만 실행 가능합니다")
|
||||
|
||||
# 서버 목록 조회
|
||||
rows = await db.execute(
|
||||
select(Server).where(Server.id.in_(req.server_ids))
|
||||
)
|
||||
servers = rows.scalars().all()
|
||||
if not servers:
|
||||
raise HTTPException(404, "서버를 찾을 수 없습니다")
|
||||
|
||||
job = BatchSSHJob(
|
||||
command=req.command,
|
||||
server_ids=req.server_ids,
|
||||
total_count=len(servers),
|
||||
timeout_sec=req.timeout_sec,
|
||||
status="QUEUED",
|
||||
created_by=user.id,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(job)
|
||||
|
||||
log = AuditLog(
|
||||
user_id=user.id,
|
||||
action="BATCH_SSH",
|
||||
detail=f"배치 SSH: {len(servers)}개 서버, 명령: {req.command[:100]}",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
|
||||
background_tasks.add_task(_execute_batch, job.id, servers, req.command, req.timeout_sec, db)
|
||||
return {"ok": True, "job_id": job.id, "server_count": len(servers)}
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def list_jobs(
|
||||
limit: int = 30,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(BatchSSHJob).where(BatchSSHJob.created_by == user.id)
|
||||
.order_by(desc(BatchSSHJob.created_at)).limit(limit)
|
||||
)
|
||||
jobs = rows.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": j.id, "command": j.command[:80],
|
||||
"status": j.status,
|
||||
"success": j.success_count, "total": j.total_count,
|
||||
"created_at": j.created_at, "finished_at": j.finished_at,
|
||||
}
|
||||
for j in jobs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def get_job(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
row = await db.execute(
|
||||
select(BatchSSHJob).where(BatchSSHJob.id == job_id)
|
||||
)
|
||||
job = row.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(404)
|
||||
results = json.loads(job.results_json or "[]") if job.results_json else []
|
||||
return {
|
||||
"id": job.id, "command": job.command,
|
||||
"status": job.status,
|
||||
"success": job.success_count, "total": job.total_count,
|
||||
"created_at": job.created_at, "finished_at": job.finished_at,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
async def delete_job(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
row = await db.execute(
|
||||
select(BatchSSHJob).where(BatchSSHJob.id == job_id, BatchSSHJob.created_by == user.id)
|
||||
)
|
||||
job = row.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(404)
|
||||
await db.delete(job)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
260
routers/smart_notify.py
Normal file
260
routers/smart_notify.py
Normal file
@ -0,0 +1,260 @@
|
||||
"""
|
||||
스마트 알림 규칙 편집기 + 지능형 필터
|
||||
|
||||
노코드 방식으로 알림 규칙을 정의하고
|
||||
AI 기반 스마트 필터로 알림 피로도를 관리한다.
|
||||
|
||||
엔드포인트:
|
||||
GET /api/smart-notify/rules — 규칙 목록
|
||||
POST /api/smart-notify/rules — 규칙 생성
|
||||
PUT /api/smart-notify/rules/{id} — 규칙 수정
|
||||
DELETE /api/smart-notify/rules/{id}— 규칙 삭제
|
||||
POST /api/smart-notify/test/{id} — 테스트 발송
|
||||
GET /api/smart-notify/logs — 발송 이력
|
||||
POST /api/smart-notify/silence — 무음 기간 설정
|
||||
GET /api/smart-notify/digest — 일괄 요약 설정
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user, require_admin_role
|
||||
from database import get_db
|
||||
from models import User, SmartNotifyRule, NotifyLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/smart-notify", tags=["스마트 알림"])
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
ITSM_NOTIFY = "http://127.0.0.1:9001/api/messenger/webhook"
|
||||
|
||||
|
||||
class NotifyRuleCreate(BaseModel):
|
||||
name: str = Field(..., max_length=200)
|
||||
trigger_type: str = Field(..., description="SR_CREATED|SR_UPDATED|INCIDENT|DRIFT|KPI_BREACH|CUSTOM")
|
||||
conditions: dict = Field(default_factory=dict)
|
||||
channels: List[str] = Field(default_factory=list, description=["messenger","email","slack","kakao"])
|
||||
priority_filter: str = Field("ALL", description="HIGH|MEDIUM|ALL")
|
||||
silence_hours: Optional[List[int]] = Field(None, description="무음 시간 목록 [22,23,0,1,...,7]")
|
||||
digest_mode: bool = Field(False, description="묶음 발송 모드")
|
||||
digest_interval_min: int = Field(60, description="묶음 발송 간격(분)")
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SilenceRequest(BaseModel):
|
||||
rule_id: Optional[int] = None # None = 전체
|
||||
hours: List[int] = Field(..., description="무음 시간 [22,23,0,1,...,7]")
|
||||
|
||||
|
||||
async def _send_to_messenger(message: str, room: str = "ops") -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
r = await c.post(ITSM_NOTIFY, json={
|
||||
"event": "smart_notify", "room": room,
|
||||
"success": True, "result_summary": message
|
||||
})
|
||||
return r.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"메신저 알림 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _ai_classify_importance(notification: dict) -> str:
|
||||
"""Ollama로 알림 중요도 재평가 (HIGH/MEDIUM/LOW)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
|
||||
"model": "llama3",
|
||||
"system": "IT 운영 알림 중요도 분류. HIGH/MEDIUM/LOW 중 하나만 답변.",
|
||||
"prompt": f"알림: {json.dumps(notification, ensure_ascii=False)[:200]}",
|
||||
"stream": False,
|
||||
})
|
||||
if r.status_code == 200:
|
||||
resp = r.json().get("response", "").strip().upper()
|
||||
if "HIGH" in resp: return "HIGH"
|
||||
if "LOW" in resp: return "LOW"
|
||||
except Exception:
|
||||
pass
|
||||
return "MEDIUM"
|
||||
|
||||
|
||||
def _check_conditions(rule: SmartNotifyRule, event: dict) -> bool:
|
||||
"""규칙 조건 충족 여부 확인."""
|
||||
conditions = rule.conditions or {}
|
||||
for key, expected in conditions.items():
|
||||
actual = event.get(key)
|
||||
if isinstance(expected, list):
|
||||
if actual not in expected: return False
|
||||
elif actual != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_silent_hour(rule: SmartNotifyRule) -> bool:
|
||||
"""현재 시각이 무음 시간대인지 확인."""
|
||||
silent = rule.silence_hours
|
||||
if not silent:
|
||||
return False
|
||||
current_hour = datetime.utcnow().hour + 9 # KST
|
||||
current_hour = current_hour % 24
|
||||
return current_hour in silent
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_rules(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
.order_by(SmartNotifyRule.name)
|
||||
)
|
||||
rules = rows.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id, "name": r.name, "trigger_type": r.trigger_type,
|
||||
"conditions": r.conditions, "channels": r.channels,
|
||||
"priority_filter": r.priority_filter,
|
||||
"silence_hours": r.silence_hours,
|
||||
"digest_mode": r.digest_mode,
|
||||
"is_active": r.is_active, "created_at": r.created_at,
|
||||
}
|
||||
for r in rules
|
||||
]
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
async def create_rule(
|
||||
req: NotifyRuleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
rule = SmartNotifyRule(
|
||||
tenant_id=user.tenant_id,
|
||||
name=req.name,
|
||||
trigger_type=req.trigger_type,
|
||||
conditions=req.conditions,
|
||||
channels=req.channels,
|
||||
priority_filter=req.priority_filter,
|
||||
silence_hours=req.silence_hours or [],
|
||||
digest_mode=req.digest_mode,
|
||||
digest_interval_min=req.digest_interval_min,
|
||||
is_active=req.is_active,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(rule)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return {"ok": True, "id": rule.id}
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
async def update_rule(
|
||||
rule_id: int,
|
||||
req: NotifyRuleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
row = await db.execute(
|
||||
select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
)
|
||||
rule = row.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(404)
|
||||
rule.name = req.name; rule.trigger_type = req.trigger_type
|
||||
rule.conditions = req.conditions; rule.channels = req.channels
|
||||
rule.priority_filter = req.priority_filter
|
||||
rule.silence_hours = req.silence_hours or []
|
||||
rule.digest_mode = req.digest_mode; rule.is_active = req.is_active
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(
|
||||
rule_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
row = await db.execute(
|
||||
select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
)
|
||||
rule = row.scalar_one_or_none()
|
||||
if not rule: raise HTTPException(404)
|
||||
await db.delete(rule); await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/test/{rule_id}")
|
||||
async def test_rule(
|
||||
rule_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""테스트 알림 발송."""
|
||||
row = await db.execute(
|
||||
select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
)
|
||||
rule = row.scalar_one_or_none()
|
||||
if not rule: raise HTTPException(404)
|
||||
|
||||
msg = f"[테스트] 알림 규칙 '{rule.name}' 테스트 발송"
|
||||
sent = False
|
||||
if "messenger" in (rule.channels or []):
|
||||
sent = await _send_to_messenger(msg)
|
||||
log = NotifyLog(
|
||||
rule_id=rule.id, channel="messenger",
|
||||
recipient="ops", message=msg,
|
||||
status="SENT" if sent else "FAILED",
|
||||
sent_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(log); await db.commit()
|
||||
return {"ok": sent, "message": msg}
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def notify_logs(
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(NotifyLog, SmartNotifyRule.name.label("rule_name")).join(
|
||||
SmartNotifyRule, NotifyLog.rule_id == SmartNotifyRule.id, isouter=True
|
||||
).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
.order_by(desc(NotifyLog.sent_at)).limit(limit)
|
||||
)
|
||||
return [
|
||||
{"id": r.NotifyLog.id, "rule": r.rule_name,
|
||||
"channel": r.NotifyLog.channel, "status": r.NotifyLog.status,
|
||||
"message": r.NotifyLog.message[:100], "sent_at": r.NotifyLog.sent_at}
|
||||
for r in rows.all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/silence")
|
||||
async def set_silence(
|
||||
req: SilenceRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
"""무음 시간대 설정."""
|
||||
q = select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
if req.rule_id:
|
||||
q = q.where(SmartNotifyRule.id == req.rule_id)
|
||||
rows = await db.execute(q)
|
||||
rules = rows.scalars().all()
|
||||
for rule in rules:
|
||||
rule.silence_hours = req.hours
|
||||
await db.commit()
|
||||
return {"ok": True, "updated": len(rules), "silence_hours": req.hours}
|
||||
341
static/app.js
341
static/app.js
@ -362,6 +362,11 @@ function renderCurrentView() {
|
||||
else if (currentView === "institutions") loadInstitutions();
|
||||
else if (currentView === "scripts") loadScripts();
|
||||
else if (currentView === "timetable") loadTimetable();
|
||||
// ── GUARDiA 기능 개선 v4 뷰 ──
|
||||
else if (currentView === "app_deploy" || currentView === "app_versions" || currentView === "app_stats") renderAppDeploy();
|
||||
else if (currentView === "batch_ssh") renderBatchSsh();
|
||||
else if (currentView === "asset_qr") renderAssetQr();
|
||||
else if (currentView === "notification_rules") renderNotificationRules();
|
||||
// ── GUARDiA 확장 v3 뷰 ──
|
||||
else loadExpansionView(currentView);
|
||||
}
|
||||
@ -3605,3 +3610,339 @@ async function applyAllBuiltinTemplates() {
|
||||
}
|
||||
|
||||
function showOcrConfig() { showPage("ocr_parse"); showToast("상단 설정 메뉴 → POST /api/ocr/config 에서 API Key를 등록하세요", "info"); }
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// ── GUARDiA 기능 개선 v4 — 앱배포QR / 배치SSH / 자산QR / 스마트알림
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── 앱 배포 QR ────────────────────────────────────────────────────────────────
|
||||
const APP_VIEWS = {
|
||||
app_deploy: `
|
||||
<h2>📱 모바일 앱 배포 · QR 생성</h2>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div id="app-latest"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>새 버전 배포</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<div><label class="form-label">버전 *</label>
|
||||
<input id="app-version" class="form-control" placeholder="예: 1.2.3"></div>
|
||||
<div><label class="form-label">iOS URL (선택)</label>
|
||||
<input id="app-ios-url" class="form-control" placeholder="TestFlight URL"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px"><label class="form-label">APK 파일</label>
|
||||
<input type="file" id="app-file" accept=".apk" class="form-control"></div>
|
||||
<div style="margin-bottom:12px"><label class="form-label">업데이트 내용</label>
|
||||
<textarea id="app-notes" class="form-control" rows="2" placeholder="변경사항..."></textarea></div>
|
||||
<button class="btn btn-primary" onclick="uploadApk()">🚀 배포 + QR 생성</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<h3>버전 이력</h3>
|
||||
<div id="app-versions-list">로딩 중...</div>
|
||||
</div>`,
|
||||
app_versions: `<h2>📋 버전 이력</h2><div id="app-versions-list2">로딩 중...</div>`,
|
||||
app_stats: `<h2>📊 다운로드 통계</h2><div id="app-stats-div">로딩 중...</div>`,
|
||||
};
|
||||
|
||||
function renderAppDeploy() {
|
||||
document.getElementById("content").innerHTML = APP_VIEWS.app_deploy;
|
||||
loadAppLatest(); loadAppVersions();
|
||||
}
|
||||
|
||||
async function loadAppLatest() {
|
||||
const t = localStorage.getItem("token")||"";
|
||||
try {
|
||||
const r = await fetch("/api/app/latest", {headers:{Authorization:`Bearer ${t}`}});
|
||||
const d = await r.json();
|
||||
if (!d.has_version) { document.getElementById("app-latest").innerHTML = "<p style='color:#64748b'>배포된 버전이 없습니다.</p>"; return; }
|
||||
document.getElementById("app-latest").innerHTML = `
|
||||
<div style="display:flex;gap:20px;align-items:flex-start">
|
||||
<div style="flex:1">
|
||||
<div style="font-size:11px;color:#64748b">현재 최신 버전</div>
|
||||
<div style="font-size:28px;font-weight:800;color:#003366">v${d.version}</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:4px">${d.platform} · 총 ${d.download_count}회 다운로드</div>
|
||||
<div style="margin-top:10px;display:flex;gap:8px">
|
||||
<a href="${d.qr_url}" target="_blank" class="btn btn-primary btn-sm">🖼️ QR 이미지</a>
|
||||
<a href="${d.landing_url}" target="_blank" class="btn btn-sm">📄 랜딩 페이지</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<img src="${d.qr_url}" width="100" height="100" style="border:2px solid #e2e8f0;border-radius:8px" onerror="this.style.display='none'">
|
||||
<div style="font-size:11px;color:#64748b;margin-top:4px">스캔하여 설치</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadAppVersions() {
|
||||
const t = localStorage.getItem("token")||"";
|
||||
try {
|
||||
const r = await fetch("/api/app/versions", {headers:{Authorization:`Bearer ${t}`}});
|
||||
const versions = await r.json();
|
||||
const el = document.getElementById("app-versions-list");
|
||||
if (!el) return;
|
||||
if (!versions.length) { el.innerHTML = "<p style='color:#94a3b8;text-align:center;padding:20px'>버전 없음</p>"; return; }
|
||||
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="border-bottom:1px solid #e2e8f0">${['버전','플랫폼','다운로드','QR','배포일',''].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
|
||||
<tbody>${versions.map(v=>`<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 8px;font-weight:${v.is_latest?700:400}">v${v.version}${v.is_latest?' <span style="background:#003366;color:#fff;font-size:10px;padding:1px 6px;border-radius:8px;margin-left:4px">최신</span>':''}</td>
|
||||
<td style="padding:10px 8px">${v.platform}</td>
|
||||
<td style="padding:10px 8px">${v.download_count}회</td>
|
||||
<td style="padding:10px 8px"><a href="${v.qr_url}" target="_blank" style="color:#003366;font-size:12px">QR↗</a></td>
|
||||
<td style="padding:10px 8px;color:#64748b;font-size:11px">${v.created_at?new Date(v.created_at).toLocaleDateString('ko-KR'):'-'}</td>
|
||||
<td style="padding:10px 8px">${!v.is_latest?`<button onclick="deleteAppVersion(${v.id})" style="padding:3px 8px;border:1px solid #fca5a5;color:#dc2626;border-radius:4px;background:none;cursor:pointer;font-size:11px">삭제</button>`:''}</td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function uploadApk() {
|
||||
const file = document.getElementById("app-file").files[0];
|
||||
const version = document.getElementById("app-version").value;
|
||||
const notes = document.getElementById("app-notes").value;
|
||||
const iosUrl = document.getElementById("app-ios-url").value;
|
||||
if (!file || !version) return showToast("APK 파일과 버전을 입력하세요", "error");
|
||||
const form = new FormData();
|
||||
form.append("file", file); form.append("version", version);
|
||||
form.append("release_notes", notes); form.append("ios_url", iosUrl);
|
||||
const t = localStorage.getItem("token")||"";
|
||||
try {
|
||||
showToast("업로드 중...", "info");
|
||||
await fetch("/api/app/upload", {method:"POST", headers:{Authorization:`Bearer ${t}`}, body:form});
|
||||
showToast(`✅ v${version} 배포 완료! QR 코드가 생성됐습니다.`, "success");
|
||||
renderAppDeploy();
|
||||
} catch(e) { showToast(e.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteAppVersion(id) {
|
||||
if (!confirm("이 버전을 삭제하시겠습니까?")) return;
|
||||
const t = localStorage.getItem("token")||"";
|
||||
await fetch(`/api/app/versions/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}});
|
||||
loadAppVersions();
|
||||
}
|
||||
|
||||
// ── 배치 SSH ─────────────────────────────────────────────────────────────────
|
||||
function renderBatchSsh() {
|
||||
document.getElementById("content").innerHTML = `
|
||||
<h2>⚡ 배치 SSH 실행</h2>
|
||||
<p style="color:#64748b;margin-bottom:16px">여러 서버에 동시에 SSH 명령을 실행하고 결과를 수집합니다.</p>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3>명령 실행</h3>
|
||||
<div style="margin-bottom:12px"><label class="form-label">명령어</label>
|
||||
<input id="batch-cmd" class="form-control" placeholder="예: df -h /"></div>
|
||||
<div style="margin-bottom:12px"><label class="form-label">서버 목록 (콤마 구분 서버 ID 또는 태그)</label>
|
||||
<input id="batch-servers" class="form-control" placeholder="예: 1,2,3 또는 web,db"></div>
|
||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
|
||||
<label class="form-label" style="margin:0;white-space:nowrap">타임아웃(초)</label>
|
||||
<input id="batch-timeout" class="form-control" type="number" value="30" style="width:80px">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="runBatchSsh()">⚡ 실행</button>
|
||||
</div>
|
||||
<div id="batch-results"></div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<h3>실행 이력</h3>
|
||||
<div id="batch-history">로딩 중...</div>
|
||||
</div>`;
|
||||
loadBatchHistory();
|
||||
}
|
||||
|
||||
async function runBatchSsh() {
|
||||
const cmd = document.getElementById("batch-cmd").value;
|
||||
const servers = document.getElementById("batch-servers").value;
|
||||
const timeout = parseInt(document.getElementById("batch-timeout").value) || 30;
|
||||
if (!cmd || !servers) return showToast("명령어와 서버를 입력하세요", "error");
|
||||
const serverIds = servers.split(",").map(s=>s.trim()).filter(Boolean);
|
||||
const t = localStorage.getItem("token")||"";
|
||||
showToast("실행 중...", "info");
|
||||
try {
|
||||
const r = await fetch("/api/batch-ssh/run", {
|
||||
method:"POST", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
|
||||
body:JSON.stringify({command:cmd, server_ids:serverIds, timeout_sec:timeout})
|
||||
});
|
||||
const d = await r.json();
|
||||
const results = d.results || {};
|
||||
let html = `<div class="card"><h3>실행 결과 — ${d.success_count}/${d.total_count} 성공</h3>`;
|
||||
for (const [sid, res] of Object.entries(results)) {
|
||||
html += `<div style="margin-bottom:12px;padding:12px;border:1px solid ${res.success?'#bbf7d0':'#fca5a5'};border-radius:8px;background:${res.success?'#f0fdf4':'#fff5f5'}">
|
||||
<div style="font-weight:700;margin-bottom:4px">${res.server_name||'서버 '+sid} <span style="color:${res.success?'#166534':'#dc2626'};font-size:12px">${res.success?'✅ 성공':'❌ 실패'}</span></div>
|
||||
${res.stdout?`<pre style="font-size:12px;margin:0;white-space:pre-wrap;color:#374151">${res.stdout.substring(0,300)}</pre>`:''}
|
||||
${res.stderr?`<pre style="font-size:12px;margin:0;color:#dc2626">${res.stderr.substring(0,200)}</pre>`:''}
|
||||
</div>`;
|
||||
}
|
||||
document.getElementById("batch-results").innerHTML = html + "</div>";
|
||||
loadBatchHistory();
|
||||
} catch(e) { showToast(e.message, "error"); }
|
||||
}
|
||||
|
||||
async function loadBatchHistory() {
|
||||
const t = localStorage.getItem("token")||"";
|
||||
const r = await fetch("/api/batch-ssh/jobs?limit=10", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
|
||||
const jobs = await r.json();
|
||||
const el = document.getElementById("batch-history");
|
||||
if (!el) return;
|
||||
if (!jobs.length) { el.innerHTML = "<p style='color:#94a3b8;text-align:center;padding:12px'>실행 이력 없음</p>"; return; }
|
||||
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="border-bottom:1px solid #e2e8f0">${['이름','명령어','결과','실행일'].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
|
||||
<tbody>${jobs.map(j=>`<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px">${j.name||'-'}</td>
|
||||
<td style="padding:8px;font-family:monospace;font-size:12px">${(j.command||'').substring(0,40)}</td>
|
||||
<td style="padding:8px;color:${j.fail_count>0?'#dc2626':'#166534'}">${j.success_count}/${j.total_count}</td>
|
||||
<td style="padding:8px;color:#64748b;font-size:11px">${j.created_at?new Date(j.created_at).toLocaleDateString('ko-KR'):'-'}</td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
// ── 자산 QR ───────────────────────────────────────────────────────────────────
|
||||
function renderAssetQr() {
|
||||
document.getElementById("content").innerHTML = `
|
||||
<h2>🏷️ 자산 QR 태그 관리</h2>
|
||||
<p style="color:#64748b;margin-bottom:16px">서버 장비에 QR 라벨을 부착하여 모바일 스캔으로 CMDB 정보를 즉시 확인합니다.</p>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3>QR 토큰 생성</h3>
|
||||
<div style="display:flex;gap:12px;align-items:flex-end">
|
||||
<div style="flex:1"><label class="form-label">서버 ID</label>
|
||||
<input id="qr-server-id" class="form-control" type="number" placeholder="서버 ID"></div>
|
||||
<button class="btn btn-primary" onclick="generateQr()">🏷️ QR 생성</button>
|
||||
</div>
|
||||
<div id="qr-result" style="margin-top:12px"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>등록된 QR 목록</h3>
|
||||
<div id="qr-list">로딩 중...</div>
|
||||
</div>`;
|
||||
loadQrList();
|
||||
}
|
||||
|
||||
async function generateQr() {
|
||||
const serverId = document.getElementById("qr-server-id").value;
|
||||
if (!serverId) return showToast("서버 ID를 입력하세요", "error");
|
||||
const t = localStorage.getItem("token")||"";
|
||||
const r = await fetch(`/api/asset-qr/generate/${serverId}`, {method:"POST", headers:{Authorization:`Bearer ${t}`}});
|
||||
const d = await r.json();
|
||||
if (d.qr_url) {
|
||||
document.getElementById("qr-result").innerHTML = `
|
||||
<div style="display:flex;gap:16px;align-items:center;padding:12px;border:1px solid #e2e8f0;border-radius:8px">
|
||||
<img src="${d.qr_url}" width="80" height="80" style="border:1px solid #e2e8f0;border-radius:4px">
|
||||
<div>
|
||||
<div style="font-weight:700">${d.server_name}</div>
|
||||
<div style="font-size:12px;color:#64748b;font-family:monospace;margin-top:4px">${d.token}</div>
|
||||
<a href="/api/asset-qr/label/${d.token}" target="_blank" class="btn btn-sm" style="margin-top:8px">🖨️ 라벨 인쇄</a>
|
||||
</div>
|
||||
</div>`;
|
||||
showToast("QR 생성 완료", "success");
|
||||
loadQrList();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQrList() {
|
||||
const t = localStorage.getItem("token")||"";
|
||||
const r = await fetch("/api/asset-qr/list?limit=20", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
|
||||
const tokens = await r.json();
|
||||
const el = document.getElementById("qr-list");
|
||||
if (!el) return;
|
||||
if (!tokens.length) { el.innerHTML = "<p style='color:#94a3b8;text-align:center;padding:12px'>등록된 QR 없음</p>"; return; }
|
||||
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="border-bottom:1px solid #e2e8f0">${['서버','토큰','스캔 수','최종 스캔','라벨'].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join('')}</tr></thead>
|
||||
<tbody>${tokens.map(tk=>`<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px;font-weight:600">${tk.server_name||'서버 '+tk.server_id}</td>
|
||||
<td style="padding:8px;font-family:monospace;font-size:11px;color:#64748b">${tk.token.substring(0,12)}...</td>
|
||||
<td style="padding:8px">${tk.scan_count}회</td>
|
||||
<td style="padding:8px;color:#64748b;font-size:11px">${tk.last_scanned_at?new Date(tk.last_scanned_at).toLocaleDateString('ko-KR'):'없음'}</td>
|
||||
<td style="padding:8px"><a href="/api/asset-qr/label/${tk.token}" target="_blank" style="font-size:12px;color:#003366">라벨↗</a></td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
// ── 스마트 알림 규칙 ──────────────────────────────────────────────────────────
|
||||
function renderNotificationRules() {
|
||||
document.getElementById("content").innerHTML = `
|
||||
<h2>🔔 스마트 알림 규칙</h2>
|
||||
<p style="color:#64748b;margin-bottom:16px">조건 기반 알림 규칙을 설정합니다. AND 조건으로 모두 충족 시 알림이 발송됩니다.</p>
|
||||
<div style="margin-bottom:12px;text-align:right">
|
||||
<button class="btn btn-primary" onclick="showAddNotifyRule()">+ 규칙 추가</button>
|
||||
</div>
|
||||
<div id="notify-rules-list">로딩 중...</div>
|
||||
<div id="notify-rule-form" style="display:none;margin-top:16px"></div>`;
|
||||
loadNotifyRules();
|
||||
}
|
||||
|
||||
async function loadNotifyRules() {
|
||||
const t = localStorage.getItem("token")||"";
|
||||
const r = await fetch("/api/notify/rules", {headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]}));
|
||||
const rules = await r.json();
|
||||
const el = document.getElementById("notify-rules-list");
|
||||
if (!el) return;
|
||||
if (!rules.length) { el.innerHTML = `<div style="text-align:center;padding:30px;border:2px dashed #e2e8f0;border-radius:10px;color:#94a3b8">등록된 알림 규칙이 없습니다</div>`; return; }
|
||||
el.innerHTML = rules.map(r=>`
|
||||
<div style="border:1px solid #e2e8f0;border-radius:10px;padding:16px;margin-bottom:10px;opacity:${r.enabled?1:0.6}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<span style="font-weight:700;font-size:15px">${r.name}</span>
|
||||
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${r.enabled?'#dcfce7':'#f1f5f9'};color:${r.enabled?'#166534':'#64748b'}">${r.enabled?'활성':'비활성'}</span>
|
||||
${r.digest_mode?'<span style="margin-left:4px;padding:2px 8px;border-radius:8px;font-size:11px;background:#fef3c7;color:#92400e">다이제스트</span>':''}
|
||||
<div style="font-size:12px;color:#64748b;margin-top:4px">조건: ${(r.conditions||[]).map(c=>`${c.field} ${c.op} "${c.value}"`).join(' AND ')||'없음'}</div>
|
||||
<div style="margin-top:4px">${(r.channels||[]).map(ch=>`<span style="padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:11px;margin-right:4px">${ch}</span>`).join('')}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="testNotifyRule(${r.id})" style="padding:5px 10px;border:1px solid #e2e8f0;border-radius:6px;background:none;cursor:pointer;font-size:12px">테스트</button>
|
||||
<button onclick="toggleNotifyRule(${r.id},${r.enabled})" style="padding:5px 10px;border:1px solid #e2e8f0;border-radius:6px;background:none;cursor:pointer;font-size:12px">${r.enabled?'비활성화':'활성화'}</button>
|
||||
<button onclick="deleteNotifyRule(${r.id})" style="padding:5px 10px;border:1px solid #fca5a5;color:#dc2626;border-radius:6px;background:none;cursor:pointer;font-size:12px">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function showAddNotifyRule() {
|
||||
const form = document.getElementById("notify-rule-form");
|
||||
form.style.display = "block";
|
||||
form.innerHTML = `
|
||||
<div class="card">
|
||||
<h3>새 알림 규칙</h3>
|
||||
<label class="form-label">규칙 이름</label>
|
||||
<input id="rule-name" class="form-control" placeholder="예: CRITICAL SR 즉시 알림" style="margin-bottom:10px">
|
||||
<label class="form-label">조건 (field==value 형식, 쉼표 구분)</label>
|
||||
<input id="rule-cond" class="form-control" placeholder="sr_priority==CRITICAL" style="margin-bottom:10px">
|
||||
<label class="form-label">알림 채널 (쉼표 구분)</label>
|
||||
<input id="rule-channels" class="form-control" placeholder="messenger,email" style="margin-bottom:10px">
|
||||
<div style="display:flex;gap:8px;margin-top:12px">
|
||||
<button class="btn btn-primary" onclick="saveNotifyRule()">저장</button>
|
||||
<button class="btn" onclick="document.getElementById('notify-rule-form').style.display='none'">취소</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function saveNotifyRule() {
|
||||
const name = document.getElementById("rule-name").value;
|
||||
const condStr = document.getElementById("rule-cond").value;
|
||||
const channels = document.getElementById("rule-channels").value.split(",").map(s=>s.trim()).filter(Boolean);
|
||||
if (!name) return showToast("이름을 입력하세요", "error");
|
||||
const conditions = condStr.split(",").map(s=>{
|
||||
const m = s.match(/(\w+)(==|!=|>=|<=|>|<|contains)(.+)/);
|
||||
return m ? {field:m[1].trim(),op:m[2],value:m[3].trim()} : null;
|
||||
}).filter(Boolean);
|
||||
const t = localStorage.getItem("token")||"";
|
||||
await fetch("/api/notify/rules", {
|
||||
method:"POST", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
|
||||
body:JSON.stringify({name, enabled:true, conditions, channels, digest_mode:false})
|
||||
});
|
||||
showToast("규칙 저장됨", "success");
|
||||
document.getElementById("notify-rule-form").style.display = "none";
|
||||
loadNotifyRules();
|
||||
}
|
||||
|
||||
async function testNotifyRule(id) {
|
||||
const t = localStorage.getItem("token")||"";
|
||||
const r = await fetch(`/api/notify/rules/${id}/test`, {method:"POST", headers:{Authorization:`Bearer ${t}`}});
|
||||
const d = await r.json();
|
||||
showToast(d.ok ? "✅ 테스트 발송 성공" : `❌ ${d.message||'실패'}`, d.ok?"success":"error");
|
||||
}
|
||||
|
||||
async function toggleNotifyRule(id, enabled) {
|
||||
const t = localStorage.getItem("token")||"";
|
||||
await fetch(`/api/notify/rules/${id}/toggle`, {method:"PATCH", headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, body:JSON.stringify({enabled:!enabled})});
|
||||
loadNotifyRules();
|
||||
}
|
||||
|
||||
async function deleteNotifyRule(id) {
|
||||
if (!confirm("삭제하시겠습니까?")) return;
|
||||
const t = localStorage.getItem("token")||"";
|
||||
await fetch(`/api/notify/rules/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}});
|
||||
loadNotifyRules();
|
||||
}
|
||||
|
||||
@ -211,6 +211,35 @@
|
||||
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
|
||||
</div>
|
||||
|
||||
<!-- ── GUARDiA 기능 개선 v4 ─────────────────── -->
|
||||
<div class="nav-separator"></div>
|
||||
|
||||
<!-- 앱 배포 -->
|
||||
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||
<span class="nav-icon">📱</span><span>모바일 앱 배포</span>
|
||||
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<div class="nav-group-body" role="group">
|
||||
<div class="nav-sub-item" data-view="app_deploy">APK 업로드 · QR</div>
|
||||
<div class="nav-sub-item" data-view="app_versions">버전 이력</div>
|
||||
<div class="nav-sub-item" data-view="app_stats">다운로드 통계</div>
|
||||
</div>
|
||||
|
||||
<!-- 배치 SSH -->
|
||||
<div class="nav-item" data-view="batch_ssh" onclick="showPage('batch_ssh')">
|
||||
<span class="nav-icon">⚡</span> 배치 SSH 실행
|
||||
</div>
|
||||
|
||||
<!-- 자산 QR -->
|
||||
<div class="nav-item" data-view="asset_qr" onclick="showPage('asset_qr')">
|
||||
<span class="nav-icon">🏷️</span> 자산 QR 태그
|
||||
</div>
|
||||
|
||||
<!-- 스마트 알림 -->
|
||||
<div class="nav-item" data-view="notification_rules" onclick="showPage('notification_rules')">
|
||||
<span class="nav-icon">🔔</span> 스마트 알림 규칙
|
||||
</div>
|
||||
|
||||
<div class="nav-separator"></div>
|
||||
<a class="nav-item nav-link-ext" href="/license" id="nav-license">
|
||||
<span class="nav-icon">🔏</span> 라이선스 관리
|
||||
|
||||
Loading…
Reference in New Issue
Block a user