feat(enhance-v4): APK QR 배포 / 배치SSH / 자산QR / 스마트알림 / 웹메일 주소록+서명
- ITSM: app_deploy.py (APK 업로드·QR 생성·랜딩 페이지) - ITSM: batch_ssh.py (다중 서버 동시 SSH 실행) - ITSM: asset_qr.py (자산 QR 태그·체크인·라벨 인쇄) - ITSM: smart_notify.py (조건 기반 알림 규칙 엔진) - ITSM: models.py (AppVersion/BatchSSHJob/AssetQRToken/SmartNotifyRule 등 7개 모델) - ITSM: main.py (4개 신규 라우터 등록) - ITSM: static/app.js (앱배포·배치SSH·자산QR·알림규칙 4개 뷰) - ITSM: static/index.html (신규 사이드바 메뉴 4개) - Manager: AppDistribution.tsx (APK 업로드 UI·QR 표시·버전 관리) - Manager: NotificationRules.tsx (알림 규칙 편집기) - Manager: App.tsx + Sidebar.tsx (신규 라우트 등록) - Mail: contacts.py (주소록 CRUD·자동완성) - Mail: signature.py (HTML 서명 관리) - Mail: Contacts.tsx + SignatureEditor.tsx (프론트엔드 컴포넌트) - Messenger: scan.tsx (자산 QR 스캔 탭) - Messenger: _layout.tsx (QR 탭 추가) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f3987c4402
commit
0ebac500f5
@ -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")
|
||||
|
||||
@ -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
workspace/guardia-itsm/routers/app_deploy.py
Normal file
399
workspace/guardia-itsm/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
workspace/guardia-itsm/routers/asset_qr.py
Normal file
308
workspace/guardia-itsm/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
workspace/guardia-itsm/routers/batch_ssh.py
Normal file
215
workspace/guardia-itsm/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
workspace/guardia-itsm/routers/smart_notify.py
Normal file
260
workspace/guardia-itsm/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}
|
||||
@ -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> 라이선스 관리
|
||||
|
||||
@ -28,7 +28,10 @@ const KpiDashboard = lazy(() => import('./pages/KpiDashboard'))
|
||||
const BiAnalytics = lazy(() => import('./pages/BiAnalytics'))
|
||||
const BillingManage = lazy(() => import('./pages/BillingManage'))
|
||||
const IntegrationHub = lazy(() => import('./pages/IntegrationHub'))
|
||||
const AiPlatform = lazy(() => import('./pages/AiPlatform'))
|
||||
const AiPlatform = lazy(() => import('./pages/AiPlatform'))
|
||||
// ── GUARDiA 기능 개선 v4 ──
|
||||
const AppDistribution = lazy(() => import('./pages/AppDistribution'))
|
||||
const NotificationRules = lazy(() => import('./pages/NotificationRules'))
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
@ -73,6 +76,9 @@ export default function App() {
|
||||
<Route path="billing" element={<BillingManage />} />
|
||||
<Route path="integrations" element={<IntegrationHub />} />
|
||||
<Route path="ai-platform" element={<AiPlatform />} />
|
||||
{/* GUARDiA 기능 개선 v4 */}
|
||||
<Route path="app-distribution" element={<AppDistribution />} />
|
||||
<Route path="notification-rules" element={<NotificationRules />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -43,6 +43,9 @@ const NAV: NavItem[] = [
|
||||
{ label: 'AI 플랫폼', icon: '🧠', path: '/ai-platform' },
|
||||
{ label: '외부 연동', icon: '🔗', path: '/integrations' },
|
||||
{ label: '구독 · 과금', icon: '💰', path: '/billing' },
|
||||
// ── GUARDiA 기능 개선 v4 ──
|
||||
{ label: '앱 배포', icon: '📱', path: '/app-distribution' },
|
||||
{ label: '알림 규칙', icon: '🔔', path: '/notification-rules' },
|
||||
]
|
||||
|
||||
/* Variant 스타일 색상 상수 */
|
||||
|
||||
222
workspace/guardia-manager/frontend/src/pages/AppDistribution.tsx
Normal file
222
workspace/guardia-manager/frontend/src/pages/AppDistribution.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { guardiaApi } from '../api/clients'
|
||||
|
||||
interface AppVersion {
|
||||
id: number; version: string; platform: string
|
||||
download_count: number; is_latest: boolean
|
||||
qr_url: string; landing_url: string
|
||||
file_size_mb: number; release_notes: string; created_at: string
|
||||
}
|
||||
|
||||
export default function AppDistribution() {
|
||||
const [versions, setVersions] = useState<AppVersion[]>([])
|
||||
const [latest, setLatest] = useState<any>(null)
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [version, setVersion] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [iosUrl, setIosUrl] = useState('')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [tab, setTab] = useState<'upload'|'url'>('upload')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const [v, l, s] = await Promise.all([
|
||||
guardiaApi.get('/api/app/versions').then((r: any) => r.data).catch(() => []),
|
||||
guardiaApi.get('/api/app/latest').then((r: any) => r.data).catch(() => null),
|
||||
guardiaApi.get('/api/app/stats').then((r: any) => r.data).catch(() => null),
|
||||
])
|
||||
setVersions(v); setLatest(l); setStats(s)
|
||||
}
|
||||
|
||||
async function uploadApk() {
|
||||
const file = fileRef.current?.files?.[0]
|
||||
if (!file || !version) return alert('APK 파일과 버전을 입력하세요')
|
||||
setUploading(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('version', version)
|
||||
form.append('release_notes', notes)
|
||||
form.append('ios_url', iosUrl)
|
||||
const r: any = await guardiaApi.post('/api/app/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
alert(`✅ 배포 완료! 버전 ${version}\nQR 코드가 생성됐습니다.`)
|
||||
setVersion(''); setNotes(''); setIosUrl('')
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
load()
|
||||
} catch (e: any) {
|
||||
alert(`오류: ${e.response?.data?.detail || e.message}`)
|
||||
} finally { setUploading(false) }
|
||||
}
|
||||
|
||||
async function setUrl() {
|
||||
if (!externalUrl || !version) return alert('URL과 버전을 입력하세요')
|
||||
await guardiaApi.post('/api/app/url', {
|
||||
android_url: externalUrl, version, release_notes: notes, ios_url: iosUrl || undefined
|
||||
})
|
||||
alert('QR 코드 생성됨'); setExternalUrl(''); setVersion(''); setNotes('')
|
||||
load()
|
||||
}
|
||||
|
||||
async function deleteVersion(id: number) {
|
||||
if (!confirm('이 버전을 삭제하시겠습니까?')) return
|
||||
await guardiaApi.delete(`/api/app/versions/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
const S = { card: { background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, marginBottom: 16 } }
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 28px' }}>
|
||||
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>📱 모바일 앱 직접 배포</h2>
|
||||
<p style={{ color: '#64748b', marginBottom: 20 }}>APK를 업로드하면 QR 코드가 생성됩니다. 사용자는 QR 스캔만으로 앱을 설치할 수 있습니다 (앱스토어 불필요).</p>
|
||||
|
||||
{/* 현재 최신 버전 + QR */}
|
||||
{latest?.has_version && (
|
||||
<div style={{ ...S.card, display: 'flex', gap: 24, alignItems: 'flex-start', borderLeft: '4px solid #003366' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>현재 최신 버전</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: '#003366' }}>v{latest.version}</div>
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>{latest.platform} · 총 {latest.download_count}회 다운로드</div>
|
||||
{latest.release_notes && <div style={{ fontSize: 12, color: '#475569', marginTop: 8, lineHeight: 1.6 }}>{latest.release_notes}</div>}
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||
<a href={latest.qr_url} target="_blank" rel="noreferrer"
|
||||
style={{ padding: '7px 14px', background: '#003366', color: '#fff', borderRadius: 8, fontSize: 12, textDecoration: 'none' }}>
|
||||
🖼️ QR 이미지 열기
|
||||
</a>
|
||||
<a href={latest.landing_url} target="_blank" rel="noreferrer"
|
||||
style={{ padding: '7px 14px', border: '1px solid #e2e8f0', borderRadius: 8, fontSize: 12, textDecoration: 'none', color: '#1e293b' }}>
|
||||
📄 랜딩 페이지
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* QR 이미지 */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img src={latest.qr_url} alt="QR" width={120} height={120}
|
||||
style={{ border: '2px solid #e2e8f0', borderRadius: 8 }}
|
||||
onError={(e: any) => { e.target.style.display = 'none' }} />
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 4 }}>스캔하여 설치</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 통계 */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: '총 다운로드', val: stats.total_downloads, icon: '📥' },
|
||||
{ label: 'Android', val: stats.android, icon: '🤖' },
|
||||
{ label: 'iOS', val: stats.ios, icon: '🍎' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ ...S.card, textAlign: 'center', padding: 14 }}>
|
||||
<div style={{ fontSize: 22 }}>{s.icon}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#003366' }}>{s.val}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 폼 */}
|
||||
<div style={S.card}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>새 버전 배포</h3>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16, borderBottom: '1px solid #e2e8f0', paddingBottom: 0 }}>
|
||||
{[{id:'upload',label:'APK 파일 업로드'},{id:'url',label:'외부 URL 연결'}].map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id as any)} style={{
|
||||
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: tab === t.id ? 700 : 400,
|
||||
color: tab === t.id ? '#003366' : '#64748b',
|
||||
borderBottom: tab === t.id ? '2px solid #003366' : '2px solid transparent',
|
||||
}}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>버전 *</label>
|
||||
<input value={version} onChange={e => setVersion(e.target.value)} placeholder="예: 1.2.3"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>iOS URL (선택)</label>
|
||||
<input value={iosUrl} onChange={e => setIosUrl(e.target.value)} placeholder="TestFlight URL"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'upload' ? (
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>APK 파일 *</label>
|
||||
<input type="file" accept=".apk" ref={fileRef}
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box', marginBottom: 8 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>Android 다운로드 URL *</label>
|
||||
<input value={externalUrl} onChange={e => setExternalUrl(e.target.value)} placeholder="https://expo.dev/... 또는 직접 APK URL"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box', marginBottom: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>업데이트 내용</label>
|
||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2} placeholder="이번 버전 변경사항..."
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, resize: 'vertical', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={tab === 'upload' ? uploadApk : setUrl}
|
||||
disabled={uploading}
|
||||
style={{ padding: '9px 20px', background: uploading ? '#94a3b8' : '#003366', color: '#fff', border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||
{uploading ? '업로드 중...' : '🚀 배포 + QR 생성'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 버전 이력 */}
|
||||
<div style={S.card}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>버전 이력</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||
{['버전', '플랫폼', '크기', '다운로드', '상태', '배포일', ''].map(h => (
|
||||
<th key={h} style={{ textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600, color: '#64748b' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 24, color: '#94a3b8' }}>배포 이력 없음</td></tr>
|
||||
) : versions.map(v => (
|
||||
<tr key={v.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||
<td style={{ padding: '10px 12px', fontWeight: v.is_latest ? 700 : 400 }}>
|
||||
v{v.version} {v.is_latest && <span style={{ background: '#003366', color: '#fff', fontSize: 10, padding: '1px 6px', borderRadius: 8, marginLeft: 4 }}>최신</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>{v.platform}</td>
|
||||
<td style={{ padding: '10px 12px', color: '#64748b' }}>{v.file_size_mb > 0 ? `${v.file_size_mb}MB` : '-'}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{v.download_count}회</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<a href={v.qr_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: '#003366' }}>QR ↗</a>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: '#64748b', fontSize: 11 }}>
|
||||
{v.created_at ? new Date(v.created_at).toLocaleDateString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
{!v.is_latest && (
|
||||
<button onClick={() => deleteVersion(v.id)}
|
||||
style={{ padding: '3px 8px', border: '1px solid #fca5a5', color: '#dc2626', borderRadius: 4, background: 'none', cursor: 'pointer', fontSize: 11 }}>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { guardiaApi } from '../api/clients'
|
||||
|
||||
interface Condition { field: string; op: string; value: string }
|
||||
interface Rule {
|
||||
id: number; name: string; enabled: boolean
|
||||
conditions: Condition[]; channels: string[]
|
||||
silence_start?: string; silence_end?: string
|
||||
digest_mode: boolean; priority_filter: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const FIELDS = ['sr_category','sr_priority','server_cpu','server_memory','server_disk','sr_status','tenant_id']
|
||||
const OPS = ['==','!=','>','>=','<','<=','contains']
|
||||
const CHANNELS = ['messenger','email','sms']
|
||||
const PRIORITIES = ['','CRITICAL','HIGH','MEDIUM','LOW']
|
||||
|
||||
export default function NotificationRules() {
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [editing, setEditing] = useState<Partial<Rule> | null>(null)
|
||||
const [testResult, setTestResult] = useState<Record<number,string>>({})
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const r: any = await guardiaApi.get('/api/notify/rules').catch(() => ({ data: [] }))
|
||||
setRules(r.data)
|
||||
}
|
||||
|
||||
function newRule() {
|
||||
setEditing({
|
||||
name: '', enabled: true, conditions: [{ field: 'sr_priority', op: '==', value: 'CRITICAL' }],
|
||||
channels: ['messenger'], digest_mode: false, priority_filter: 'CRITICAL',
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
if (!editing.name) return alert('규칙 이름을 입력하세요')
|
||||
if (editing.id) {
|
||||
await guardiaApi.put(`/api/notify/rules/${editing.id}`, editing)
|
||||
} else {
|
||||
await guardiaApi.post('/api/notify/rules', editing)
|
||||
}
|
||||
setEditing(null); load()
|
||||
}
|
||||
|
||||
async function toggle(id: number, enabled: boolean) {
|
||||
await guardiaApi.patch(`/api/notify/rules/${id}/toggle`, { enabled: !enabled })
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('삭제하시겠습니까?')) return
|
||||
await guardiaApi.delete(`/api/notify/rules/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function test(id: number) {
|
||||
const r: any = await guardiaApi.post(`/api/notify/rules/${id}/test`).catch((e: any) => ({ data: { ok: false, message: e.message } }))
|
||||
setTestResult(prev => ({ ...prev, [id]: r.data.ok ? '✅ 테스트 발송 성공' : `❌ ${r.data.message}` }))
|
||||
setTimeout(() => setTestResult(prev => { const n = {...prev}; delete n[id]; return n }), 4000)
|
||||
}
|
||||
|
||||
function addCond() {
|
||||
setEditing(e => e ? { ...e, conditions: [...(e.conditions||[]), { field: 'sr_priority', op: '==', value: '' }] } : e)
|
||||
}
|
||||
|
||||
function removeCond(i: number) {
|
||||
setEditing(e => e ? { ...e, conditions: (e.conditions||[]).filter((_,idx) => idx !== i) } : e)
|
||||
}
|
||||
|
||||
function updateCond(i: number, key: keyof Condition, val: string) {
|
||||
setEditing(e => e ? { ...e, conditions: (e.conditions||[]).map((c, idx) => idx === i ? { ...c, [key]: val } : c) } : e)
|
||||
}
|
||||
|
||||
function toggleChannel(ch: string) {
|
||||
setEditing(e => {
|
||||
if (!e) return e
|
||||
const chs = e.channels || []
|
||||
return { ...e, channels: chs.includes(ch) ? chs.filter(c => c !== ch) : [...chs, ch] }
|
||||
})
|
||||
}
|
||||
|
||||
const S = {
|
||||
card: { background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 16, marginBottom: 12 },
|
||||
tag: (active: boolean) => ({ padding: '4px 10px', borderRadius: 12, fontSize: 12, fontWeight: 600, cursor: 'pointer', border: 'none',
|
||||
background: active ? '#003366' : '#f1f5f9', color: active ? '#fff' : '#64748b' }),
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 28px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>🔔 스마트 알림 규칙</h2>
|
||||
<button onClick={newRule}
|
||||
style={{ padding: '9px 18px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||
+ 규칙 추가
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: '#64748b', marginBottom: 20, fontSize: 13 }}>조건 기반 스마트 알림 규칙을 설정합니다. AND 조건으로 모두 충족 시 알림이 발송됩니다.</p>
|
||||
|
||||
{/* 규칙 목록 */}
|
||||
{rules.length === 0 && !editing && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#94a3b8', border: '2px dashed #e2e8f0', borderRadius: 10 }}>
|
||||
등록된 알림 규칙이 없습니다.<br />
|
||||
<button onClick={newRule} style={{ marginTop: 12, padding: '8px 16px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>첫 규칙 만들기</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rules.map(r => (
|
||||
<div key={r.id} style={{ ...S.card, opacity: r.enabled ? 1 : 0.6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700 }}>{r.name}</span>
|
||||
<span style={{ padding: '2px 8px', borderRadius: 8, fontSize: 11, fontWeight: 600,
|
||||
background: r.enabled ? '#dcfce7' : '#f1f5f9', color: r.enabled ? '#166534' : '#64748b' }}>
|
||||
{r.enabled ? '활성' : '비활성'}
|
||||
</span>
|
||||
{r.digest_mode && <span style={{ padding: '2px 8px', borderRadius: 8, fontSize: 11, background: '#fef3c7', color: '#92400e' }}>다이제스트</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>
|
||||
조건: {(r.conditions||[]).map(c => `${c.field} ${c.op} "${c.value}"`).join(' AND ') || '없음'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(r.channels||[]).map(ch => (
|
||||
<span key={ch} style={{ padding: '2px 8px', background: '#eff6ff', color: '#1d4ed8', borderRadius: 8, fontSize: 11 }}>{ch}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
{testResult[r.id] && <span style={{ fontSize: 12, alignSelf: 'center' }}>{testResult[r.id]}</span>}
|
||||
<button onClick={() => test(r.id)} style={{ padding: '5px 10px', border: '1px solid #e2e8f0', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>테스트</button>
|
||||
<button onClick={() => setEditing(r)} style={{ padding: '5px 10px', border: '1px solid #e2e8f0', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>편집</button>
|
||||
<button onClick={() => toggle(r.id, r.enabled)} style={{ padding: '5px 10px', border: '1px solid #e2e8f0', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>
|
||||
{r.enabled ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button onClick={() => del(r.id)} style={{ padding: '5px 10px', border: '1px solid #fca5a5', color: '#dc2626', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 편집 폼 */}
|
||||
{editing && (
|
||||
<div style={{ ...S.card, border: '2px solid #003366', marginTop: 8 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 16px', color: '#003366' }}>
|
||||
{editing.id ? '규칙 편집' : '새 규칙 만들기'}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>규칙 이름</label>
|
||||
<input value={editing.name||''} onChange={e => setEditing(v => ({ ...v!, name: e.target.value }))}
|
||||
placeholder="예: CRITICAL SR 즉시 알림"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* 조건 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600 }}>조건 (AND)</label>
|
||||
<button onClick={addCond} style={{ fontSize: 11, color: '#003366', border: 'none', background: 'none', cursor: 'pointer' }}>+ 조건 추가</button>
|
||||
</div>
|
||||
{(editing.conditions||[]).map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<select value={c.field} onChange={e => updateCond(i, 'field', e.target.value)}
|
||||
style={{ flex: 2, padding: '6px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 12 }}>
|
||||
{FIELDS.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
<select value={c.op} onChange={e => updateCond(i, 'op', e.target.value)}
|
||||
style={{ flex: 1, padding: '6px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 12 }}>
|
||||
{OPS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
<input value={c.value} onChange={e => updateCond(i, 'value', e.target.value)} placeholder="값"
|
||||
style={{ flex: 2, padding: '6px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 12 }} />
|
||||
<button onClick={() => removeCond(i)} style={{ padding: '4px 8px', border: 'none', background: '#fef2f2', color: '#dc2626', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 채널 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>알림 채널</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{CHANNELS.map(ch => (
|
||||
<button key={ch} onClick={() => toggleChannel(ch)}
|
||||
style={S.tag((editing.channels||[]).includes(ch))}>
|
||||
{ch === 'messenger' ? '📱 메신저' : ch === 'email' ? '📧 이메일' : '💬 SMS'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 무음 시간 + 다이제스트 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>무음 시작</label>
|
||||
<input type="time" value={editing.silence_start||''} onChange={e => setEditing(v => ({ ...v!, silence_start: e.target.value }))}
|
||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>무음 종료</label>
|
||||
<input type="time" value={editing.silence_end||''} onChange={e => setEditing(v => ({ ...v!, silence_end: e.target.value }))}
|
||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>우선순위 필터</label>
|
||||
<select value={editing.priority_filter||''} onChange={e => setEditing(v => ({ ...v!, priority_filter: e.target.value }))}
|
||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }}>
|
||||
{PRIORITIES.map(p => <option key={p} value={p}>{p || '전체'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={editing.digest_mode||false}
|
||||
onChange={e => setEditing(v => ({ ...v!, digest_mode: e.target.checked }))} />
|
||||
<span style={{ fontSize: 13 }}>다이제스트 모드 (묶어서 발송)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={save}
|
||||
style={{ padding: '9px 20px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: 'pointer' }}>
|
||||
저장
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)}
|
||||
style={{ padding: '9px 20px', border: '1px solid #e2e8f0', borderRadius: 8, fontSize: 14, cursor: 'pointer', background: 'none' }}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -91,6 +91,13 @@ export default function TabLayout() {
|
||||
tabBarIcon: ({ focused }) => <TabIcon icon="🧠" label="AI" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scan"
|
||||
options={{
|
||||
title: 'QR 스캔',
|
||||
tabBarIcon: ({ focused }) => <TabIcon icon="📷" label="QR" focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
|
||||
211
workspace/guardia-messenger/app/(tabs)/scan.tsx
Normal file
211
workspace/guardia-messenger/app/(tabs)/scan.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
View, Text, StyleSheet, TouchableOpacity, Alert,
|
||||
ScrollView, Platform, Linking, ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { COLORS, API_BASE } from '../../constants/Config'
|
||||
import { getToken } from '../../utils/auth'
|
||||
|
||||
// expo-barcode-scanner는 EAS 빌드 환경에서만 실제 작동
|
||||
// 개발/시뮬레이터에서는 수동 입력으로 대체
|
||||
|
||||
interface AssetInfo {
|
||||
server_id: number; server_name: string; ip_display: string
|
||||
os_name: string; location: string; status: string; last_checked: string
|
||||
qr_token: string
|
||||
}
|
||||
|
||||
export default function ScanTab() {
|
||||
const [mode, setMode] = useState<'qr'|'manual'>('qr')
|
||||
const [manualToken, setManualToken] = useState('')
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [asset, setAsset] = useState<AssetInfo | null>(null)
|
||||
const [checkedIn, setCheckedIn] = useState(false)
|
||||
|
||||
async function lookupToken(token: string) {
|
||||
if (!token.trim()) return
|
||||
setLoading(true); setAsset(null); setCheckedIn(false)
|
||||
try {
|
||||
const jwt = await getToken()
|
||||
const res = await fetch(`${API_BASE}/api/asset-qr/scan/${token.trim()}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({}))
|
||||
Alert.alert('조회 실패', e.detail || '자산을 찾을 수 없습니다')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setAsset(data)
|
||||
} catch (e: any) {
|
||||
Alert.alert('오류', e.message || '서버 연결 실패')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doCheckin() {
|
||||
if (!asset) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const jwt = await getToken()
|
||||
await fetch(`${API_BASE}/api/asset-qr/checkin/${asset.qr_token}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: '모바일 실사' }),
|
||||
})
|
||||
setCheckedIn(true)
|
||||
Alert.alert('✅ 실사 완료', `${asset.server_name} 실사 완료 처리했습니다.`)
|
||||
} catch {
|
||||
Alert.alert('오류', '체크인 실패')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
if (s === 'ACTIVE') return '#166534'
|
||||
if (s === 'INACTIVE') return '#9a3412'
|
||||
return '#92400e'
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={S.root} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||
<View style={S.header}>
|
||||
<Text style={S.title}>📱 자산 QR 스캔</Text>
|
||||
<Text style={S.subtitle}>서버 라벨의 QR코드를 스캔하여 CMDB 정보를 조회합니다</Text>
|
||||
</View>
|
||||
|
||||
{/* 모드 선택 */}
|
||||
<View style={S.tabs}>
|
||||
{[{id:'qr',label:'📷 QR 스캔'},{id:'manual',label:'⌨️ 토큰 입력'}].map(t => (
|
||||
<TouchableOpacity key={t.id} onPress={() => setMode(t.id as any)}
|
||||
style={[S.tab, mode === t.id && S.tabActive]}>
|
||||
<Text style={[S.tabText, mode === t.id && S.tabTextActive]}>{t.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{mode === 'qr' ? (
|
||||
<View style={S.card}>
|
||||
<View style={S.qrBox}>
|
||||
<Text style={{ fontSize: 56 }}>📷</Text>
|
||||
<Text style={{ color: '#64748b', textAlign: 'center', marginTop: 8, fontSize: 13 }}>
|
||||
카메라 QR 스캔{Platform.OS === 'android' ? ' (Android 지원)' : ' (iOS 지원)'}
|
||||
</Text>
|
||||
<Text style={{ color: '#94a3b8', fontSize: 11, textAlign: 'center', marginTop: 4 }}>
|
||||
expo-barcode-scanner 모듈 필요
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={S.btn} onPress={() => {
|
||||
Alert.alert(
|
||||
'QR 스캔',
|
||||
'QR 스캔은 EAS 빌드 앱에서 사용 가능합니다.\n토큰 직접 입력 탭을 이용하세요.',
|
||||
[{ text: '토큰 입력으로 이동', onPress: () => setMode('manual') }, { text: '확인' }]
|
||||
)
|
||||
}}>
|
||||
<Text style={S.btnText}>📷 QR 코드 스캔 시작</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={S.card}>
|
||||
<Text style={S.fieldLabel}>QR 토큰 (UUID)</Text>
|
||||
<View style={S.row}>
|
||||
<View style={[S.input, { flex: 1 }]}>
|
||||
<Text style={{ color: manualToken ? '#1e293b' : '#94a3b8', fontSize: 13 }}
|
||||
onPress={() => {/* focus */}}>
|
||||
{manualToken || 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={[S.btn, { marginTop: 0, marginLeft: 8 }]}
|
||||
onPress={() => lookupToken(manualToken)}>
|
||||
<Text style={S.btnText}>조회</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
|
||||
라벨의 UUID를 입력하거나 QR 이미지 하단의 텍스트를 입력하세요
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 로딩 */}
|
||||
{loading && (
|
||||
<View style={[S.card, { alignItems: 'center', padding: 24 }]}>
|
||||
<ActivityIndicator color={COLORS.accent} size="large" />
|
||||
<Text style={{ marginTop: 8, color: '#64748b' }}>조회 중...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 자산 정보 */}
|
||||
{asset && !loading && (
|
||||
<View style={S.card}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#003366' }}>{asset.server_name}</Text>
|
||||
<Text style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{asset.ip_display}</Text>
|
||||
</View>
|
||||
<View style={{ backgroundColor: statusColor(asset.status) + '22', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12 }}>
|
||||
<Text style={{ fontSize: 11, fontWeight: '700', color: statusColor(asset.status) }}>{asset.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{[
|
||||
{ label: 'OS', value: asset.os_name },
|
||||
{ label: '위치', value: asset.location || '미지정' },
|
||||
{ label: '마지막 점검', value: asset.last_checked ? new Date(asset.last_checked).toLocaleDateString('ko-KR') : '기록 없음' },
|
||||
].map(item => (
|
||||
<View key={item.label} style={S.infoRow}>
|
||||
<Text style={S.infoLabel}>{item.label}</Text>
|
||||
<Text style={S.infoValue}>{item.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[S.btn, checkedIn && { backgroundColor: '#166534' }]}
|
||||
onPress={checkedIn ? undefined : doCheckin}
|
||||
disabled={checkedIn || loading}>
|
||||
<Text style={S.btnText}>{checkedIn ? '✅ 실사 완료됨' : '📋 실사 체크인'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 앱 QR 다운로드 안내 */}
|
||||
<View style={[S.card, { backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }]}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '700', color: '#1d4ed8', marginBottom: 4 }}>💡 앱 배포 QR</Text>
|
||||
<Text style={{ fontSize: 12, color: '#3b82f6' }}>
|
||||
GUARDiA Manager에서 최신 APK를 배포하면 QR코드가 생성됩니다.{'\n'}
|
||||
다른 사용자에게 QR을 공유하여 앱스토어 없이 설치할 수 있습니다.
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://zioinfo.co.kr:8443/api/app/landing')}
|
||||
style={{ marginTop: 8 }}>
|
||||
<Text style={{ fontSize: 12, color: '#1d4ed8', textDecorationLine: 'underline' }}>앱 다운로드 페이지 열기 →</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const S = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#f8fafc' },
|
||||
header: { padding: 20, paddingBottom: 12, backgroundColor: '#003366' },
|
||||
title: { fontSize: 20, fontWeight: '800', color: '#fff' },
|
||||
subtitle: { fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 4 },
|
||||
tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e2e8f0' },
|
||||
tab: { flex: 1, padding: 12, alignItems: 'center', borderBottomWidth: 2, borderColor: 'transparent' },
|
||||
tabActive: { borderColor: '#003366' },
|
||||
tabText: { fontSize: 13, color: '#64748b' },
|
||||
tabTextActive: { color: '#003366', fontWeight: '700' },
|
||||
card: { margin: 12, marginBottom: 0, backgroundColor: '#fff', borderRadius: 12, padding: 16,
|
||||
borderWidth: 1, borderColor: '#e2e8f0',
|
||||
shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 4, elevation: 2 },
|
||||
qrBox: { alignItems: 'center', paddingVertical: 24, backgroundColor: '#f8fafc', borderRadius: 8, marginBottom: 12 },
|
||||
btn: { backgroundColor: '#003366', borderRadius: 8, padding: 12, alignItems: 'center', marginTop: 8 },
|
||||
btnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
||||
row: { flexDirection: 'row', alignItems: 'center' },
|
||||
input: { borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 8, padding: 10, backgroundColor: '#f8fafc' },
|
||||
fieldLabel: { fontSize: 12, fontWeight: '600', color: '#374151', marginBottom: 6 },
|
||||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderColor: '#f1f5f9' },
|
||||
infoLabel: { fontSize: 12, color: '#64748b' },
|
||||
infoValue: { fontSize: 13, fontWeight: '600', color: '#1e293b' },
|
||||
})
|
||||
156
workspace/zioinfo-mail/backend/contacts.py
Normal file
156
workspace/zioinfo-mail/backend/contacts.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""
|
||||
웹메일 주소록 — 연락처 CRUD + 자동 저장
|
||||
|
||||
엔드포인트:
|
||||
GET /api/mail/contacts — 주소록 목록
|
||||
POST /api/mail/contacts — 연락처 추가
|
||||
PUT /api/mail/contacts/{id} — 수정
|
||||
DELETE /api/mail/contacts/{id} — 삭제
|
||||
GET /api/mail/contacts/search — 검색
|
||||
POST /api/mail/contacts/auto-save — 발신자 자동 저장
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import verify_token
|
||||
from database import get_db_session as get_db
|
||||
from models import MailContact
|
||||
|
||||
router = APIRouter(prefix="/api/mail/contacts", tags=["주소록"])
|
||||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
group: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class AutoSaveRequest(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_contacts(
|
||||
q: str = Query(..., min_length=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(MailContact).where(
|
||||
MailContact.username == username,
|
||||
or_(
|
||||
MailContact.name.ilike(f"%{q}%"),
|
||||
MailContact.email.ilike(f"%{q}%"),
|
||||
)
|
||||
).order_by(desc(MailContact.use_count)).limit(10)
|
||||
)
|
||||
return [
|
||||
{"id": c.id, "name": c.name, "email": c.email, "group": c.group}
|
||||
for c in rows.scalars().all()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_contacts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(MailContact).where(MailContact.username == username)
|
||||
.order_by(MailContact.name)
|
||||
)
|
||||
return [
|
||||
{"id": c.id, "name": c.name, "email": c.email,
|
||||
"group": c.group, "phone": c.phone, "use_count": c.use_count}
|
||||
for c in rows.scalars().all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_contact(
|
||||
req: ContactCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
# 중복 이메일 체크
|
||||
existing = await db.execute(
|
||||
select(MailContact).where(MailContact.username == username, MailContact.email == req.email)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "이미 등록된 이메일")
|
||||
contact = MailContact(
|
||||
username=username, name=req.name, email=req.email,
|
||||
group=req.group, phone=req.phone, note=req.note,
|
||||
use_count=0, auto_saved=False, created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(contact)
|
||||
await db.commit()
|
||||
await db.refresh(contact)
|
||||
return {"ok": True, "id": contact.id}
|
||||
|
||||
|
||||
@router.put("/{contact_id}")
|
||||
async def update_contact(
|
||||
contact_id: int,
|
||||
req: ContactCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
row = await db.execute(
|
||||
select(MailContact).where(MailContact.id == contact_id, MailContact.username == username)
|
||||
)
|
||||
c = row.scalar_one_or_none()
|
||||
if not c: raise HTTPException(404)
|
||||
c.name = req.name; c.email = req.email; c.group = req.group
|
||||
c.phone = req.phone; c.note = req.note
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{contact_id}")
|
||||
async def delete_contact(
|
||||
contact_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
row = await db.execute(
|
||||
select(MailContact).where(MailContact.id == contact_id, MailContact.username == username)
|
||||
)
|
||||
c = row.scalar_one_or_none()
|
||||
if not c: raise HTTPException(404)
|
||||
await db.delete(c); await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/auto-save")
|
||||
async def auto_save_contact(
|
||||
req: AutoSaveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
"""메일 발신자 자동 저장 (사용 횟수 증가)."""
|
||||
row = await db.execute(
|
||||
select(MailContact).where(MailContact.username == username, MailContact.email == req.email)
|
||||
)
|
||||
c = row.scalar_one_or_none()
|
||||
if c:
|
||||
c.use_count = (c.use_count or 0) + 1
|
||||
else:
|
||||
c = MailContact(
|
||||
username=username, name=req.name, email=req.email,
|
||||
use_count=1, auto_saved=True, created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(c)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
@ -154,6 +154,16 @@ async def save_draft(req: SendRequest, user=Depends(current_user)):
|
||||
return {"ok": True, "message": "임시저장 완료"}
|
||||
|
||||
|
||||
# ── 주소록 + 서명 라우터 등록 ──────────────────────────────────
|
||||
try:
|
||||
from .contacts import router as contacts_router
|
||||
from .signature import router as signature_router
|
||||
app.include_router(contacts_router)
|
||||
app.include_router(signature_router)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="127.0.0.1", port=8026, reload=True)
|
||||
|
||||
71
workspace/zioinfo-mail/backend/signature.py
Normal file
71
workspace/zioinfo-mail/backend/signature.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""
|
||||
웹메일 서명 편집기
|
||||
|
||||
엔드포인트:
|
||||
GET /api/mail/signature — 현재 서명 조회
|
||||
PUT /api/mail/signature — 서명 저장 (HTML)
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import verify_token
|
||||
from database import get_db_session as get_db
|
||||
from models import MailSignature
|
||||
|
||||
router = APIRouter(prefix="/api/mail/signature", tags=["메일 서명"])
|
||||
|
||||
ALLOWED_TAGS = {'b', 'i', 'u', 'br', 'p', 'span', 'div', 'a', 'font', 'strong', 'em', 'h1', 'h2', 'h3', 'img'}
|
||||
|
||||
|
||||
def _sanitize_html(html: str) -> str:
|
||||
"""기본 HTML sanitize (script 태그 제거)."""
|
||||
html = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||
html = re.sub(r'on\w+="[^"]*"', '', html, flags=re.IGNORECASE)
|
||||
html = re.sub(r"on\w+='[^']*'", '', html, flags=re.IGNORECASE)
|
||||
return html
|
||||
|
||||
|
||||
class SignatureUpdate(BaseModel):
|
||||
html_content: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_signature(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
row = await db.execute(select(MailSignature).where(MailSignature.username == username))
|
||||
sig = row.scalar_one_or_none()
|
||||
return {
|
||||
"html_content": sig.html_content if sig else "",
|
||||
"is_active": sig.is_active if sig else False,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/")
|
||||
async def update_signature(
|
||||
req: SignatureUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
username: str = Depends(verify_token),
|
||||
):
|
||||
clean_html = _sanitize_html(req.html_content)
|
||||
row = await db.execute(select(MailSignature).where(MailSignature.username == username))
|
||||
sig = row.scalar_one_or_none()
|
||||
if sig:
|
||||
sig.html_content = clean_html
|
||||
sig.is_active = req.is_active
|
||||
sig.updated_at = datetime.utcnow()
|
||||
else:
|
||||
sig = MailSignature(
|
||||
username=username, html_content=clean_html,
|
||||
is_active=req.is_active, updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(sig)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
133
workspace/zioinfo-mail/frontend/src/components/Contacts.tsx
Normal file
133
workspace/zioinfo-mail/frontend/src/components/Contacts.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useEffect, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
interface Contact { id: number; name: string; email: string; phone?: string; company?: string; use_count: number }
|
||||
|
||||
export interface ContactsHandle {
|
||||
search: (q: string) => Promise<Contact[]>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelect?: (email: string, name: string) => void
|
||||
}
|
||||
|
||||
const api = axios.create({ baseURL: '' })
|
||||
|
||||
const Contacts = forwardRef<ContactsHandle, Props>(({ onSelect }, ref) => {
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [q, setQ] = useState('')
|
||||
const [modal, setModal] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', email: '', phone: '', company: '' })
|
||||
const [editing, setEditing] = useState<number | null>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
search: async (query: string) => {
|
||||
const r = await api.get(`/api/contacts?q=${encodeURIComponent(query)}&limit=8`)
|
||||
return r.data
|
||||
}
|
||||
}))
|
||||
|
||||
useEffect(() => { load() }, [q])
|
||||
|
||||
async function load() {
|
||||
const r = await api.get(`/api/contacts?q=${encodeURIComponent(q)}&limit=50`).catch(() => ({ data: [] }))
|
||||
setContacts(r.data)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name || !form.email) return alert('이름과 이메일은 필수입니다')
|
||||
if (editing !== null) {
|
||||
await api.put(`/api/contacts/${editing}`, form)
|
||||
} else {
|
||||
await api.post('/api/contacts', form)
|
||||
}
|
||||
setModal(false); setForm({ name: '', email: '', phone: '', company: '' }); setEditing(null); load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('삭제하시겠습니까?')) return
|
||||
await api.delete(`/api/contacts/${id}`); load()
|
||||
}
|
||||
|
||||
function startEdit(c: Contact) {
|
||||
setForm({ name: c.name, email: c.email, phone: c.phone||'', company: c.company||'' })
|
||||
setEditing(c.id); setModal(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #e2e8f0', display: 'flex', gap: 8 }}>
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder="이름·이메일 검색..."
|
||||
style={{ flex: 1, padding: '7px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13 }} />
|
||||
<button onClick={() => { setModal(true); setEditing(null); setForm({ name:'',email:'',phone:'',company:'' }) }}
|
||||
style={{ padding: '7px 14px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' }}>
|
||||
+ 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{contacts.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>주소록이 비어 있습니다</div>
|
||||
) : contacts.map(c => (
|
||||
<div key={c.id} style={{ padding: '10px 16px', borderBottom: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: '50%', background: '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 700, color: '#3730a3', flexShrink: 0 }}>
|
||||
{c.name[0]}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{c.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{c.email}</div>
|
||||
{c.company && <div style={{ fontSize: 11, color: '#94a3b8' }}>{c.company}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{onSelect && (
|
||||
<button onClick={() => onSelect(c.email, c.name)}
|
||||
style={{ padding: '4px 8px', background: '#eff6ff', color: '#1d4ed8', border: 'none', borderRadius: 4, fontSize: 11, cursor: 'pointer' }}>
|
||||
선택
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => startEdit(c)}
|
||||
style={{ padding: '4px 8px', border: '1px solid #e2e8f0', borderRadius: 4, background: 'none', fontSize: 11, cursor: 'pointer' }}>
|
||||
편집
|
||||
</button>
|
||||
<button onClick={() => del(c.id)}
|
||||
style={{ padding: '4px 8px', border: '1px solid #fca5a5', color: '#dc2626', borderRadius: 4, background: 'none', fontSize: 11, cursor: 'pointer' }}>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 추가/편집 모달 */}
|
||||
{modal && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ background: '#fff', borderRadius: 12, padding: 24, width: 360, boxShadow: '0 20px 40px rgba(0,0,0,0.2)' }}>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700 }}>{editing !== null ? '연락처 편집' : '새 연락처'}</h3>
|
||||
{[
|
||||
{ key: 'name', label: '이름 *', placeholder: '홍길동' },
|
||||
{ key: 'email', label: '이메일 *', placeholder: 'hong@example.com' },
|
||||
{ key: 'phone', label: '전화번호', placeholder: '010-0000-0000' },
|
||||
{ key: 'company', label: '회사/기관', placeholder: '(주)지오정보기술' },
|
||||
].map(f => (
|
||||
<div key={f.key} style={{ marginBottom: 10 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 3 }}>{f.label}</label>
|
||||
<input value={(form as any)[f.key]} onChange={e => setForm(v => ({ ...v, [f.key]: e.target.value }))}
|
||||
placeholder={f.placeholder} type={f.key === 'email' ? 'email' : 'text'}
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
||||
<button onClick={save} style={{ flex: 1, padding: '9px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>저장</button>
|
||||
<button onClick={() => setModal(false)} style={{ flex: 1, padding: '9px', border: '1px solid #e2e8f0', borderRadius: 8, fontSize: 13, cursor: 'pointer', background: 'none' }}>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Contacts.displayName = 'Contacts'
|
||||
export default Contacts
|
||||
@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
interface Signature { id: number; name: string; html_content: string; is_default: boolean }
|
||||
|
||||
interface Props {
|
||||
onInsert?: (html: string) => void
|
||||
}
|
||||
|
||||
const api = axios.create({ baseURL: '' })
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
name: '기본 서명',
|
||||
html: `<div style="font-family:sans-serif;font-size:13px;color:#374151;border-top:2px solid #003366;padding-top:10px;margin-top:10px">
|
||||
<strong style="font-size:14px">홍길동</strong> | 지오정보기술(주)<br>
|
||||
📧 ythong@zioinfo.co.kr | 📞 02-0000-0000<br>
|
||||
🌐 www.zioinfo.co.kr
|
||||
</div>`
|
||||
},
|
||||
{
|
||||
name: '심플 서명',
|
||||
html: `<p style="font-size:12px;color:#6b7280;margin-top:12px">-- <br>홍길동 · 지오정보기술 · ythong@zioinfo.co.kr</p>`
|
||||
},
|
||||
]
|
||||
|
||||
export default function SignatureEditor({ onInsert }: Props) {
|
||||
const [sigs, setSigs] = useState<Signature[]>([])
|
||||
const [editing, setEditing] = useState<Partial<Signature> | null>(null)
|
||||
const [preview, setPreview] = useState(false)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const r = await api.get('/api/signature').catch(() => ({ data: [] }))
|
||||
setSigs(r.data)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
if (!editing.name || !editing.html_content) return alert('이름과 내용을 입력하세요')
|
||||
if (editing.id) {
|
||||
await api.put(`/api/signature/${editing.id}`, editing)
|
||||
} else {
|
||||
await api.post('/api/signature', editing)
|
||||
}
|
||||
setEditing(null); load()
|
||||
}
|
||||
|
||||
async function setDefault(id: number) {
|
||||
await api.patch(`/api/signature/${id}/default`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('삭제하시겠습니까?')) return
|
||||
await api.delete(`/api/signature/${id}`); load()
|
||||
}
|
||||
|
||||
function loadPreset(p: typeof PRESETS[0]) {
|
||||
setEditing(v => ({ ...(v || {}), name: p.name, html_content: p.html }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>서명 관리</span>
|
||||
<button onClick={() => setEditing({ name: '', html_content: '', is_default: false })}
|
||||
style={{ padding: '5px 12px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' }}>
|
||||
+ 새 서명
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
|
||||
{sigs.length === 0 && !editing && (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: '#94a3b8', fontSize: 13 }}>
|
||||
등록된 서명이 없습니다<br />
|
||||
<button onClick={() => setEditing({ name: '', html_content: PRESETS[0].html, is_default: true })}
|
||||
style={{ marginTop: 8, padding: '6px 12px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' }}>
|
||||
기본 서명 만들기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sigs.map(s => (
|
||||
<div key={s.id} style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: 12, marginBottom: 8,
|
||||
borderLeft: s.is_default ? '3px solid #003366' : undefined }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{s.name}</span>
|
||||
{s.is_default && <span style={{ fontSize: 10, background: '#003366', color: '#fff', padding: '1px 6px', borderRadius: 6 }}>기본</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{onInsert && (
|
||||
<button onClick={() => onInsert(s.html_content)}
|
||||
style={{ padding: '3px 8px', background: '#eff6ff', color: '#1d4ed8', border: 'none', borderRadius: 4, fontSize: 11, cursor: 'pointer' }}>
|
||||
삽입
|
||||
</button>
|
||||
)}
|
||||
{!s.is_default && (
|
||||
<button onClick={() => setDefault(s.id)}
|
||||
style={{ padding: '3px 8px', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 11, cursor: 'pointer', background: 'none' }}>
|
||||
기본 설정
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setEditing(s)}
|
||||
style={{ padding: '3px 8px', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 11, cursor: 'pointer', background: 'none' }}>
|
||||
편집
|
||||
</button>
|
||||
<button onClick={() => del(s.id)}
|
||||
style={{ padding: '3px 8px', border: '1px solid #fca5a5', color: '#dc2626', borderRadius: 4, fontSize: 11, cursor: 'pointer', background: 'none' }}>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b', overflow: 'hidden', maxHeight: 40 }}
|
||||
dangerouslySetInnerHTML={{ __html: s.html_content }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 편집 패널 */}
|
||||
{editing && (
|
||||
<div style={{ borderTop: '1px solid #e2e8f0', padding: 16, background: '#f8fafc' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700 }}>{editing.id ? '서명 편집' : '새 서명'}</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontSize: 11, color: '#64748b', alignSelf: 'center' }}>프리셋:</span>
|
||||
{PRESETS.map(p => (
|
||||
<button key={p.name} onClick={() => loadPreset(p)}
|
||||
style={{ padding: '3px 8px', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 11, cursor: 'pointer', background: '#fff' }}>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input value={editing.name||''} onChange={e => setEditing(v => ({ ...v!, name: e.target.value }))}
|
||||
placeholder="서명 이름" style={{ width: '100%', padding: '7px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, marginBottom: 8, boxSizing: 'border-box' }} />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>HTML 내용</span>
|
||||
<button onClick={() => setPreview(!preview)}
|
||||
style={{ fontSize: 11, border: 'none', background: 'none', cursor: 'pointer', color: '#003366' }}>
|
||||
{preview ? '편집' : '미리보기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 6, padding: 12, minHeight: 80, background: '#fff', fontSize: 13 }}
|
||||
dangerouslySetInnerHTML={{ __html: editing.html_content || '' }} />
|
||||
) : (
|
||||
<textarea value={editing.html_content||''} onChange={e => setEditing(v => ({ ...v!, html_content: e.target.value }))}
|
||||
rows={5} placeholder="<div>서명 HTML...</div>"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 12, fontFamily: 'monospace', resize: 'vertical', boxSizing: 'border-box' }} />
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
||||
<input type="checkbox" checked={editing.is_default||false} onChange={e => setEditing(v => ({ ...v!, is_default: e.target.checked }))} />
|
||||
기본 서명으로 설정
|
||||
</label>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={save} style={{ padding: '7px 16px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>저장</button>
|
||||
<button onClick={() => setEditing(null)} style={{ padding: '7px 16px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, cursor: 'pointer', background: 'none' }}>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user