Compare commits

..

39 Commits

Author SHA1 Message Date
755e4a80f9 Merge pull request 'feat/task-type-hierarchy' (#5) from feat/task-type-hierarchy into main
Reviewed-on: #5
2026-03-12 13:05:00 +00:00
Zhi
7b2ac29f2c fix: cascade delete milestones/issues, clean references 2026-03-12 12:55:14 +00:00
Zhi
50f5e360e4 fix: prevent deleting project owner 2026-03-12 12:47:15 +00:00
Zhi
d1f9129922 fix: import check_permission 2026-03-12 12:42:50 +00:00
Zhi
2e14077668 fix: milestones router use Milestone model correctly 2026-03-12 12:33:41 +00:00
Zhi
4a32ed921a fix: remove project_id from ProjectMemberBase schema 2026-03-12 12:27:38 +00:00
Zhi
dac2de62f6 fix: import Milestone model in milestones router 2026-03-12 12:26:38 +00:00
Zhi
9254723f2c fix: import Role model 2026-03-12 12:19:14 +00:00
Zhi
afd769bc12 fix: create_project auto-add member use role_id 2026-03-12 12:16:39 +00:00
Zhi
818dbf12b9 fix: add member.remove permission check 2026-03-12 12:13:14 +00:00
Zhi
c695ef903f fix: rbac ProjectMember reference, add repo field to Project 2026-03-12 12:04:51 +00:00
Zhi
ace0707394 fix: member/milestone endpoints - role_id column, schema fixes 2026-03-12 12:00:37 +00:00
Zhi
74177915df feat: add configurable role/permission system 2026-03-12 11:41:55 +00:00
Zhi
2f659e1430 feat: add project creation permission (admin only), add milestones API with RBAC 2026-03-12 11:04:04 +00:00
Zhi
1eb90cd61c fix: project create schema - owner_name auto-fill from owner_id, sub/related projects as list 2026-03-12 10:52:46 +00:00
zhi
d5bf47f4fc fix: quote enum values and csv export subtype 2026-03-12 09:37:19 +00:00
zhi
e5775bb9c8 feat: add project code generation + remove issues/milestones from nav 2026-03-12 09:25:26 +00:00
zhi
6b3e42195d feat: add task type hierarchy with subtypes (issue/meeting/support/maintenance/research/review/story/test) 2026-03-11 23:55:52 +00:00
9f9aad8ce0 Merge pull request 'feat/public-monitor-and-agent-telemetry' (#4) from feat/public-monitor-and-agent-telemetry into main
Reviewed-on: #4
2026-03-11 22:15:25 +00:00
zhi
5f47a17794 fix: add requests dependency for provider usage adapters 2026-03-11 17:29:17 +00:00
zhi
863c79ef3e docs: remove GroupId query param from minimax example 2026-03-11 17:15:53 +00:00
zhi
c81654739c feat: add kimi/minimax usage adapters and update provider docs 2026-03-11 17:05:18 +00:00
zhi
d5402f3a70 docs: add provider credential format guidance 2026-03-11 13:13:23 +00:00
zhi
ff4baf6113 feat: support provider usage via configurable JSON credentials 2026-03-11 13:13:07 +00:00
zhi
5b8f84d87d feat: add provider usage adapters for openai and placeholders for others 2026-03-11 13:08:58 +00:00
zhi
c0ec70c64f docs: fix plugin plan markdown content and protocol examples 2026-03-11 12:53:11 +00:00
zhi
74e054c51e docs: add openclaw monitor plugin implementation plan draft 2026-03-11 12:52:21 +00:00
zhi
9fb13f4906 feat: add RSA public-key handshake support for monitor server websocket 2026-03-11 12:51:54 +00:00
zhi
464bccafd8 feat: add 10m server challenge flow and websocket telemetry channel 2026-03-11 12:41:32 +00:00
zhi
d299428d35 feat: add public monitor API + admin provider/server management scaffold 2026-03-11 11:59:53 +00:00
zhi
95a4702e1e fix: remove user_id query requirement from notifications count/read-all 2026-03-11 10:49:03 +00:00
zhi
7218aabc59 fix: notifications endpoints use current user auth instead of required user_id query
- /notifications and /notifications/count no longer require user_id param
- return both count and unread fields for compatibility
- /notifications/read-all marks current user notifications
- /notifications/{id}/read enforces ownership (or admin)
2026-03-11 10:46:48 +00:00
zhi
7fe0a72549 Merge pull request 'feat: RBAC + activity logging + Docker health check' (#3) from feat/rbac-and-polish into main 2026-03-11 10:43:42 +00:00
zhi
a21026ac09 fix: enforce missing RBAC checks on issue/comment updates and deletes 2026-03-11 10:43:31 +00:00
Zhi
3cf2b1bc49 feat: auto activity logging on issue create/delete, fix schema db.add bug 2026-02-27 09:39:39 +00:00
Zhi
a56faacc4c feat: add curl to Dockerfile for health check 2026-02-27 09:37:42 +00:00
Zhi
622112c02f feat: comments RBAC + notification on new comment 2026-02-24 04:22:42 +00:00
Zhi
26ee18a4a4 feat: RBAC on issues (create/update/delete require dev+/mgr+) 2026-02-24 04:20:43 +00:00
Zhi
6d58ee779c feat: RBAC module + project endpoints protected (admin/mgr roles) 2026-02-24 04:16:32 +00:00
21 changed files with 1821 additions and 46 deletions

View File

@@ -5,6 +5,7 @@ WORKDIR /app
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
build-essential \ build-essential \
curl \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

72
app/api/rbac.py Normal file
View File

@@ -0,0 +1,72 @@
"""Role-based access control helpers - using configurable permissions."""
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
from app.models import models
def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None:
"""Get user's role in a project."""
member = db.query(models.ProjectMember).filter(
models.ProjectMember.user_id == user_id,
models.ProjectMember.project_id == project_id,
).first()
if member and member.role_id:
return db.query(Role).filter(Role.id == member.role_id).first()
# Check global admin
user = db.query(models.User).filter(models.User.id == user_id).first()
if user and user.is_admin:
# Return global admin role
return db.query(Role).filter(Role.is_global == True, Role.name == "superadmin").first()
return None
def has_permission(db: Session, user_id: int, project_id: int, permission: str) -> bool:
"""Check if user has a specific permission in a project."""
role = get_user_role(db, user_id, project_id)
if not role:
return False
# Check if role has the permission
perm = db.query(Permission).filter(Permission.name == permission).first()
if not perm:
return False
role_perm = db.query(RolePermission).filter(
RolePermission.role_id == role.id,
RolePermission.permission_id == perm.id
).first()
return role_perm is not None
def check_permission(db: Session, user_id: int, project_id: int, permission: str):
"""Raise 403 if user doesn't have the permission."""
if not has_permission(db, user_id, project_id, permission):
role = get_user_role(db, user_id, project_id)
role_name = role.name if role else "none"
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission '{permission}' required. Your role: {role_name}"
)
# Keep old function for backward compatibility (deprecated)
def check_project_role(db: Session, user_id: int, project_id: int, min_role: str = "viewer"):
"""Legacy function - maps old role names to new permission system."""
# Map old roles to permissions
role_to_perm = {
"admin": "project.edit",
"mgr": "milestone.create",
"dev": "issue.create",
"ops": "issue.view",
"viewer": "project.view",
}
perm = role_to_perm.get(min_role, "project.view")
check_permission(db, user_id, project_id, perm)

View File

@@ -1,4 +1,4 @@
"""Comments router.""" """Comments router with RBAC and notifications."""
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -6,16 +6,47 @@ from sqlalchemy.orm import Session
from app.core.config import get_db from app.core.config import get_db
from app.models import models from app.models import models
from app.schemas import schemas from app.schemas import schemas
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.models.notification import Notification as NotificationModel
router = APIRouter(tags=["Comments"]) router = APIRouter(tags=["Comments"])
def _notify_if_needed(db, issue_id, user_ids, ntype, title):
"""Helper to notify multiple users."""
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
return
for uid in set(user_ids):
if uid:
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="issue", entity_id=issue_id)
db.add(n)
db.commit()
@router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED) @router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED)
def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)): def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
# Get project_id from issue first
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
db_comment = models.Comment(**comment.model_dump()) db_comment = models.Comment(**comment.model_dump())
db.add(db_comment) db.add(db_comment)
db.commit() db.commit()
db.refresh(db_comment) db.refresh(db_comment)
# Notify reporter and assignee (but not the commenter themselves)
notify_users = []
if issue.reporter_id != current_user.id:
notify_users.append(issue.reporter_id)
if issue.assignee_id and issue.assignee_id != current_user.id:
notify_users.append(issue.assignee_id)
if notify_users:
_notify_if_needed(db, issue.id, notify_users, "comment_added", f"New comment on: {issue.title[:50]}")
return db_comment return db_comment
@@ -25,10 +56,14 @@ def list_comments(issue_id: int, db: Session = Depends(get_db)):
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) @router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db)): def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment: if not comment:
raise HTTPException(status_code=404, detail="Comment not found") raise HTTPException(status_code=404, detail="Comment not found")
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
for field, value in comment_update.model_dump(exclude_unset=True).items(): for field, value in comment_update.model_dump(exclude_unset=True).items():
setattr(comment, field, value) setattr(comment, field, value)
db.commit() db.commit()
@@ -37,10 +72,15 @@ def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: S
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_comment(comment_id: int, db: Session = Depends(get_db)): def delete_comment(comment_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment: if not comment:
raise HTTPException(status_code=404, detail="Comment not found") raise HTTPException(status_code=404, detail="Comment not found")
# Get issue to check project role
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
db.delete(comment) db.delete(comment)
db.commit() db.commit()
return None return None

View File

@@ -11,9 +11,41 @@ from app.models import models
from app.schemas import schemas from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel from app.models.notification import Notification as NotificationModel
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.services.activity import log_activity
router = APIRouter(tags=["Issues"]) router = APIRouter(tags=["Issues"])
# ---- Type / Subtype validation ----
ISSUE_SUBTYPE_MAP = {
'meeting': {'conference', 'handover', 'recap'},
'support': {'access', 'information'},
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
'maintenance': {'deploy', 'release'},
'review': {'code_review', 'decision_review', 'function_review'},
'story': {'feature', 'improvement', 'refactor'},
'test': {'regression', 'security', 'smoke', 'stress'},
'research': set(),
'task': {'defect'},
'resolution': set(),
}
ALLOWED_ISSUE_TYPES = set(ISSUE_SUBTYPE_MAP.keys())
def _validate_issue_type_subtype(issue_type: str | None, issue_subtype: str | None, require_subtype: bool = False):
if issue_type is None:
raise HTTPException(status_code=400, detail='issue_type is required')
if issue_type not in ALLOWED_ISSUE_TYPES:
raise HTTPException(status_code=400, detail=f'Invalid issue_type: {issue_type}')
allowed = ISSUE_SUBTYPE_MAP.get(issue_type, set())
if issue_subtype:
if issue_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid issue_subtype for {issue_type}: {issue_subtype}')
else:
if require_subtype and allowed:
raise HTTPException(status_code=400, detail=f'issue_subtype required for type: {issue_type}')
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None): def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message, n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
@@ -26,7 +58,8 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
# ---- CRUD ---- # ---- CRUD ----
@router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) @router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED)
def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)): def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
db_issue = models.Issue(**issue.model_dump()) db_issue = models.Issue(**issue.model_dump())
db.add(db_issue) db.add(db_issue)
db.commit() db.commit()
@@ -35,12 +68,13 @@ def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session =
bg.add_task(fire_webhooks_sync, event, bg.add_task(fire_webhooks_sync, event,
{"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status}, {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status},
db_issue.project_id, db) db_issue.project_id, db)
log_activity(db, "issue.created", "issue", db_issue.id, current_user.id, {"title": db_issue.title})
return db_issue return db_issue
@router.get("/issues") @router.get("/issues")
def list_issues( def list_issues(
project_id: int = None, issue_status: str = None, issue_type: str = None, project_id: int = None, issue_status: str = None, issue_type: str = None, issue_subtype: str = None,
assignee_id: int = None, tag: str = None, assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc", sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50, page: int = 1, page_size: int = 50,
@@ -54,6 +88,8 @@ def list_issues(
query = query.filter(models.Issue.status == issue_status) query = query.filter(models.Issue.status == issue_status)
if issue_type: if issue_type:
query = query.filter(models.Issue.issue_type == issue_type) query = query.filter(models.Issue.issue_type == issue_type)
if issue_subtype:
query = query.filter(models.Issue.issue_subtype == issue_subtype)
if assignee_id: if assignee_id:
query = query.filter(models.Issue.assignee_id == assignee_id) query = query.filter(models.Issue.assignee_id == assignee_id)
if tag: if tag:
@@ -93,15 +129,27 @@ def get_issue(issue_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
return issue return issue
@router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse) @router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse)
def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db)): def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
for field, value in issue_update.model_dump(exclude_unset=True).items(): update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
for field, value in update_data.items():
setattr(issue, field, value) setattr(issue, field, value)
db.commit() db.commit()
db.refresh(issue) db.refresh(issue)
@@ -109,10 +157,18 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session =
@router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_issue(issue_id: int, db: Session = Depends(get_db)): def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if issue:
check_project_role(db, current_user.id, issue.project_id, min_role="mgr")
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title})
db.delete(issue) db.delete(issue)
db.commit() db.commit()
return None return None
@@ -128,6 +184,11 @@ def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Se
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
old_status = issue.status old_status = issue.status
issue.status = new_status issue.status = new_status
db.commit() db.commit()
@@ -146,6 +207,11 @@ def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db))
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
user = db.query(models.User).filter(models.User.id == assignee_id).first() user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -200,6 +266,11 @@ def add_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
current = set(issue.tags.split(",")) if issue.tags else set() current = set(issue.tags.split(",")) if issue.tags else set()
current.add(tag.strip()) current.add(tag.strip())
current.discard("") current.discard("")
@@ -213,6 +284,11 @@ def remove_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
current = set(issue.tags.split(",")) if issue.tags else set() current = set(issue.tags.split(",")) if issue.tags else set()
current.discard(tag.strip()) current.discard(tag.strip())
current.discard("") current.discard("")
@@ -296,4 +372,4 @@ def search_issues(q: str, project_id: int = None, page: int = 1, page_size: int
total_pages = math.ceil(total / page_size) if total else 1 total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all() items = query.offset((page - 1) * page_size).limit(page_size).all()
return {"items": [schemas.IssueResponse.model_validate(i) for i in items], return {"items": [schemas.IssueResponse.model_validate(i) for i in items],
"total": total, "page": page, "page_size": page_size, "total_pages": total_pages} "total": total, "page": page, "page_size": page_size, "total_pages": total_pages}

View File

@@ -0,0 +1,68 @@
"""Milestones API router."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.models import models
from app.models.milestone import Milestone
from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
@router.get("", response_model=List[schemas.MilestoneResponse])
def list_milestones(project_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""List all milestones for a project."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
return milestones
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a new milestone for a project."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
db_milestone = Milestone(project_id=project_id, **milestone.model_dump())
db.add(db_milestone)
db.commit()
db.refresh(db_milestone)
return db_milestone
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get a milestone by ID."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return milestone
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
def update_milestone(project_id: int, milestone_id: int, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Update a milestone."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
for key, value in milestone.model_dump(exclude_unset=True).items():
setattr(db_milestone, key, value)
db.commit()
db.refresh(db_milestone)
return db_milestone
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Delete a milestone."""
check_project_role(db, current_user.id, project_id, min_role="admin")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
db.delete(db_milestone)
db.commit()
return None

View File

@@ -12,6 +12,7 @@ from sqlalchemy import func as sqlfunc
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models import models from app.models import models
from app.models.apikey import APIKey from app.models.apikey import APIKey
from app.models.activity import ActivityLog from app.models.activity import ActivityLog
@@ -184,38 +185,40 @@ class NotificationResponse(BaseModel):
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"]) @router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)): def list_notifications(unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id) query = db.query(NotificationModel).filter(NotificationModel.user_id == current_user.id)
if unread_only: if unread_only:
query = query.filter(NotificationModel.is_read == False) query = query.filter(NotificationModel.is_read == False)
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all() return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
@router.get("/notifications/count", tags=["Notifications"]) @router.get("/notifications/count", tags=["Notifications"])
def notification_count(user_id: int, db: Session = Depends(get_db)): def notification_count(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
count = db.query(NotificationModel).filter( count = db.query(NotificationModel).filter(
NotificationModel.user_id == user_id, NotificationModel.is_read == False NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
).count() ).count()
return {"user_id": user_id, "unread": count} return {"user_id": current_user.id, "count": count, "unread": count}
@router.post("/notifications/{notification_id}/read", tags=["Notifications"]) @router.post("/notifications/{notification_id}/read", tags=["Notifications"])
def mark_read(notification_id: int, db: Session = Depends(get_db)): def mark_read(notification_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first() n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first()
if not n: if not n:
raise HTTPException(status_code=404, detail="Notification not found") raise HTTPException(status_code=404, detail="Notification not found")
if n.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
n.is_read = True n.is_read = True
db.commit() db.commit()
return {"status": "read"} return {"status": "read"}
@router.post("/notifications/read-all", tags=["Notifications"]) @router.post("/notifications/read-all", tags=["Notifications"])
def mark_all_read(user_id: int, db: Session = Depends(get_db)): def mark_all_read(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
db.query(NotificationModel).filter( db.query(NotificationModel).filter(
NotificationModel.user_id == user_id, NotificationModel.is_read == False NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
).update({"is_read": True}) ).update({"is_read": True})
db.commit() db.commit()
return {"status": "all_read"} return {"status": "all_read", "user_id": current_user.id}
# ============ Work Logs ============ # ============ Work Logs ============
@@ -291,11 +294,11 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
issues = query.all() issues = query.all()
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output) writer = csv.writer(output)
writer.writerow(["id", "title", "type", "status", "priority", "project_id", writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
"reporter_id", "assignee_id", "milestone_id", "due_date", "reporter_id", "assignee_id", "milestone_id", "due_date",
"tags", "created_at", "updated_at"]) "tags", "created_at", "updated_at"])
for i in issues: for i in issues:
writer.writerow([i.id, i.title, i.issue_type, i.status, i.priority, i.project_id, writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", i.status, i.priority, i.project_id,
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date, i.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
i.tags, i.created_at, i.updated_at]) i.tags, i.created_at, i.updated_at])
output.seek(0) output.seek(0)

