Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
env/

# 로컬 테스트 아티팩트
test.db
test_*.db
*.sqlite3
22 changes: 11 additions & 11 deletions api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ async def signup_project(body: ProjectSignupRequest, db: Session = Depends(get_d

@router.post("/verify")
@limiter.limit("10/minute")
async def verify_email(
def verify_email(
request: Request, body: VerifyCodeRequest, db: Session = Depends(get_db)
):
"""
Expand Down Expand Up @@ -421,7 +421,7 @@ async def verify_email(


@router.get("/pending-approvals")
async def get_pending_approvals(
def get_pending_approvals(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
Expand Down Expand Up @@ -452,7 +452,7 @@ async def get_pending_approvals(


@router.post("/approve/{user_id}")
async def approve_project_owner(
def approve_project_owner(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
Expand All @@ -475,7 +475,7 @@ async def approve_project_owner(


@router.post("/reject/{user_id}")
async def reject_project_owner(
def reject_project_owner(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
Expand Down Expand Up @@ -568,7 +568,7 @@ async def resend_code(

@router.post("/login", response_model=Token)
@limiter.limit("10/minute")
async def login(
def login(
request: Request,
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
Expand Down Expand Up @@ -624,7 +624,7 @@ async def login(


@router.post("/refresh")
async def refresh_token(
def refresh_token(
request: Request,
body: RefreshRequest = None,
db: Session = Depends(get_db),
Expand Down Expand Up @@ -668,7 +668,7 @@ async def refresh_token(


@router.get("/me")
async def read_users_me(current_user: User = Depends(get_current_user)):
def read_users_me(current_user: User = Depends(get_current_user)):
"""현재 로그인된 사용자의 정보를 조회합니다."""
return {
"id": current_user.id,
Expand Down Expand Up @@ -764,7 +764,7 @@ async def request_password_reset(

@router.post("/password-reset/confirm")
@limiter.limit("5/minute")
async def confirm_password_reset(
def confirm_password_reset(
request: Request, body: PasswordResetConfirm, db: Session = Depends(get_db)
):
"""비밀번호 재설정 2단계: 인증 코드 확인 + 해당 role 계정의 비밀번호만 변경."""
Expand Down Expand Up @@ -839,7 +839,7 @@ async def confirm_password_reset(


@router.put("/change-password")
async def change_password(
def change_password(
body: ChangePasswordRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
Expand Down Expand Up @@ -926,7 +926,7 @@ async def upload_avatar(


@router.delete("/avatar")
async def delete_avatar(
def delete_avatar(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
Expand All @@ -945,7 +945,7 @@ async def delete_avatar(


@router.post("/logout")
async def logout():
def logout():
"""로그아웃 — httpOnly 쿠키 삭제."""
response = JSONResponse(content={"message": "로그아웃 되었습니다."})
_clear_auth_cookies(response)
Expand Down
16 changes: 9 additions & 7 deletions api/routes/faq.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from pydantic import BaseModel, field_validator
from typing import Optional

Expand Down Expand Up @@ -55,18 +55,20 @@ class Config:
# ── Endpoints ────────────────────────────────────────────────

@router.get("", response_model=list[FaqQuestionResponse])
async def get_faq_questions(
def get_faq_questions(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""FAQ 질문 목록 조회 — 본인 질문만 (관리자는 전체)"""
from models.user import UserRole

# user 를 함께 로드(joinedload)해 q.user.email 접근 시 발생하던 N+1 제거
base_query = db.query(FaqQuestion).options(joinedload(FaqQuestion.user))
if current_user.role == UserRole.ADMIN:
questions = db.query(FaqQuestion).order_by(FaqQuestion.created_at.desc()).all()
questions = base_query.order_by(FaqQuestion.created_at.desc()).all()
else:
questions = (
db.query(FaqQuestion)
base_query
.filter(FaqQuestion.user_id == current_user.id)
.order_by(FaqQuestion.created_at.desc())
.all()
Expand All @@ -86,7 +88,7 @@ async def get_faq_questions(


@router.post("", response_model=FaqQuestionResponse)
async def create_faq_question(
def create_faq_question(
body: FaqQuestionCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
Expand All @@ -110,7 +112,7 @@ async def create_faq_question(


@router.put("/{question_id}/answer", response_model=FaqQuestionResponse)
async def answer_faq_question(
def answer_faq_question(
question_id: int,
body: FaqAnswerCreate,
admin: User = Depends(get_current_active_admin),
Expand All @@ -137,7 +139,7 @@ async def answer_faq_question(


@router.delete("/{question_id}")
async def delete_faq_question(
def delete_faq_question(
question_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
Expand Down
14 changes: 7 additions & 7 deletions api/routes/firewall.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@


@router.get("/{vmid}/rules")
async def get_firewall_rules(
def get_firewall_rules(
vmid: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
Expand All @@ -34,7 +34,7 @@ async def get_firewall_rules(


@router.post("/{vmid}/rules")
async def add_firewall_rule(
def add_firewall_rule(
vmid: int,
rule: FirewallRule,
db: Session = Depends(get_db),
Expand All @@ -58,7 +58,7 @@ async def add_firewall_rule(


@router.delete("/{vmid}/rules/{pos}")
async def delete_firewall_rule(
def delete_firewall_rule(
vmid: int,
pos: int,
db: Session = Depends(get_db),
Expand All @@ -80,7 +80,7 @@ async def delete_firewall_rule(
# ── 커스텀 포트 할당 (30000~39999) ───────────────────────────────────────────

@router.get("/{node}/{vmid}/ports")
async def get_custom_ports(
def get_custom_ports(
node: str,
vmid: int,
db: Session = Depends(get_db),
Expand Down Expand Up @@ -109,7 +109,7 @@ async def get_custom_ports(


@router.post("/{node}/{vmid}/ports", status_code=201)
async def add_custom_port(
def add_custom_port(
node: str,
vmid: int,
body: VmPortCreate,
Expand Down Expand Up @@ -177,7 +177,7 @@ async def add_custom_port(


@router.post("/{node}/{vmid}/ports/defaults/restore")
async def restore_default_ports(
def restore_default_ports(
node: str,
vmid: int,
db: Session = Depends(get_db),
Expand Down Expand Up @@ -237,7 +237,7 @@ async def restore_default_ports(


@router.delete("/{node}/{vmid}/ports/{port_id}")
async def delete_custom_port(
def delete_custom_port(
node: str,
vmid: int,
port_id: int,
Expand Down
57 changes: 30 additions & 27 deletions api/routes/monitoring.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from services.proxmox_client import get_proxmox_for_server
from services.proxmox_client import get_proxmox_for_server, gather_per_server
from core.database import get_db
from models.server import Server
from models.user import User, UserRole
Expand All @@ -12,8 +12,29 @@
router = APIRouter()


def _fetch_node_stats(server):
"""단일 노드 상태 조회 (스레드풀에서 서버별 병렬 실행)."""
proxmox = get_proxmox_for_server(server)
node_status = proxmox.nodes(server.name).status.get()

cpu_usage = node_status.get("cpu", 0) * 100 # 소수점(0.05)을 백분율(5%)로
memory = node_status.get("memory", {})
total_ram_gb = memory.get("total", 0) / (1024**3)
used_ram_gb = memory.get("used", 0) / (1024**3)
free_ram_gb = total_ram_gb - used_ram_gb

return {
"status": "online",
"cpu_usage_percent": round(cpu_usage, 1),
"ram_total_gb": round(total_ram_gb, 1),
"ram_used_gb": round(used_ram_gb, 1),
"ram_free_gb": round(free_ram_gb, 1),
"uptime_seconds": node_status.get("uptime", 0),
}


@router.get("/nodes")
async def get_system_stats(
def get_system_stats(
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
"""
Expand All @@ -33,33 +54,15 @@ async def get_system_stats(
if not servers:
return {"message": "등록된 활성 서버가 없습니다.", "stats": {}}

all_stats = {}
# 노드별 상태를 동시에 조회 (서버마다 독립 Proxmox 연결 → 스레드 안전)
stats_by_id = gather_per_server(servers, _fetch_node_stats)

all_stats = {}
for server in servers:
try:
proxmox = get_proxmox_for_server(server)

# 각 노드의 상태 조회
node_status = proxmox.nodes(server.name).status.get()

# 데이터 가공
cpu_usage = node_status.get("cpu", 0) * 100 # 소수점(0.05)을 백분율(5%)로

memory = node_status.get("memory", {})
total_ram_gb = memory.get("total", 0) / (1024**3)
used_ram_gb = memory.get("used", 0) / (1024**3)
free_ram_gb = total_ram_gb - used_ram_gb

all_stats[server.name] = {
"status": "online",
"cpu_usage_percent": round(cpu_usage, 1),
"ram_total_gb": round(total_ram_gb, 1),
"ram_used_gb": round(used_ram_gb, 1),
"ram_free_gb": round(free_ram_gb, 1),
"uptime_seconds": node_status.get("uptime", 0),
}
except Exception as e:
logger.error(f"[monitoring] 노드 {server.name} 조회 실패: {e}")
result = stats_by_id.get(server.id)
if result is not None:
all_stats[server.name] = result
else:
all_stats[server.name] = {
"status": "offline",
"error": "노드에 연결할 수 없습니다.",
Expand Down
2 changes: 1 addition & 1 deletion api/routes/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


@router.get("/{node}/{vmid}/ports")
async def get_forwarded_ports(
def get_forwarded_ports(
node: str,
vmid: int,
db: Session = Depends(get_db),
Expand Down
8 changes: 4 additions & 4 deletions api/routes/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


@router.get("")
async def get_notifications(
def get_notifications(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
Expand All @@ -34,7 +34,7 @@ async def get_notifications(


@router.patch("/{notification_id}/read")
async def mark_as_read(
def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
Expand All @@ -52,7 +52,7 @@ async def mark_as_read(


@router.post("/read-all")
async def mark_all_as_read(
def mark_all_as_read(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
Expand All @@ -65,7 +65,7 @@ async def mark_all_as_read(


@router.delete("/{notification_id}")
async def delete_notification(
def delete_notification(
notification_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
Expand Down
4 changes: 2 additions & 2 deletions api/routes/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def _generate_pkce() -> tuple[str, str]:

@router.get("/authorize")
@limiter.limit("10/minute")
async def oauth_authorize(request: Request):
def oauth_authorize(request: Request):
"""DataGSM OAuth 인증 시작 — 사용자를 DataGSM 로그인 페이지로 리다이렉트"""
state = secrets.token_urlsafe(32)
verifier, challenge = _generate_pkce()
Expand Down Expand Up @@ -253,7 +253,7 @@ class TokenExchangeRequest(BaseModel):


@router.post("/exchange")
async def exchange_temp_code(body: TokenExchangeRequest):
def exchange_temp_code(body: TokenExchangeRequest):
"""임시 코드를 JWT 토큰으로 교환 (1회용) — httpOnly 쿠키에 설정"""
entry = _token_store.pop(body.code, None)
if not entry or entry["expires"] < time.time():
Expand Down
Loading