feat(enhance-v4): APK QR 배포 / 배치SSH / 자산QR / 스마트알림 / 웹메일 주소록+서명
Some checks are pending
GUARDiA CI / Python Lint & Import Test (push) Waiting to run
GUARDiA CI / Validate Install Scripts (push) Waiting to run
GUARDiA CI / PR Validation Summary (push) Blocked by required conditions

- 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:
DESKTOP-TKLFCPR\ython 2026-06-02 19:49:00 +09:00
parent f3987c4402
commit 0ebac500f5
19 changed files with 2897 additions and 1 deletions

View File

@ -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")

View File

@ -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())

View 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}

View 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
]}

View 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}

View 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}

View File

@ -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();
}

View File

@ -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> 라이선스 관리

View File

@ -29,6 +29,9 @@ const BiAnalytics = lazy(() => import('./pages/BiAnalytics'))
const BillingManage = lazy(() => import('./pages/BillingManage'))
const IntegrationHub = lazy(() => import('./pages/IntegrationHub'))
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>

View File

@ -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 스타일 색상 상수 */

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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={{

View 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' },
})

View 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}

View File

@ -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)

View 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}

View 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

View File

@ -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 &nbsp;|&nbsp; 📞 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>
)
}