287
app/api/routers/monitor.py Normal file
View File

@@ -0,0 +1,287 @@
from datetime import datetime, timedelta, timezone
import json
import uuid
from typing import List, Dict
from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import get_db, SessionLocal
from app.api.deps import get_current_user_or_apikey
from app.models import models
from app.models.monitor import (
ProviderAccount,
MonitoredServer,
ServerState,
ServerChallenge,
ServerHandshakeNonce,
)
from app.services.monitoring import (
get_issue_stats_cached,
get_provider_usage_view,
get_server_states_view,
test_provider_connection,
)
from app.services.crypto_box import get_public_key_info, decrypt_payload_b64, ts_within
router = APIRouter(prefix='/monitor', tags=['Monitor'])
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
ACTIVE_WS: Dict[int, WebSocket] = {}
class ProviderAccountCreate(BaseModel):
provider: str
label: str
credential: str
class ProviderTestRequest(BaseModel):
provider: str
credential: str
class MonitoredServerCreate(BaseModel):
identifier: str
display_name: str | None = None
class ChallengeResponse(BaseModel):
identifier: str
challenge_uuid: str
expires_at: str
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
if not current_user.is_admin:
raise HTTPException(status_code=403, detail='Admin required')
return current_user
@router.get('/public/server-public-key')
def monitor_public_key():
return get_public_key_info()
@router.get('/public/overview')
def public_overview(db: Session = Depends(get_db)):
return {
'issues': get_issue_stats_cached(db, ttl_seconds=1800),
'providers': get_provider_usage_view(db),
'servers': get_server_states_view(db, offline_after_minutes=7),
'generated_at': datetime.now(timezone.utc).isoformat(),
}
@router.get('/admin/providers/accounts')
def list_provider_accounts(db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
accounts = db.query(ProviderAccount).order_by(ProviderAccount.created_at.desc()).all()
return [
{
'id': a.id,
'provider': a.provider,
'label': a.label,
'is_enabled': a.is_enabled,
'created_at': a.created_at,
'credential_masked': '***' + (a.credential[-4:] if a.credential else ''),
}
for a in accounts
]
@router.post('/admin/providers/accounts', status_code=status.HTTP_201_CREATED)
def create_provider_account(payload: ProviderAccountCreate, db: Session = Depends(get_db), user: models.User = Depends(require_admin)):
provider = payload.provider.lower().strip()
if provider not in SUPPORTED_PROVIDERS:
raise HTTPException(status_code=400, detail=f'Unsupported provider: {provider}')
obj = ProviderAccount(
provider=provider,
label=payload.label.strip(),
credential=payload.credential.strip(),
is_enabled=True,
created_by=user.id,
)
db.add(obj)
db.commit()
db.refresh(obj)
return {'id': obj.id, 'provider': obj.provider, 'label': obj.label, 'is_enabled': obj.is_enabled}
@router.post('/admin/providers/test')
def test_provider(payload: ProviderTestRequest, _: models.User = Depends(require_admin)):
ok, message = test_provider_connection(payload.provider.lower().strip(), payload.credential.strip())
return {'ok': ok, 'message': message}
@router.delete('/admin/providers/accounts/{account_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_provider_account(account_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
obj = db.query(ProviderAccount).filter(ProviderAccount.id == account_id).first()
if not obj:
raise HTTPException(status_code=404, detail='Provider account not found')
db.delete(obj)
db.commit()
return None
@router.get('/admin/servers')
def list_servers(db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
return get_server_states_view(db, offline_after_minutes=7)
@router.post('/admin/servers', status_code=status.HTTP_201_CREATED)
def add_server(payload: MonitoredServerCreate, db: Session = Depends(get_db), user: models.User = Depends(require_admin)):
identifier = payload.identifier.strip()
if not identifier:
raise HTTPException(status_code=400, detail='identifier required')
exists = db.query(MonitoredServer).filter(MonitoredServer.identifier == identifier).first()
if exists:
raise HTTPException(status_code=400, detail='identifier already exists')
obj = MonitoredServer(identifier=identifier, display_name=payload.display_name, is_enabled=True, created_by=user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return {'id': obj.id, 'identifier': obj.identifier, 'display_name': obj.display_name, 'is_enabled': obj.is_enabled}
@router.post('/admin/servers/{server_id}/challenge', response_model=ChallengeResponse)
def issue_server_challenge(server_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
server = db.query(MonitoredServer).filter(MonitoredServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail='Server not found')
challenge_uuid = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
ch = ServerChallenge(server_id=server_id, challenge_uuid=challenge_uuid, expires_at=expires_at)
db.add(ch)
db.commit()
return ChallengeResponse(identifier=server.identifier, challenge_uuid=challenge_uuid, expires_at=expires_at.isoformat())
@router.delete('/admin/servers/{server_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_server(server_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
obj = db.query(MonitoredServer).filter(MonitoredServer.id == server_id).first()
if not obj:
raise HTTPException(status_code=404, detail='Server not found')
state = db.query(ServerState).filter(ServerState.server_id == server_id).first()
if state:
db.delete(state)
db.query(ServerChallenge).filter(ServerChallenge.server_id == server_id).delete()
db.query(ServerHandshakeNonce).filter(ServerHandshakeNonce.server_id == server_id).delete()
db.delete(obj)
db.commit()
return None
class ServerHeartbeat(BaseModel):
identifier: str
openclaw_version: str | None = None
agents: List[dict] = []
cpu_pct: float | None = None
mem_pct: float | None = None
disk_pct: float | None = None
swap_pct: float | None = None
@router.post('/server/heartbeat')
def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)):
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == payload.identifier, MonitoredServer.is_enabled == True).first()
if not server:
raise HTTPException(status_code=404, detail='unknown server identifier')
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
if not st:
st = ServerState(server_id=server.id)
db.add(st)
st.openclaw_version = payload.openclaw_version
st.agents_json = json.dumps(payload.agents, ensure_ascii=False)
st.cpu_pct = payload.cpu_pct
st.mem_pct = payload.mem_pct
st.disk_pct = payload.disk_pct
st.swap_pct = payload.swap_pct
st.last_seen_at = datetime.now(timezone.utc)
db.commit()
return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}
@router.websocket('/server/ws')
async def server_ws(websocket: WebSocket):
await websocket.accept()
db = SessionLocal()
server_id = None
try:
hello = await websocket.receive_json()
encrypted_payload = (hello.get('encrypted_payload') or '').strip()
if encrypted_payload:
data = decrypt_payload_b64(encrypted_payload)
identifier = (data.get('identifier') or '').strip()
challenge_uuid = (data.get('challenge_uuid') or '').strip()
nonce = (data.get('nonce') or '').strip()
ts = data.get('ts')
if not ts_within(ts, max_minutes=10):
await websocket.close(code=4401)
return
else:
# backward compatible mode
identifier = (hello.get('identifier') or '').strip()
challenge_uuid = (hello.get('challenge_uuid') or '').strip()
nonce = (hello.get('nonce') or '').strip()
if not identifier or not challenge_uuid or not nonce:
await websocket.close(code=4400)
return
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == identifier, MonitoredServer.is_enabled == True).first()
if not server:
await websocket.close(code=4404)
return
ch = db.query(ServerChallenge).filter(ServerChallenge.challenge_uuid == challenge_uuid, ServerChallenge.server_id == server.id).first()
if not ch or ch.used_at is not None or ch.expires_at < datetime.now(timezone.utc):
await websocket.close(code=4401)
return
nonce_used = db.query(ServerHandshakeNonce).filter(ServerHandshakeNonce.server_id == server.id, ServerHandshakeNonce.nonce == nonce).first()
if nonce_used:
await websocket.close(code=4409)
return
db.add(ServerHandshakeNonce(server_id=server.id, nonce=nonce))
ch.used_at = datetime.now(timezone.utc)
db.commit()
server_id = server.id
ACTIVE_WS[server.id] = websocket
await websocket.send_json({'ok': True, 'server_id': server.id, 'message': 'connected'})
while True:
msg = await websocket.receive_json()
event = msg.get('event')
payload = msg.get('payload') or {}
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
if not st:
st = ServerState(server_id=server.id)
db.add(st)
if event == 'server.hello':
st.openclaw_version = payload.get('openclaw_version')
st.agents_json = json.dumps(payload.get('agents') or [], ensure_ascii=False)
elif event in {'server.metrics', 'agent.status_changed'}:
st.cpu_pct = payload.get('cpu_pct', st.cpu_pct)
st.mem_pct = payload.get('mem_pct', st.mem_pct)
st.disk_pct = payload.get('disk_pct', st.disk_pct)
st.swap_pct = payload.get('swap_pct', st.swap_pct)
if 'agents' in payload:
st.agents_json = json.dumps(payload.get('agents') or [], ensure_ascii=False)
st.last_seen_at = datetime.now(timezone.utc)
db.commit()
except WebSocketDisconnect:
pass
except Exception:
try:
await websocket.close(code=1011)
except Exception:
pass
finally:
if server_id and ACTIVE_WS.get(server_id) is websocket:
ACTIVE_WS.pop(server_id, None)
db.close()

View File

@@ -1,21 +1,179 @@
"""Projects router.""" """Projects router with RBAC."""
import json
import re
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_db from app.core.config import get_db
from app.models import models from app.models import models
from app.models.role_permission import Role
from app.schemas import schemas from app.schemas import schemas
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role, check_permission
router = APIRouter(prefix="/projects", tags=["Projects"]) router = APIRouter(prefix="/projects", tags=["Projects"])
def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None:
if not codes:
return None
# dedupe preserve order
seen = set()
ordered = []
for c in codes:
if c and c not in seen:
ordered.append(c)
seen.add(c)
if self_code and self_code in seen:
raise HTTPException(status_code=400, detail='Project cannot link to itself')
existing = {p.project_code for p in db.query(models.Project).filter(models.Project.project_code.in_(ordered)).all()}
missing = [c for c in ordered if c not in existing]
if missing:
raise HTTPException(status_code=400, detail=f'Unknown project codes: {", ".join(missing)}')
return ordered
WORD_SEGMENT_RE = re.compile(r"[A-Za-z]+")
CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+")
def _split_words(name: str):
segments = WORD_SEGMENT_RE.findall(name or '')
words = []
for seg in segments:
parts = CAMEL_RE.findall(seg)
for part in parts:
if part.isupper() and len(part) > 1:
words.extend(list(part))
else:
words.append(part)
return words
def _code_exists(db, code: str) -> bool:
return db.query(models.Project).filter(models.Project.project_code == code).first() is not None
def _next_counter(db, prefix: str, width: int) -> str:
if width <= 0:
return ''
counter = db.query(models.ProjectCodeCounter).filter(models.ProjectCodeCounter.prefix == prefix).first()
if not counter:
counter = models.ProjectCodeCounter(prefix=prefix, next_value=0)
db.add(counter)
db.flush()
value = counter.next_value
counter.next_value += 1
db.flush()
return format(value, 'x').upper().zfill(width)
def _generate_with_counter(db, prefix: str, width: int) -> str:
if prefix.upper() == 'UN':
prefix = 'UN'
while True:
suffix = _next_counter(db, prefix, width)
code = (prefix + suffix).upper()
if not _code_exists(db, code):
return code
def _generate_project_code(db, name: str) -> str:
words = _split_words(name)
if not words:
return _generate_with_counter(db, 'UN', 4)
if len(words) == 1:
letters = ''.join([c for c in words[0] if c.isalpha()]).upper()
if not letters:
return _generate_with_counter(db, 'UN', 4)
if len(letters) >= 6:
code = letters[:6]
if _code_exists(db, code):
raise HTTPException(status_code=400, detail='Project code collision')
return code
prefix = letters
width = 6 - len(prefix)
return _generate_with_counter(db, prefix, width)
total_letters = sum(len(w) for w in words)
if len(words) > 6:
code = ''.join([w[0] for w in words[:6]]).upper()
if _code_exists(db, code):
raise HTTPException(status_code=400, detail='Project code collision')
return code
if total_letters < 6:
prefix = ''.join(words).upper()
width = 6 - len(prefix)
return _generate_with_counter(db, prefix, width)
if total_letters == 6:
code = ''.join(words).upper()
if _code_exists(db, code):
raise HTTPException(status_code=400, detail='Project code collision')
return code
word_count = len(words)
needed = 6 - word_count
for idx in range(word_count - 1, -1, -1):
extra_letters = list(words[idx][1:])
if needed > len(extra_letters):
continue
indices = list(range(len(extra_letters)))
def combos(start, depth, path):
if depth == 0:
yield path
return
for i in range(start, len(indices) - depth + 1):
yield from combos(i + 1, depth - 1, path + [indices[i]])
for combo in combos(0, needed, []):
pieces = []
for wi, w in enumerate(words):
pieces.append(w[0])
if wi == idx:
pieces.extend([extra_letters[i] for i in combo])
code = ''.join(pieces)[:6].upper()
if not _code_exists(db, code):
return code
raise HTTPException(status_code=400, detail='Project code collision')
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
db_project = models.Project(**project.model_dump()) # Check if user is admin
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can create projects")
# Auto-fill owner_name from owner_id
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
if not user:
raise HTTPException(status_code=400, detail="Invalid owner_id: user not found")
payload = project.model_dump()
payload["owner_name"] = payload.get("owner_name") or user.username
payload["project_code"] = _generate_project_code(db, project.name)
# Validate and serialize sub_projects
sub_codes = payload.get("sub_projects")
if sub_codes:
payload["sub_projects"] = json.dumps(_validate_project_links(db, sub_codes, payload["project_code"]))
else:
payload["sub_projects"] = None
# Validate and serialize related_projects
related_codes = payload.get("related_projects")
if related_codes:
payload["related_projects"] = json.dumps(_validate_project_links(db, related_codes, payload["project_code"]))
else:
payload["related_projects"] = None
db_project = models.Project(**payload)
db.add(db_project) db.add(db_project)
db.commit() db.commit()
db.refresh(db_project) db.refresh(db_project)
# Auto-add creator as admin member
admin_role = db.query(Role).filter(Role.name == "admin").first()
db_member = models.ProjectMember(project_id=db_project.id, user_id=project.owner_id, role_id=admin_role.id if admin_role else None)
db.add(db_member)
db.commit()
return db_project return db_project
@@ -33,11 +191,27 @@ def get_project(project_id: int, db: Session = Depends(get_db)):
@router.patch("/{project_id}", response_model=schemas.ProjectResponse) @router.patch("/{project_id}", response_model=schemas.ProjectResponse)
def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: Session = Depends(get_db)): def update_project(
project_id: int,
project_update: schemas.ProjectUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
check_project_role(db, current_user.id, project_id, min_role="mgr")
project = db.query(models.Project).filter(models.Project.id == project_id).first() project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
for field, value in project_update.model_dump(exclude_unset=True).items(): update_data = project_update.model_dump(exclude_unset=True)
update_data.pop("name", None)
if "sub_projects" in update_data and update_data["sub_projects"]:
update_data["sub_projects"] = json.dumps(update_data["sub_projects"])
elif "sub_projects" in update_data:
update_data["sub_projects"] = None
if "related_projects" in update_data and update_data["related_projects"]:
update_data["related_projects"] = json.dumps(update_data["related_projects"])
elif "related_projects" in update_data:
update_data["related_projects"] = None
for field, value in update_data.items():
setattr(project, field, value) setattr(project, field, value)
db.commit() db.commit()
db.refresh(project) db.refresh(project)
@@ -45,10 +219,47 @@ def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: S
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(project_id: int, db: Session = Depends(get_db)): def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
check_project_role(db, current_user.id, project_id, min_role="admin")
project = db.query(models.Project).filter(models.Project.id == project_id).first() project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
project_code = project.project_code
# Delete milestones and their issues
from app.models.milestone import Milestone
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
for ms in milestones:
# Delete issues under milestone
issues = db.query(models.Issue).filter(models.Issue.milestone_id == ms.id).all()
for issue in issues:
db.delete(issue)
db.delete(ms)
# Delete project members
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
for m in members:
db.delete(m)
# Remove from other projects' sub_projects and related_projects
import json
all_projects = db.query(models.Project).all()
for p in all_projects:
if p.sub_projects and project_code in p.sub_projects:
subs = json.loads(p.sub_projects) if p.sub_projects else []
subs = [s for s in subs if s != project_code]
p.sub_projects = json.dumps(subs) if subs else None
if p.related_projects and project_code in p.related_projects:
related = json.loads(p.related_projects) if p.related_projects else []
related = [r for r in related if r != project_code]
p.related_projects = json.dumps(related) if related else None
db.delete(project) db.delete(project)
db.commit() db.commit()
return None return None
@@ -57,7 +268,13 @@ def delete_project(project_id: int, db: Session = Depends(get_db)):
# ---- Members ---- # ---- Members ----
@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) @router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db: Session = Depends(get_db)): def add_project_member(
project_id: int,
member: schemas.ProjectMemberCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
check_project_role(db, current_user.id, project_id, min_role="mgr")
project = db.query(models.Project).filter(models.Project.id == project_id).first() project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
@@ -69,23 +286,68 @@ def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db:
).first() ).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="User already a member") raise HTTPException(status_code=400, detail="User already a member")
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role=member.role) # Convert role name to role_id
role = db.query(Role).filter(Role.name == member.role).first()
role_id = role.id if role else None
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role_id=role_id)
db.add(db_member) db.add(db_member)
db.commit() db.commit()
db.refresh(db_member) db.refresh(db_member)
return db_member role_name = "developer"
if db_member.role_id:
role = db.query(Role).filter(Role.id == db_member.role_id).first()
if role:
role_name = role.name
return {
"id": db_member.id,
"user_id": db_member.user_id,
"project_id": db_member.project_id,
"role": role_name
}
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) @router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
def list_project_members(project_id: int, db: Session = Depends(get_db)): def list_project_members(project_id: int, db: Session = Depends(get_db)):
return db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
result = []
for m in members:
role_name = "developer"
if m.role_id:
role = db.query(Role).filter(Role.id == m.role_id).first()
if role:
role_name = role.name
result.append({
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"role": role_name
})
return result
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_project_member(project_id: int, user_id: int, db: Session = Depends(get_db)): def remove_project_member(
project_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
check_permission(db, current_user.id, project_id, "member.remove")
member = db.query(models.ProjectMember).filter( member = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id
).first() ).first()
# Prevent removing project owner (admin role)
if member.role_id:
role = db.query(Role).filter(Role.id == member.role_id).first()
if role and role.name == "admin":
# Check if this is the only admin
admin_count = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id,
models.ProjectMember.role_id == member.role_id
).count()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot remove the last owner of the project")
if not member: if not member:
raise HTTPException(status_code=404, detail="Member not found") raise HTTPException(status_code=404, detail="Member not found")
db.delete(member) db.delete(member)

214
app/api/routers/roles.py Normal file
View File

@@ -0,0 +1,214 @@
"""Roles and Permissions API router."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
router = APIRouter(prefix="/roles", tags=["Roles"])
# Schemas
class PermissionResponse(BaseModel):
id: int
name: str
description: str | None
category: str
class Config:
from_attributes = True
class RoleResponse(BaseModel):
id: int
name: str
description: str | None
is_global: bool
permission_ids: List[int] = []
class Config:
from_attributes = True
class RoleDetailResponse(BaseModel):
id: int
name: str
description: str | None
is_global: bool
permissions: List[PermissionResponse] = []
class Config:
from_attributes = True
class RoleCreate(BaseModel):
name: str
description: str | None = None
is_global: bool = False
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
class PermissionAssign(BaseModel):
permission_ids: List[int]
@router.get("/permissions", response_model=List[PermissionResponse])
def list_permissions(db: Session = Depends(get_db)):
"""List all permissions."""
return db.query(Permission).all()
@router.get("", response_model=List[RoleResponse])
def list_roles(db: Session = Depends(get_db)):
"""List all roles."""
roles = db.query(Role).all()
result = []
for role in roles:
perm_ids = [rp.permission_id for rp in role.permissions]
result.append(RoleResponse(
id=role.id,
name=role.name,
description=role.description,
is_global=role.is_global,
permission_ids=perm_ids
))
return result
@router.get("/{role_id}", response_model=RoleDetailResponse)
def get_role(role_id: int, db: Session = Depends(get_db)):
"""Get a role with its permissions."""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
perms = []
for rp in role.permissions:
perms.append(PermissionResponse(
id=rp.permission.id,
name=rp.permission.name,
description=rp.permission.description,
category=rp.permission.category
))
return RoleDetailResponse(
id=role.id,
name=role.name,
description=role.description,
is_global=role.is_global,
permissions=perms
)
@router.post("", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
def create_role(role: RoleCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a new role. Requires is_admin."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can create roles")
existing = db.query(Role).filter(Role.name == role.name).first()
if existing:
raise HTTPException(status_code=400, detail="Role already exists")
db_role = Role(**role.model_dump())
db.add(db_role)
db.commit()
db.refresh(db_role)
return RoleResponse(
id=db_role.id,
name=db_role.name,
description=db_role.description,
is_global=db_role.is_global,
permission_ids=[]
)
@router.patch("/{role_id}", response_model=RoleResponse)
def update_role(role_id: int, role: RoleUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Update a role."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can edit roles")
db_role = db.query(Role).filter(Role.id == role_id).first()
if not db_role:
raise HTTPException(status_code=404, detail="Role not found")
for key, value in role.model_dump(exclude_unset=True).items():
setattr(db_role, key, value)
db.commit()
db.refresh(db_role)
perm_ids = [rp.permission_id for rp in db_role.permissions]
return RoleResponse(
id=db_role.id,
name=db_role.name,
description=db_role.description,
is_global=db_role.is_global,
permission_ids=perm_ids
)
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(role_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Delete a role."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can delete roles")
db_role = db.query(Role).filter(Role.id == role_id).first()
if not db_role:
raise HTTPException(status_code=404, detail="Role not found")
member_count = db.query(models.ProjectMember).filter(models.ProjectMember.role_id == role_id).count()
if member_count > 0:
raise HTTPException(status_code=400, detail="Role is in use by members")
db.delete(db_role)
db.commit()
return None
@router.post("/{role_id}/permissions", response_model=RoleDetailResponse)
def assign_permissions(role_id: int, perm_assign: PermissionAssign, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Assign permissions to a role."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can edit role permissions")
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
db.query(RolePermission).filter(RolePermission.role_id == role_id).delete()
for perm_id in perm_assign.permission_ids:
perm = db.query(Permission).filter(Permission.id == perm_id).first()
if perm:
rp = RolePermission(role_id=role_id, permission_id=perm_id)
db.add(rp)
db.commit()
db.refresh(role)
perms = []
for rp in role.permissions:
perms.append(PermissionResponse(
id=rp.permission.id,
name=rp.permission.name,
description=rp.permission.description,
category=rp.permission.category
))
return RoleDetailResponse(
id=role.id,
name=role.name,
description=role.description,
is_global=role.is_global,
permissions=perms
)

View File

@@ -70,7 +70,7 @@ def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None:
return user return user
def init_default_project(db: Session, project_cfg: dict, owner_id: int) -> None: def init_default_project(db: Session, project_cfg: dict, owner_id: int, owner_name: str = "") -> None:
"""Create default project if configured and not exists.""" """Create default project if configured and not exists."""
name = project_cfg.get("name") name = project_cfg.get("name")
if not name: if not name:
@@ -83,6 +83,7 @@ def init_default_project(db: Session, project_cfg: dict, owner_id: int) -> None:
project = models.Project( project = models.Project(
name=name, name=name,
description=project_cfg.get("description", ""), description=project_cfg.get("description", ""),
owner_name=project_cfg.get("owner") or owner_name or "",
owner_id=owner_id, owner_id=owner_id,
) )
db.add(project) db.add(project)
@@ -108,6 +109,6 @@ def run_init(db: Session) -> None:
# Default project # Default project
project_cfg = config.get("default_project") project_cfg = config.get("default_project")
if project_cfg and admin_user: if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id) init_default_project(db, project_cfg, admin_user.id, admin_user.username)
logger.info("Initialization complete") logger.info("Initialization complete")

View File

@@ -34,6 +34,9 @@ from app.api.routers.users import router as users_router
from app.api.routers.comments import router as comments_router from app.api.routers.comments import router as comments_router
from app.api.routers.webhooks import router as webhooks_router from app.api.routers.webhooks import router as webhooks_router
from app.api.routers.misc import router as misc_router from app.api.routers.misc import router as misc_router
from app.api.routers.monitor import router as monitor_router
from app.api.routers.milestones import router as milestones_router
from app.api.routers.roles import router as roles_router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(issues_router) app.include_router(issues_router)
@@ -42,13 +45,53 @@ app.include_router(users_router)
app.include_router(comments_router) app.include_router(comments_router)
app.include_router(webhooks_router) app.include_router(webhooks_router)
app.include_router(misc_router) app.include_router(misc_router)
app.include_router(monitor_router)
app.include_router(milestones_router)
app.include_router(roles_router)
# Auto schema migration for lightweight deployments
def _migrate_schema():
from sqlalchemy import text
from app.core.config import SessionLocal
db = SessionLocal()
try:
# issues.issue_subtype
result = db.execute(text("SHOW COLUMNS FROM issues LIKE 'issue_subtype'")).fetchone()
if not result:
db.execute(text("ALTER TABLE issues ADD COLUMN issue_subtype VARCHAR(64) NULL"))
# issues.issue_type enum -> varchar
result = db.execute(text("SHOW COLUMNS FROM issues WHERE Field='issue_type'")).fetchone()
if result and 'enum' in result[1].lower():
db.execute(text("ALTER TABLE issues MODIFY issue_type VARCHAR(32) DEFAULT 'issue'"))
# projects.project_code
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
# projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
# projects.sub_projects / related_projects
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL"))
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL"))
except Exception as e:
print(f"Migration warning: {e}")
finally:
db.close()
# Run database migration on startup # Run database migration on startup
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
from app.core.config import Base, engine, SessionLocal from app.core.config import Base, engine, SessionLocal
from app.models import webhook, apikey, activity, milestone, notification, worklog from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_schema()
# Initialize from AbstractWizard (admin user, default project, etc.) # Initialize from AbstractWizard (admin user, default project, etc.)
from app.init_wizard import run_init from app.init_wizard import run_init
@@ -57,3 +100,21 @@ def startup():
run_init(db) run_init(db)
finally: finally:
db.close() db.close()
# Start lightweight monitor polling thread (every 10 minutes)
import threading, time
from app.services.monitoring import refresh_provider_usage_once
def _monitor_poll_loop():
while True:
db2 = SessionLocal()
try:
refresh_provider_usage_once(db2)
except Exception:
pass
finally:
db2.close()
time.sleep(600)
t = threading.Thread(target=_monitor_poll_loop, daemon=True)
t.start()

View File

@@ -1,15 +1,22 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.config import Base from app.core.config import Base
from app.models.role_permission import Role
import enum import enum
class IssueType(str, enum.Enum): class IssueType(str, enum.Enum):
TASK = "task" MEETING = "meeting"
SUPPORT = "support"
ISSUE = "issue"
MAINTENANCE = "maintenance"
RESEARCH = "research"
REVIEW = "review"
STORY = "story" STORY = "story"
TEST = "test" TEST = "test"
RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交 RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交
TASK = "task" # legacy generic type
class IssueStatus(str, enum.Enum): class IssueStatus(str, enum.Enum):
@@ -33,7 +40,8 @@ class Issue(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False) title = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
issue_type = Column(Enum(IssueType), default=IssueType.TASK) issue_type = Column(String(32), default=IssueType.ISSUE.value)
issue_subtype = Column(String(64), nullable=True)
status = Column(Enum(IssueStatus), default=IssueStatus.OPEN) status = Column(Enum(IssueStatus), default=IssueStatus.OPEN)
priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM) priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM)
@@ -85,6 +93,11 @@ class Project(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False) name = Column(String(100), unique=True, nullable=False)
project_code = Column(String(16), unique=True, index=True, nullable=True)
owner_name = Column(String(128), nullable=False)
sub_projects = Column(String(512), nullable=True)
related_projects = Column(String(512), nullable=True)
repo = Column(String(512), nullable=True)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -120,7 +133,16 @@ class ProjectMember(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role = Column(String(20), default="dev") # admin, dev, mgr, ops role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
role = relationship("Role")
project = relationship("Project", back_populates="members") project = relationship("Project", back_populates="members")
user = relationship("User", back_populates="project_memberships") user = relationship("User", back_populates="project_memberships")
class ProjectCodeCounter(Base):
__tablename__ = "project_code_counters"
id = Column(Integer, primary_key=True, index=True)
prefix = Column(String(16), unique=True, index=True, nullable=False)
next_value = Column(Integer, default=0)

78
app/models/monitor.py Normal file
View File

@@ -0,0 +1,78 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Float, ForeignKey
from sqlalchemy.sql import func
from app.core.config import Base
class ProviderAccount(Base):
__tablename__ = 'provider_accounts'
id = Column(Integer, primary_key=True, index=True)
provider = Column(String(32), nullable=False, index=True) # anthropic/openai/minimax/kimi/qwen
label = Column(String(128), nullable=False)
credential = Column(Text, nullable=False) # TODO: encrypt at rest
is_enabled = Column(Boolean, default=True)
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class ProviderUsageSnapshot(Base):
__tablename__ = 'provider_usage_snapshots'
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey('provider_accounts.id'), nullable=False, index=True)
window_label = Column(String(32), nullable=True) # e.g. 1h / 7d
used = Column(Float, nullable=True)
limit = Column(Float, nullable=True)
usage_pct = Column(Float, nullable=True)
reset_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(32), nullable=False, default='unknown') # ok/error/pending/unsupported
error = Column(Text, nullable=True)
raw_payload = Column(Text, nullable=True)
fetched_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
class MonitoredServer(Base):
__tablename__ = 'monitored_servers'
id = Column(Integer, primary_key=True, index=True)
identifier = Column(String(128), nullable=False, unique=True)
display_name = Column(String(128), nullable=True)
is_enabled = Column(Boolean, default=True)
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class ServerState(Base):
__tablename__ = 'server_states'
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey('monitored_servers.id'), nullable=False, unique=True)
openclaw_version = Column(String(64), nullable=True)
agents_json = Column(Text, nullable=True) # json list
cpu_pct = Column(Float, nullable=True)
mem_pct = Column(Float, nullable=True)
disk_pct = Column(Float, nullable=True)
swap_pct = Column(Float, nullable=True)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class ServerChallenge(Base):
__tablename__ = 'server_challenges'
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey('monitored_servers.id'), nullable=False, index=True)
challenge_uuid = Column(String(64), nullable=False, unique=True, index=True)
expires_at = Column(DateTime(timezone=True), nullable=False)
used_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class ServerHandshakeNonce(Base):
__tablename__ = 'server_handshake_nonces'
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey('monitored_servers.id'), nullable=False, index=True)
nonce = Column(String(128), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,44 @@
"""Role and Permission models."""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class Role(Base):
"""Role definition - configurable roles."""
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False)
description = Column(String(255), nullable=True)
is_global = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
permissions = relationship("RolePermission", back_populates="role")
class Permission(Base):
"""Permission definitions - granular permissions."""
__tablename__ = "permissions"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(String(255), nullable=True)
category = Column(String(50), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
roles = relationship("RolePermission", back_populates="permission")
class RolePermission(Base):
"""Maps roles to permissions."""
__tablename__ = "role_permissions"
id = Column(Integer, primary_key=True, index=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False)
role = relationship("Role", back_populates="permissions")
permission = relationship("Permission", back_populates="roles")

View File

@@ -5,10 +5,16 @@ from enum import Enum
class IssueTypeEnum(str, Enum): class IssueTypeEnum(str, Enum):
TASK = "task" MEETING = "meeting"
SUPPORT = "support"
ISSUE = "issue"
MAINTENANCE = "maintenance"
RESEARCH = "research"
REVIEW = "review"
STORY = "story" STORY = "story"
TEST = "test" TEST = "test"
RESOLUTION = "resolution" RESOLUTION = "resolution"
TASK = "task" # legacy
class IssueStatusEnum(str, Enum): class IssueStatusEnum(str, Enum):
@@ -30,7 +36,8 @@ class IssuePriorityEnum(str, Enum):
class IssueBase(BaseModel): class IssueBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
issue_type: IssueTypeEnum = IssueTypeEnum.TASK issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE
issue_subtype: Optional[str] = None
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None depends_on_id: Optional[int] = None
@@ -51,6 +58,8 @@ class IssueCreate(IssueBase):
class IssueUpdate(BaseModel): class IssueUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
issue_type: Optional[IssueTypeEnum] = None
issue_subtype: Optional[str] = None
status: Optional[IssueStatusEnum] = None status: Optional[IssueStatusEnum] = None
priority: Optional[IssuePriorityEnum] = None priority: Optional[IssuePriorityEnum] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
@@ -110,7 +119,10 @@ class CommentResponse(CommentBase):
# Project schemas # Project schemas
class ProjectBase(BaseModel): class ProjectBase(BaseModel):
name: str name: str
owner_name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
sub_projects: Optional[list[str]] = None
related_projects: Optional[list[str]] = None
class ProjectCreate(ProjectBase): class ProjectCreate(ProjectBase):
@@ -118,13 +130,27 @@ class ProjectCreate(ProjectBase):
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
owner_name: Optional[str] = None
sub_projects: Optional[list[str]] = None
related_projects: Optional[list[str]] = None
class ProjectResponse(ProjectBase): class ProjectResponse(BaseModel):
id: int
name: str
owner_name: Optional[str] = None
project_code: Optional[str] = None
description: Optional[str] = None
sub_projects: Optional[list[str]] = None
related_projects: Optional[list[str]] = None
owner_id: int
created_at: datetime
class _ProjectResponse_Inactive(ProjectBase):
id: int id: int
owner_id: int owner_id: int
project_code: str | None = None
created_at: datetime created_at: datetime
class Config: class Config:
@@ -156,7 +182,6 @@ class UserResponse(UserBase):
# Project Member schemas # Project Member schemas
class ProjectMemberBase(BaseModel): class ProjectMemberBase(BaseModel):
user_id: int user_id: int
project_id: int
role: str = "dev" role: str = "dev"
@@ -164,8 +189,11 @@ class ProjectMemberCreate(ProjectMemberBase):
pass pass
class ProjectMemberResponse(ProjectMemberBase): class ProjectMemberResponse(BaseModel):
id: int id: int
user_id: int
project_id: int
role: str = "dev"
class Config: class Config:
from_attributes = True from_attributes = True
@@ -179,7 +207,7 @@ class MilestoneBase(BaseModel):
class MilestoneCreate(MilestoneBase): class MilestoneCreate(MilestoneBase):
project_id: int pass
class MilestoneUpdate(BaseModel): class MilestoneUpdate(BaseModel):

18
app/services/activity.py Normal file
View File

@@ -0,0 +1,18 @@
"""Activity logging helper — auto-record CRUD operations."""
import json
from sqlalchemy.orm import Session
from app.models.activity import ActivityLog
def log_activity(db: Session, action: str, entity_type: str, entity_id: int, user_id: int = None, details: dict = None):
"""Record an activity log entry."""
entry = ActivityLog(
action=action,
entity_type=entity_type,
entity_id=entity_id,
user_id=user_id,
details=json.dumps(details) if details else None,
)
db.add(entry)
db.commit()
return entry

View File

@@ -0,0 +1,63 @@
import base64
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import rsa, padding
KEY_DIR = Path(os.getenv('MONITOR_KEY_DIR', '/config/monitor_keys'))
PRIV_PATH = KEY_DIR / 'monitor_private.pem'
PUB_PATH = KEY_DIR / 'monitor_public.pem'
def ensure_keypair() -> None:
KEY_DIR.mkdir(parents=True, exist_ok=True)
if PRIV_PATH.exists() and PUB_PATH.exists():
return
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
PRIV_PATH.write_bytes(private_pem)
PUB_PATH.write_bytes(public_pem)
def get_public_key_info() -> Dict[str, str]:
ensure_keypair()
pem = PUB_PATH.read_text()
kid = hashlib.sha256(pem.encode()).hexdigest()[:16]
return {'public_key_pem': pem, 'key_id': kid}
def decrypt_payload_b64(ciphertext_b64: str) -> Dict[str, Any]:
ensure_keypair()
private_key = serialization.load_pem_private_key(PRIV_PATH.read_bytes(), password=None)
plaintext = private_key.decrypt(
base64.b64decode(ciphertext_b64),
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
obj = json.loads(plaintext.decode())
return obj
def ts_within(ts_iso: str, max_minutes: int = 10) -> bool:
try:
ts = datetime.fromisoformat(ts_iso.replace('Z', '+00:00'))
except Exception:
return False
now = datetime.now(timezone.utc)
return abs((now - ts).total_seconds()) <= max_minutes * 60

305
app/services/monitoring.py Normal file
View File

@@ -0,0 +1,305 @@
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Tuple
import requests
from sqlalchemy.orm import Session
from app.models.models import Issue
from app.models.monitor import ProviderAccount, ProviderUsageSnapshot, MonitoredServer, ServerState
_CACHE: Dict[str, Dict[str, Any]] = {}
def _now():
return datetime.now(timezone.utc)
def _parse_credential(raw: str) -> Dict[str, Any]:
raw = (raw or '').strip()
if raw.startswith('{'):
try:
return json.loads(raw)
except Exception:
return {'api_key': raw}
return {'api_key': raw}
def _parse_reset_at(value) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=timezone.utc)
if isinstance(value, str):
try:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
except Exception:
return None
return None
def _normalize_usage_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
used = payload.get('used') or payload.get('usage') or payload.get('consumed') or payload.get('total_usage')
limit = payload.get('limit') or payload.get('quota') or payload.get('hard_limit') or payload.get('total')
remaining = payload.get('remain') or payload.get('remaining') or payload.get('left')
usage_pct = payload.get('usage_pct') or payload.get('percent') or payload.get('usage_percent')
window_label = payload.get('window') or payload.get('window_label')
reset_at = payload.get('reset_at') or payload.get('reset_time') or payload.get('reset')
if used is None and remaining is not None and limit is not None:
try:
used = float(limit) - float(remaining)
except Exception:
pass
if usage_pct is None and used is not None and limit:
try:
usage_pct = round(float(used) / float(limit) * 100, 2)
except Exception:
pass
return {
'window_label': window_label,
'used': used,
'limit': limit,
'usage_pct': usage_pct,
'reset_at': _parse_reset_at(reset_at),
'raw': payload,
}
def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
key = 'issue_stats_24h'
now = _now()
hit = _CACHE.get(key)
if hit and (now - hit['at']).total_seconds() < ttl_seconds:
return hit['data']
since = now - timedelta(hours=24)
total = db.query(Issue).count()
new_24h = db.query(Issue).filter(Issue.created_at >= since).count()
processed_24h = db.query(Issue).filter(
Issue.updated_at != None,
Issue.updated_at >= since,
Issue.status.in_(['resolved', 'closed'])
).count()
data = {
'total_issues': total,
'new_issues_24h': new_24h,
'processed_issues_24h': processed_24h,
'computed_at': now.isoformat(),
'cache_ttl_seconds': ttl_seconds,
}
_CACHE[key] = {'at': now, 'data': data}
return data
def _provider_headers(provider: str, credential: str, extra: Dict[str, Any] | None = None):
extra = extra or {}
if extra.get('auth_header'):
val = extra.get('auth_value')
if not val:
scheme = extra.get('auth_scheme')
val = f"{scheme} {credential}" if scheme else credential
return {extra['auth_header']: val}
if provider == 'openai':
return {'Authorization': f'Bearer {credential}'}
if provider == 'anthropic':
return {'x-api-key': credential, 'anthropic-version': '2023-06-01'}
return {}
def test_provider_connection(provider: str, credential: str):
provider = provider.lower()
info = _parse_credential(credential)
key = info.get('api_key') or credential
try:
if provider == 'openai':
r = requests.get('https://api.openai.com/v1/models', headers=_provider_headers(provider, key, info), timeout=12)
return r.status_code == 200, f'status={r.status_code}'
if provider == 'anthropic':
r = requests.get('https://api.anthropic.com/v1/models', headers=_provider_headers(provider, key, info), timeout=12)
return r.status_code == 200, f'status={r.status_code}'
usage_url = info.get('usage_url') or info.get('test_url')
if provider == 'kimi' and not usage_url:
usage_url = 'https://www.kimi.com/api/user/usage'
if usage_url:
r = requests.get(usage_url, headers=_provider_headers(provider, key, info), timeout=12)
return r.status_code < 500, f'status={r.status_code}'
if provider in {'minimax', 'kimi', 'qwen'}:
return True, 'accepted (connectivity check pending provider-specific adapter)'
return False, 'unsupported provider'
except Exception as e:
return False, str(e)
def _openai_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
info = _parse_credential(credential)
key = info.get('api_key') or credential
headers = _provider_headers('openai', key, info)
today = _now().date()
start = (today - timedelta(days=7)).isoformat()
end = today.isoformat()
usage_url = info.get('usage_url') or f'https://api.openai.com/v1/dashboard/billing/usage?start_date={start}&end_date={end}'
sub_url = info.get('subscription_url') or 'https://api.openai.com/v1/dashboard/billing/subscription'
u = requests.get(usage_url, headers=headers, timeout=12)
s = requests.get(sub_url, headers=headers, timeout=12)
if u.status_code != 200 or s.status_code != 200:
return 'error', {'error': f'usage:{u.status_code}, subscription:{s.status_code}'}
usage = u.json()
sub = s.json()
total_usage = usage.get('total_usage')
hard_limit = sub.get('hard_limit_usd')
reset_at_ts = sub.get('billing_cycle_anchor')
reset_at = datetime.fromtimestamp(reset_at_ts, tz=timezone.utc) if reset_at_ts else None
usage_pct = None
if total_usage is not None and hard_limit:
usage_pct = round(total_usage / hard_limit * 100, 2)
return 'ok', {
'window_label': '7d',
'used': total_usage,
'limit': hard_limit,
'usage_pct': usage_pct,
'reset_at': reset_at,
'raw': {'usage': usage, 'subscription': sub},
}
def _anthropic_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
info = _parse_credential(credential)
key = info.get('api_key') or credential
usage_url = info.get('usage_url')
if not usage_url:
return 'unsupported', {'error': 'anthropic usage API not configured'}
r = requests.get(usage_url, headers=_provider_headers('anthropic', key, info), timeout=12)
if r.status_code != 200:
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
payload = r.json()
if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict):
payload = payload['data']
return 'ok', _normalize_usage_payload(payload)
def _kimi_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
info = _parse_credential(credential)
key = info.get('api_key') or credential
usage_url = info.get('usage_url') or 'https://www.kimi.com/api/user/usage'
r = requests.get(usage_url, headers=_provider_headers('kimi', key, info), timeout=12)
if r.status_code != 200:
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
payload = r.json()
if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict):
payload = payload['data']
return 'ok', _normalize_usage_payload(payload)
def _minimax_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
info = _parse_credential(credential)
key = info.get('api_key') or credential
usage_url = info.get('usage_url')
if not usage_url:
return 'unsupported', {'error': 'minimax usage API not configured'}
r = requests.get(usage_url, headers=_provider_headers('minimax', key, info), timeout=12)
if r.status_code != 200:
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
payload = r.json()
if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict):
payload = payload['data']
return 'ok', _normalize_usage_payload(payload)
def _generic_usage(provider: str, credential: str) -> Tuple[str, Dict[str, Any]]:
info = _parse_credential(credential)
key = info.get('api_key') or credential
usage_url = info.get('usage_url')
if not usage_url:
return 'unsupported', {'error': f'{provider} usage API not configured'}
r = requests.get(usage_url, headers=_provider_headers(provider, key, info), timeout=12)
if r.status_code != 200:
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
payload = r.json()
return 'ok', _normalize_usage_payload(payload)
def refresh_provider_usage_once(db: Session):
accounts = db.query(ProviderAccount).filter(ProviderAccount.is_enabled == True).all()
now = _now()
for a in accounts:
status = 'pending'
payload: Dict[str, Any] = {}
if a.provider == 'openai':
status, payload = _openai_usage(a.credential)
elif a.provider == 'anthropic':
status, payload = _anthropic_usage(a.credential)
elif a.provider == 'kimi':
status, payload = _kimi_usage(a.credential)
elif a.provider == 'minimax':
status, payload = _minimax_usage(a.credential)
elif a.provider == 'qwen':
status, payload = _generic_usage(a.provider, a.credential)
else:
ok, msg = test_provider_connection(a.provider, a.credential)
status = 'ok' if ok else 'error'
payload = {'error': None if ok else msg}
snap = ProviderUsageSnapshot(
account_id=a.id,
window_label=payload.get('window_label'),
used=payload.get('used'),
limit=payload.get('limit'),
usage_pct=payload.get('usage_pct'),
reset_at=payload.get('reset_at'),
status=status,
error=payload.get('error'),
raw_payload=json.dumps(payload.get('raw') or payload, ensure_ascii=False),
fetched_at=now,
)
db.add(snap)
db.commit()
def get_provider_usage_view(db: Session):
accounts = db.query(ProviderAccount).filter(ProviderAccount.is_enabled == True).all()
rows = []
for a in accounts:
snap = db.query(ProviderUsageSnapshot).filter(ProviderUsageSnapshot.account_id == a.id).order_by(ProviderUsageSnapshot.fetched_at.desc()).first()
rows.append({
'account_id': a.id,
'provider': a.provider,
'label': a.label,
'window': snap.window_label if snap else None,
'usage_pct': snap.usage_pct if snap else None,
'used': snap.used if snap else None,
'limit': snap.limit if snap else None,
'reset_at': snap.reset_at.isoformat() if snap and snap.reset_at else None,
'status': snap.status if snap else 'pending',
'error': snap.error if snap else None,
'fetched_at': snap.fetched_at.isoformat() if snap and snap.fetched_at else None,
})
return rows
def get_server_states_view(db: Session, offline_after_minutes: int = 7):
now = _now()
servers = db.query(MonitoredServer).filter(MonitoredServer.is_enabled == True).all()
out = []
for s in servers:
st = db.query(ServerState).filter(ServerState.server_id == s.id).first()
last_seen = st.last_seen_at if st else None
online = bool(last_seen and (now - last_seen).total_seconds() <= offline_after_minutes * 60)
out.append({
'server_id': s.id,
'identifier': s.identifier,
'display_name': s.display_name or s.identifier,
'online': online,
'openclaw_version': st.openclaw_version if st else None,
'cpu_pct': st.cpu_pct if st else None,
'mem_pct': st.mem_pct if st else None,
'disk_pct': st.disk_pct if st else None,
'swap_pct': st.swap_pct if st else None,
'agents': json.loads(st.agents_json) if st and st.agents_json else [],
'last_seen_at': last_seen.isoformat() if last_seen else None,
})
return out

View File

@@ -0,0 +1,63 @@
# Provider 账号凭证格式Monitor
默认情况下,`credential` 可以直接填写 API Key 字符串。
如果需要配置自定义 usage 端点,请使用 JSON 字符串。
## 基础格式
```json
{
"api_key": "sk-...",
"usage_url": "https://.../usage",
"auth_header": "Authorization",
"auth_scheme": "Bearer"
}
```
### 字段说明
- `api_key`: API key必填
- `usage_url`: 统计用量的 GET 端点可选minimax/kimi/qwen 推荐填写)
- `auth_header`: 自定义鉴权头名(可选)
- `auth_scheme`: 鉴权 scheme`Bearer`),会拼成 `Bearer <api_key>`
- `auth_value`: 直接指定头值(优先级高于 scheme
## OpenAI
默认使用 OpenAI 官方 billing endpoints7天窗口
- `https://api.openai.com/v1/dashboard/billing/usage`
- `https://api.openai.com/v1/dashboard/billing/subscription`
如需自定义可使用 JSON
```json
{
"api_key": "sk-...",
"usage_url": "https://api.openai.com/v1/dashboard/billing/usage?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD",
"subscription_url": "https://api.openai.com/v1/dashboard/billing/subscription"
}
```
## Anthropic
官方 usage API 需要自行提供 `usage_url`(不同组织可能不同):
```json
{
"api_key": "ak-...",
"usage_url": "https://api.anthropic.com/.../usage"
}
```
## Minimax / Kimi / Qwen
目前需要你提供 `usage_url`(具体端点取决于部署/账号):
```json
{
"api_key": "...",
"usage_url": "https://.../usage",
"auth_header": "Authorization",
"auth_scheme": "Bearer"
}
```
## Kimi
推荐 usage_url: https://www.kimi.com/api/user/usage
Authorization: Bearer <API_KEY>
## Minimax
推荐 usage_url: https://platform.minimax.io/v1/api/openplatform/coding_plan/remains
Authorization: Bearer <API_KEY>

View File

@@ -0,0 +1,68 @@
# OpenClaw Monitor Agent Plugin 开发计划(草案)
## 目标
让被监测服务器通过 WebSocket 主动接入 HarborForge Backend并持续上报
- OpenClaw 版本
- agent 列表
- 每 5 分钟主机指标CPU/MEM/DISK/SWAP
- agent 状态变更事件
## 握手流程
1. Admin 在 HarborForge 后台添加 server identifier
2. Admin 生成 challenge10 分钟有效)
3. 插件请求 `GET /monitor/public/server-public-key` 获取公钥
4. 插件构造 payload
- `identifier`
- `challenge_uuid`
- `nonce`(随机)
- `ts`ISO8601
5. 使用 RSA-OAEP(SHA256) 公钥加密base64 后作为 `encrypted_payload` 发给 `WS /monitor/server/ws`
6. 握手成功后进入事件上报通道
## 插件事件协议
### server.hello
```json
{
"event": "server.hello",
"payload": {
"openclaw_version": "x.y.z",
"agents": [{"id": "a1", "name": "agent-1", "status": "idle"}]
}
}
```
### server.metrics每 5 分钟)
```json
{
"event": "server.metrics",
"payload": {
"cpu_pct": 21.3,
"mem_pct": 42.1,
"disk_pct": 55.9,
"swap_pct": 0.0,
"agents": [{"id": "a1", "name": "agent-1", "status": "busy"}]
}
}
```
### agent.status_changed可选
```json
{
"event": "agent.status_changed",
"payload": {
"agents": [{"id": "a1", "name": "agent-1", "status": "focus"}]
}
}
```
## 实施里程碑
- M1: Node/Python CLI 插件最小握手联通
- M2: 指标采集 + 周期上报
- M3: agent 状态采集与变更事件
- M4: 守护化systemd+ 断线重连 + 本地日志
## 风险与注意事项
- 时钟漂移会导致 `ts` 校验失败(建议 NTP
- challenge 仅一次可用,重复使用会被拒绝
- nonce 重放会被拒绝
- 需要保证插件本地安全保存 identifier/challenge短期

View File

@@ -11,3 +11,4 @@ python-multipart==0.0.6
alembic==1.13.1 alembic==1.13.1
python-dotenv==1.0.0 python-dotenv==1.0.0
httpx==0.27.0 httpx==0.27.0
requests==2.31.0