feat(vmis): 租戶自動開通完整流程 + Admin Portal SSO + NC 行事曆訂閱

Backend:
- schedule_tenant: NC 新容器自動 pgsql 安裝 (_nc_db_check 全新容器處理)
- schedule_tenant: NC 初始化加入 Redis + APCu memcache 設定 (修正 OIDC invalid_state)
- schedule_tenant: 新租戶 KC realm 自動設定 accessCodeLifespan=600s (修正 authentication_expired)
- schedule_account: NC Mail 帳號自動設定 (nc_mail_result/nc_mail_done_at)
- schedule_account: NC 台灣國定假日行事曆自動訂閱 (CalDAV MKCALENDAR)
- nextcloud_client: 新增 subscribe_calendar() CalDAV 訂閱方法
- settings: 新增系統設定 API (site_title/version/timezone/SSO/Keycloak)
- models/result: 新增 nc_mail_result, nc_mail_done_at 欄位
- alembic: 遷移 002(system_settings) 003(keycloak_admin) 004(nc_mail_result)

Frontend (Admin Portal):
- 新增完整管理後台 (index/tenants/accounts/servers/schedules/logs/settings/system-status)
- api.js: Keycloak JS Adapter SSO 整合 (PKCE/S256, fallback KC JS 來源, 自動 token 更新)
- index.html: Promise.allSettled 取代 Promise.all,防止單一 API 失敗影響整頁
- 所有頁面加入 try/catch + toast 錯誤處理
- 新增品牌 LOGO 與 favicon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VMIS Developer
2026-03-15 15:31:37 +08:00
parent 42d1420f9c
commit 62baadb06f
53 changed files with 5638 additions and 195 deletions

View File

@@ -11,10 +11,10 @@ call venv\Scripts\activate.bat
echo. echo.
echo [2/2] 啟動 FastAPI 服務... echo [2/2] 啟動 FastAPI 服務...
echo API Server: http://localhost:10181 echo API Server: http://localhost:10281
echo API Docs: http://localhost:10181/docs echo API Docs: http://localhost:10281/docs
echo. echo.
python -m uvicorn app.main:app --host 0.0.0.0 --port 10181 --reload python -m uvicorn app.main:app --host 0.0.0.0 --port 10281 --reload
pause pause

View File

@@ -0,0 +1,33 @@
"""add system_settings table
Revision ID: 002
Revises: 001
Create Date: 2026-03-14
"""
from alembic import op
import sqlalchemy as sa
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'system_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('site_title', sa.String(200), nullable=False, server_default='VMIS Admin Portal'),
sa.Column('version', sa.String(50), nullable=False, server_default='2.0.0'),
sa.Column('timezone', sa.String(100), nullable=False, server_default='Asia/Taipei'),
sa.Column('sso_enabled', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('keycloak_url', sa.String(200), nullable=False, server_default='https://auth.lab.taipei'),
sa.Column('keycloak_realm', sa.String(100), nullable=False, server_default='vmis-admin'),
sa.Column('keycloak_client', sa.String(100), nullable=False, server_default='vmis-portal'),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
sa.PrimaryKeyConstraint('id'),
)
def downgrade() -> None:
op.drop_table('system_settings')

View File

@@ -0,0 +1,25 @@
"""add keycloak admin credentials to system_settings
Revision ID: 003
Revises: 002
Create Date: 2026-03-14
"""
from alembic import op
import sqlalchemy as sa
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('system_settings',
sa.Column('keycloak_admin_user', sa.String(100), nullable=False, server_default='admin'))
op.add_column('system_settings',
sa.Column('keycloak_admin_pass', sa.String(200), nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('system_settings', 'keycloak_admin_pass')
op.drop_column('system_settings', 'keycloak_admin_user')

View File

@@ -0,0 +1,23 @@
"""add nc_mail_result to account_schedule_results
Revision ID: 004
Revises: 003
Create Date: 2026-03-15
"""
from alembic import op
import sqlalchemy as sa
revision = "004"
down_revision = "003"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("account_schedule_results", sa.Column("nc_mail_result", sa.Boolean(), nullable=True))
op.add_column("account_schedule_results", sa.Column("nc_mail_done_at", sa.DateTime(), nullable=True))
def downgrade():
op.drop_column("account_schedule_results", "nc_mail_done_at")
op.drop_column("account_schedule_results", "nc_mail_result")

View File

@@ -33,6 +33,7 @@ def _get_lights(db: Session, account_id: int) -> Optional[AccountStatusLight]:
sso_result=result.sso_result, sso_result=result.sso_result,
mailbox_result=result.mailbox_result, mailbox_result=result.mailbox_result,
nc_result=result.nc_result, nc_result=result.nc_result,
nc_mail_result=result.nc_mail_result,
quota_usage=result.quota_usage, quota_usage=result.quota_usage,
) )
@@ -60,6 +61,7 @@ def list_accounts(
@router.post("", response_model=AccountResponse, status_code=201) @router.post("", response_model=AccountResponse, status_code=201)
def create_account(payload: AccountCreate, db: Session = Depends(get_db)): def create_account(payload: AccountCreate, db: Session = Depends(get_db)):
import secrets
tenant = db.get(Tenant, payload.tenant_id) tenant = db.get(Tenant, payload.tenant_id)
if not tenant: if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found") raise HTTPException(status_code=404, detail="Tenant not found")
@@ -68,8 +70,12 @@ def create_account(payload: AccountCreate, db: Session = Depends(get_db)):
account_code = _build_account_code(tenant.prefix, seq_no) account_code = _build_account_code(tenant.prefix, seq_no)
email = f"{payload.sso_account}@{tenant.domain}" email = f"{payload.sso_account}@{tenant.domain}"
data = payload.model_dump()
if not data.get("default_password"):
data["default_password"] = account_code # 預設密碼 = 帳號編碼,使用者首次登入後必須變更
account = Account( account = Account(
**payload.model_dump(), **data,
seq_no=seq_no, seq_no=seq_no,
account_code=account_code, account_code=account_code,
email=email, email=email,

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import tenants, accounts, schedules, servers, status from app.api.v1 import tenants, accounts, schedules, servers, status, settings
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(tenants.router) api_router.include_router(tenants.router)
@@ -7,3 +7,4 @@ api_router.include_router(accounts.router)
api_router.include_router(schedules.router) api_router.include_router(schedules.router)
api_router.include_router(servers.router) api_router.include_router(servers.router)
api_router.include_router(status.router) api_router.include_router(status.router)
api_router.include_router(settings.router)

View File

@@ -1,12 +1,16 @@
from typing import List from typing import List
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from croniter import croniter from croniter import croniter
from app.core.database import get_db from app.core.database import get_db
from app.models.schedule import Schedule from app.models.schedule import Schedule
from app.schemas.schedule import ScheduleResponse, ScheduleUpdate, ScheduleLogResponse from app.schemas.schedule import (
ScheduleResponse, ScheduleUpdate, ScheduleLogResponse,
LogResultsResponse, TenantResultItem, AccountResultItem,
)
router = APIRouter(prefix="/schedules", tags=["schedules"]) router = APIRouter(prefix="/schedules", tags=["schedules"])
@@ -29,9 +33,9 @@ def update_schedule_cron(schedule_id: int, payload: ScheduleUpdate, db: Session
s = db.get(Schedule, schedule_id) s = db.get(Schedule, schedule_id)
if not s: if not s:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
# Validate cron expression # Validate cron expression (5-field: 分 時 日 月 週)
try: try:
cron = croniter(payload.cron_timer, datetime.utcnow()) cron = croniter(payload.cron_timer, now_tw())
next_run = cron.get_next(datetime) next_run = cron.get_next(datetime)
except Exception: except Exception:
raise HTTPException(status_code=422, detail="Invalid cron expression") raise HTTPException(status_code=422, detail="Invalid cron expression")
@@ -67,3 +71,59 @@ def get_schedule_logs(schedule_id: int, limit: int = 20, db: Session = Depends(g
.all() .all()
) )
return logs return logs
@router.get("/{schedule_id}/logs/{log_id}/results", response_model=LogResultsResponse)
def get_log_results(schedule_id: int, log_id: int, db: Session = Depends(get_db)):
"""取得某次排程執行的詳細逐項結果"""
from app.models.result import TenantScheduleResult, AccountScheduleResult
from app.models.tenant import Tenant
from app.models.account import Account
tenant_results = []
account_results = []
if schedule_id == 1:
rows = (
db.query(TenantScheduleResult)
.filter(TenantScheduleResult.schedule_log_id == log_id)
.all()
)
for r in rows:
tenant = db.get(Tenant, r.tenant_id)
tenant_results.append(TenantResultItem(
tenant_id=r.tenant_id,
tenant_name=tenant.name if tenant else None,
traefik_status=r.traefik_status,
sso_result=r.sso_result,
mailbox_result=r.mailbox_result,
nc_result=r.nc_result,
office_result=r.office_result,
quota_usage=r.quota_usage,
fail_reason=r.fail_reason,
))
elif schedule_id == 2:
rows = (
db.query(AccountScheduleResult)
.filter(AccountScheduleResult.schedule_log_id == log_id)
.all()
)
for r in rows:
account_results.append(AccountResultItem(
account_id=r.account_id,
sso_account=r.sso_account,
sso_result=r.sso_result,
mailbox_result=r.mailbox_result,
nc_result=r.nc_result,
nc_mail_result=r.nc_mail_result,
quota_usage=r.quota_usage,
fail_reason=r.fail_reason,
))
return LogResultsResponse(
log_id=log_id,
schedule_id=schedule_id,
tenant_results=tenant_results,
account_results=account_results,
)

View File

@@ -1,5 +1,6 @@
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.utils import now_tw
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, case from sqlalchemy import func, case
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,7 +13,7 @@ router = APIRouter(prefix="/servers", tags=["servers"])
def _calc_availability(db: Session, server_id: int, days: int) -> Optional[float]: def _calc_availability(db: Session, server_id: int, days: int) -> Optional[float]:
since = datetime.utcnow() - timedelta(days=days) since = now_tw() - timedelta(days=days)
row = ( row = (
db.query( db.query(
func.count().label("total"), func.count().label("total"),

View File

@@ -0,0 +1,135 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.utils import configure_timezone
from app.models.settings import SystemSettings
from app.models.tenant import Tenant
from app.models.account import Account
from app.schemas.settings import SettingsUpdate, SettingsResponse
from app.services.keycloak_client import KeycloakClient
router = APIRouter(prefix="/settings", tags=["settings"])
def _get_or_create(db: Session) -> SystemSettings:
s = db.query(SystemSettings).first()
if not s:
s = SystemSettings(id=1)
db.add(s)
db.commit()
db.refresh(s)
return s
@router.get("", response_model=SettingsResponse)
def get_settings(db: Session = Depends(get_db)):
return _get_or_create(db)
@router.put("", response_model=SettingsResponse)
def update_settings(payload: SettingsUpdate, db: Session = Depends(get_db)):
s = _get_or_create(db)
# 啟用 SSO 前置條件檢查
if payload.sso_enabled is True:
manager = (
db.query(Tenant)
.filter(Tenant.is_manager == True, Tenant.is_active == True)
.first()
)
if not manager:
raise HTTPException(
status_code=422,
detail="啟用 SSO 前必須先建立 is_manager=true 的管理租戶"
)
has_account = (
db.query(Account)
.filter(Account.tenant_id == manager.id, Account.is_active == True)
.first()
)
if not has_account:
raise HTTPException(
status_code=422,
detail="管理租戶必須至少有一個有效帳號才能啟用 SSO"
)
for field, value in payload.model_dump(exclude_none=True).items():
setattr(s, field, value)
db.commit()
db.refresh(s)
configure_timezone(s.timezone)
return s
@router.post("/test-keycloak")
def test_keycloak(db: Session = Depends(get_db)):
"""測試 Keycloak master realm 管理帳密是否正確"""
s = _get_or_create(db)
if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
raise HTTPException(status_code=400, detail="請先設定 Keycloak URL 及管理帳密")
kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
try:
token = kc._get_admin_token()
if token:
return {"ok": True, "message": f"連線成功:{s.keycloak_url}"}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Keycloak 連線失敗:{str(e)}")
@router.post("/init-sso-realm")
def init_sso_realm(db: Session = Depends(get_db)):
"""
建立 Admin Portal SSO 環境:
1. 以管理租戶的 keycloak_realm 為準(無管理租戶時 fallback 至 system settings
2. 確認該 realm 存在(不存在則建立)
3. 在該 realm 建立 vmis-portal Public Client
4. 同步回寫 system_settings.keycloak_realm前端 JS Adapter 使用)
"""
s = _get_or_create(db)
if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
raise HTTPException(status_code=400, detail="請先設定並儲存 Keycloak 連線資訊")
# 以管理租戶的 keycloak_realm 為主要來源
manager = (
db.query(Tenant)
.filter(Tenant.is_manager == True, Tenant.is_active == True)
.first()
)
if manager and manager.keycloak_realm:
realm = manager.keycloak_realm
else:
realm = s.keycloak_realm or "vmis-admin"
client_id = s.keycloak_client or "vmis-portal"
kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
results = []
# 確認/建立 realm
if kc.realm_exists(realm):
results.append(f"✓ Realm '{realm}' 已存在")
else:
ok = kc.create_realm(realm, manager.name if manager else "VMIS Admin Portal")
if ok:
results.append(f"✓ Realm '{realm}' 建立成功")
else:
raise HTTPException(status_code=500, detail=f"Realm '{realm}' 建立失敗")
# 建立 vmis-portal Public Client
status = kc.create_public_client(realm, client_id)
if status == "exists":
results.append(f"✓ Client '{client_id}' 已存在")
elif status == "created":
results.append(f"✓ Client '{client_id}' 建立成功")
else:
results.append(f"✗ Client '{client_id}' 建立失敗")
# 同步回寫 system_settings.keycloak_realm前端 Keycloak JS Adapter 使用)
if s.keycloak_realm != realm:
s.keycloak_realm = realm
db.commit()
results.append(f"✓ 系統設定 keycloak_realm 同步為 '{realm}'")
return {"ok": True, "realm": realm, "details": results}

View File

@@ -17,6 +17,11 @@ class Settings(BaseSettings):
DOCKER_SSH_USER: str = "porsche" DOCKER_SSH_USER: str = "porsche"
TENANT_DEPLOY_BASE: str = "/home/porsche/tenants" TENANT_DEPLOY_BASE: str = "/home/porsche/tenants"
NC_ADMIN_USER: str = "admin"
NC_ADMIN_PASSWORD: str = "NC1qaz2wsx"
OO_JWT_SECRET: str = "OnlyOffice2026Secret"
APP_ENV: str = "development" APP_ENV: str = "development"
APP_PORT: int = 10281 APP_PORT: int = 10281

View File

@@ -8,6 +8,7 @@ engine = create_engine(
pool_pre_ping=True, pool_pre_ping=True,
pool_size=10, pool_size=10,
max_overflow=20, max_overflow=20,
connect_args={"options": "-c timezone=Asia/Taipei"},
) )
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

18
backend/app/core/utils.py Normal file
View File

@@ -0,0 +1,18 @@
from datetime import datetime
from zoneinfo import ZoneInfo
_tz = ZoneInfo("Asia/Taipei")
def configure_timezone(tz_name: str) -> None:
"""Update the application timezone. Called on startup and when settings change."""
global _tz
try:
_tz = ZoneInfo(tz_name)
except Exception:
_tz = ZoneInfo("Asia/Taipei")
def now_tw() -> datetime:
"""Return current time in the configured timezone as a naive datetime."""
return datetime.now(tz=_tz).replace(tzinfo=None)

View File

@@ -3,9 +3,11 @@ from app.models.account import Account
from app.models.schedule import Schedule, ScheduleLog from app.models.schedule import Schedule, ScheduleLog
from app.models.result import TenantScheduleResult, AccountScheduleResult from app.models.result import TenantScheduleResult, AccountScheduleResult
from app.models.server import Server, ServerStatusLog, SystemStatusLog from app.models.server import Server, ServerStatusLog, SystemStatusLog
from app.models.settings import SystemSettings
__all__ = [ __all__ = [
"Tenant", "Account", "Schedule", "ScheduleLog", "Tenant", "Account", "Schedule", "ScheduleLog",
"TenantScheduleResult", "AccountScheduleResult", "TenantScheduleResult", "AccountScheduleResult",
"Server", "ServerStatusLog", "SystemStatusLog", "Server", "ServerStatusLog", "SystemStatusLog",
"SystemSettings",
] ]

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, DateTime, ForeignKey from sqlalchemy import Boolean, Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -21,8 +22,8 @@ class Account(Base):
default_password = Column(String(200)) default_password = Column(String(200))
seq_no = Column(Integer, nullable=False) # 同租戶內流水號 seq_no = Column(Integer, nullable=False) # 同租戶內流水號
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=now_tw)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=now_tw, onupdate=now_tw)
tenant = relationship("Tenant", back_populates="accounts") tenant = relationship("Tenant", back_populates="accounts")
schedule_results = relationship("AccountScheduleResult", back_populates="account") schedule_results = relationship("AccountScheduleResult", back_populates="account")

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Float, ForeignKey from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -28,7 +29,7 @@ class TenantScheduleResult(Base):
fail_reason = Column(Text) fail_reason = Column(Text)
quota_usage = Column(Float) # GB quota_usage = Column(Float) # GB
recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow) recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="tenant_results") schedule_log = relationship("ScheduleLog", back_populates="tenant_results")
tenant = relationship("Tenant", back_populates="schedule_results") tenant = relationship("Tenant", back_populates="schedule_results")
@@ -52,9 +53,12 @@ class AccountScheduleResult(Base):
nc_result = Column(Boolean) nc_result = Column(Boolean)
nc_done_at = Column(DateTime) nc_done_at = Column(DateTime)
nc_mail_result = Column(Boolean)
nc_mail_done_at = Column(DateTime)
fail_reason = Column(Text) fail_reason = Column(Text)
quota_usage = Column(Float) # GB quota_usage = Column(Float) # GB
recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow) recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="account_results") schedule_log = relationship("ScheduleLog", back_populates="account_results")
account = relationship("Account", back_populates="schedule_results") account = relationship("Account", back_populates="schedule_results")

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -14,7 +15,7 @@ class Schedule(Base):
last_run_at = Column(DateTime) last_run_at = Column(DateTime)
next_run_at = Column(DateTime) next_run_at = Column(DateTime)
last_status = Column(String(10)) # ok / error last_status = Column(String(10)) # ok / error
recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow) recorded_at = Column(DateTime, nullable=False, default=now_tw)
logs = relationship("ScheduleLog", back_populates="schedule") logs = relationship("ScheduleLog", back_populates="schedule")

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Float, ForeignKey from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -13,7 +14,7 @@ class Server(Base):
description = Column(String(200)) description = Column(String(200))
sort_order = Column(Integer, nullable=False, default=0) sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow) recorded_at = Column(DateTime, nullable=False, default=now_tw)
status_logs = relationship("ServerStatusLog", back_populates="server") status_logs = relationship("ServerStatusLog", back_populates="server")
@@ -27,7 +28,7 @@ class ServerStatusLog(Base):
result = Column(Boolean, nullable=False) result = Column(Boolean, nullable=False)
response_time = Column(Float) # ms response_time = Column(Float) # ms
fail_reason = Column(Text) fail_reason = Column(Text)
recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow) recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="server_status_logs") schedule_log = relationship("ScheduleLog", back_populates="server_status_logs")
server = relationship("Server", back_populates="status_logs") server = relationship("Server", back_populates="status_logs")
@@ -43,6 +44,6 @@ class SystemStatusLog(Base):
service_desc = Column(String(100)) service_desc = Column(String(100))
result = Column(Boolean, nullable=False) result = Column(Boolean, nullable=False)
fail_reason = Column(Text) fail_reason = Column(Text)
recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow) recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="system_status_logs") schedule_log = relationship("ScheduleLog", back_populates="system_status_logs")

View File

@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from app.core.database import Base
from app.core.utils import now_tw
class SystemSettings(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, default=1)
site_title = Column(String(200), nullable=False, default="VMIS Admin Portal")
version = Column(String(50), nullable=False, default="2.0.0")
timezone = Column(String(100), nullable=False, default="Asia/Taipei")
sso_enabled = Column(Boolean, nullable=False, default=False)
# Keycloak — master realm admin (for tenant realm management)
keycloak_url = Column(String(200), nullable=False, default="https://auth.lab.taipei")
keycloak_admin_user = Column(String(100), nullable=False, default="admin")
keycloak_admin_pass = Column(String(200), nullable=False, default="")
# Keycloak — Admin Portal SSO
keycloak_realm = Column(String(100), nullable=False, default="vmis-admin")
keycloak_client = Column(String(100), nullable=False, default="vmis-portal")
updated_at = Column(DateTime, nullable=False, default=now_tw, onupdate=now_tw)

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Date from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Date
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -30,8 +31,8 @@ class Tenant(Base):
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
status = Column(String(20), nullable=False, default="trial") # trial / active / inactive status = Column(String(20), nullable=False, default="trial") # trial / active / inactive
note = Column(Text) note = Column(Text)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=now_tw)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=now_tw, onupdate=now_tw)
accounts = relationship("Account", back_populates="tenant", cascade="all, delete-orphan") accounts = relationship("Account", back_populates="tenant", cascade="all, delete-orphan")
schedule_results = relationship("TenantScheduleResult", back_populates="tenant") schedule_results = relationship("TenantScheduleResult", back_populates="tenant")

View File

@@ -32,6 +32,7 @@ class AccountStatusLight(BaseModel):
sso_result: Optional[bool] = None sso_result: Optional[bool] = None
mailbox_result: Optional[bool] = None mailbox_result: Optional[bool] = None
nc_result: Optional[bool] = None nc_result: Optional[bool] = None
nc_mail_result: Optional[bool] = None
quota_usage: Optional[float] = None quota_usage: Optional[float] = None

View File

@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, List
from pydantic import BaseModel from pydantic import BaseModel
@@ -31,3 +31,39 @@ class ScheduleLogResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class TenantResultItem(BaseModel):
tenant_id: int
tenant_name: Optional[str] = None
traefik_status: Optional[bool] = None
sso_result: Optional[bool] = None
mailbox_result: Optional[bool] = None
nc_result: Optional[bool] = None
office_result: Optional[bool] = None
quota_usage: Optional[float] = None
fail_reason: Optional[str] = None
class Config:
from_attributes = True
class AccountResultItem(BaseModel):
account_id: int
sso_account: Optional[str] = None
sso_result: Optional[bool] = None
mailbox_result: Optional[bool] = None
nc_result: Optional[bool] = None
nc_mail_result: Optional[bool] = None
quota_usage: Optional[float] = None
fail_reason: Optional[str] = None
class Config:
from_attributes = True
class LogResultsResponse(BaseModel):
log_id: int
schedule_id: int
tenant_results: List[TenantResultItem] = []
account_results: List[AccountResultItem] = []

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class SettingsUpdate(BaseModel):
site_title: Optional[str] = None
version: Optional[str] = None
timezone: Optional[str] = None
sso_enabled: Optional[bool] = None
keycloak_url: Optional[str] = None
keycloak_admin_user: Optional[str] = None
keycloak_admin_pass: Optional[str] = None
keycloak_realm: Optional[str] = None
keycloak_client: Optional[str] = None
class SettingsResponse(BaseModel):
id: int
site_title: str
version: str
timezone: str
sso_enabled: bool
keycloak_url: str
keycloak_admin_user: str
keycloak_admin_pass: str
keycloak_realm: str
keycloak_client: str
updated_at: Optional[datetime] = None
model_config = {"from_attributes": True}

View File

@@ -1,6 +1,7 @@
""" """
DockerClient — docker-py (本機 Docker socket) + paramiko SSH (遠端 docker compose) DockerClient — paramiko SSH (遠端 docker / traefik 查詢)
管理租戶的 NC / OO 容器 所有容器都在 10.1.0.254,透過 SSH 操作
3-state 回傳: None=未設定(灰), True=正常(綠), False=異常(紅)
""" """
import logging import logging
from typing import Optional from typing import Optional
@@ -12,70 +13,74 @@ logger = logging.getLogger(__name__)
class DockerClient: class DockerClient:
def __init__(self):
self._docker = None
def _get_docker(self): def _ssh(self):
if self._docker is None: """建立 SSH 連線到 10.1.0.254"""
import docker import paramiko
self._docker = docker.from_env() client = paramiko.SSHClient()
return self._docker client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
settings.DOCKER_SSH_HOST,
username=settings.DOCKER_SSH_USER,
timeout=15,
)
return client
def check_traefik_route(self, domain: str) -> bool: def check_traefik_domain(self, domain: str) -> Optional[bool]:
""" """
Traefik API: GET http://localhost:8080/api/http/routers None = domain 在 Traefik 沒有路由設定(灰)
驗證 routers 中包含 domain且 routers 數量 > 0 True = 路由存在且服務存活(綠)
False = 路由存在但服務不通(紅)
""" """
try: try:
resp = httpx.get("http://localhost:8080/api/overview", timeout=5.0) resp = httpx.get(f"http://{settings.DOCKER_SSH_HOST}:8080/api/http/routers", timeout=5.0)
if resp.status_code != 200: if resp.status_code != 200:
return False return False
data = resp.json() routers = resp.json()
# Verify actual routes exist (functional check) route_found = any(domain in str(r.get("rule", "")) for r in routers)
http_count = data.get("http", {}).get("routers", {}).get("total", 0) if not route_found:
if http_count == 0: return None
# Route exists — probe the service
try:
probe = httpx.get(
f"https://{domain}",
timeout=5.0,
follow_redirects=True,
)
return probe.status_code < 500
except Exception:
return False return False
# Check domain-specific router
routers_resp = httpx.get("http://localhost:8080/api/http/routers", timeout=5.0)
if routers_resp.status_code != 200:
return False
routers = routers_resp.json()
return any(domain in str(r.get("rule", "")) for r in routers)
except Exception as e: except Exception as e:
logger.warning(f"Traefik check failed for {domain}: {e}") logger.warning(f"Traefik check failed for {domain}: {e}")
return False return False
def ensure_container_running(self, container_name: str, tenant_code: str, realm: str) -> bool: def check_container_ssh(self, container_name: str) -> Optional[bool]:
"""Check container status; start if exited; deploy via SSH if not found.""" """
SSH 到 10.1.0.254 查詢容器狀態。
None = 容器不存在(未部署)
True = 容器正在執行
False = 容器存在但未執行exited/paused
"""
try: try:
docker_client = self._get_docker() client = self._ssh()
container = docker_client.containers.get(container_name) _, stdout, _ = client.exec_command(
if container.status == "running": f"docker inspect --format='{{{{.State.Status}}}}' {container_name} 2>/dev/null"
return True
elif container.status == "exited":
container.start()
container.reload()
return container.status == "running"
except Exception as e:
if "Not Found" in str(e) or "404" in str(e):
return self._ssh_compose_up(tenant_code, realm)
logger.error(f"Docker check failed for {container_name}: {e}")
return False
return False
def _ssh_compose_up(self, tenant_code: str, realm: str) -> bool:
"""SSH into 10.1.0.254 and run docker compose up -d"""
try:
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
settings.DOCKER_SSH_HOST,
username=settings.DOCKER_SSH_USER,
timeout=15,
) )
output = stdout.read().decode().strip()
client.close()
if not output:
return None
return output == "running"
except Exception as e:
logger.error(f"SSH container check failed for {container_name}: {e}")
return False
def ssh_compose_up(self, tenant_code: str) -> bool:
"""SSH 到 10.1.0.254 執行 docker compose up -d"""
try:
client = self._ssh()
deploy_dir = f"{settings.TENANT_DEPLOY_BASE}/{tenant_code}" deploy_dir = f"{settings.TENANT_DEPLOY_BASE}/{tenant_code}"
stdin, stdout, stderr = client.exec_command( _, stdout, _ = client.exec_command(
f"cd {deploy_dir} && docker compose up -d 2>&1" f"cd {deploy_dir} && docker compose up -d 2>&1"
) )
exit_status = stdout.channel.recv_exit_status() exit_status = stdout.channel.recv_exit_status()
@@ -84,3 +89,19 @@ class DockerClient:
except Exception as e: except Exception as e:
logger.error(f"SSH compose up failed for {tenant_code}: {e}") logger.error(f"SSH compose up failed for {tenant_code}: {e}")
return False return False
def get_oo_disk_usage_gb(self, container_name: str) -> Optional[float]:
"""取得 OO 容器磁碟使用量GB容器不存在回傳 None"""
try:
client = self._ssh()
_, stdout, _ = client.exec_command(
f"docker exec {container_name} df -B1 / 2>/dev/null | awk 'NR==2 {{print $3}}'"
)
output = stdout.read().decode().strip()
client.close()
if output.isdigit():
return round(int(output) / (1024 ** 3), 3)
return None
except Exception as e:
logger.warning(f"OO disk usage check failed for {container_name}: {e}")
return None

View File

@@ -1,34 +1,36 @@
""" """
KeycloakClient — 直接呼叫 Keycloak REST API不使用 python-keycloak 套件。 KeycloakClient — 直接呼叫 Keycloak REST API不使用 python-keycloak 套件。
管理租戶 realm 及帳號的建立/查詢 使用 master realm admin 帳密取得管理 token管理租戶 realm 及帳號。
""" """
import logging import logging
from typing import Optional from typing import Optional
import httpx import httpx
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TIMEOUT = 10.0 TIMEOUT = 10.0
class KeycloakClient: class KeycloakClient:
def __init__(self): def __init__(self, base_url: str, admin_user: str, admin_pass: str):
self._base = settings.KEYCLOAK_URL.rstrip("/") self._base = base_url.rstrip("/")
self._admin_user = admin_user
self._admin_pass = admin_pass
self._admin_token: Optional[str] = None self._admin_token: Optional[str] = None
def _get_admin_token(self) -> str: def _get_admin_token(self) -> str:
"""取得 vmis-admin realm 的 admin access token""" """取得 master realm 的 admin access tokenResource Owner Password"""
url = f"{self._base}/realms/{settings.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token" url = f"{self._base}/realms/master/protocol/openid-connect/token"
resp = httpx.post( resp = httpx.post(
url, url,
data={ data={
"grant_type": "client_credentials", "grant_type": "password",
"client_id": settings.KEYCLOAK_ADMIN_CLIENT_ID, "client_id": "admin-cli",
"client_secret": settings.KEYCLOAK_ADMIN_CLIENT_SECRET, "username": self._admin_user,
"password": self._admin_pass,
}, },
timeout=TIMEOUT, timeout=TIMEOUT,
verify=False,
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json()["access_token"] return resp.json()["access_token"]
@@ -43,7 +45,12 @@ class KeycloakClient:
def realm_exists(self, realm: str) -> bool: def realm_exists(self, realm: str) -> bool:
try: try:
resp = httpx.get(self._admin_url(realm), headers=self._headers(), timeout=TIMEOUT) resp = httpx.get(
self._admin_url(realm),
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
return resp.status_code == 200 return resp.status_code == 200
except Exception: except Exception:
return False return False
@@ -60,21 +67,58 @@ class KeycloakClient:
json=payload, json=payload,
headers=self._headers(), headers=self._headers(),
timeout=TIMEOUT, timeout=TIMEOUT,
verify=False,
) )
return resp.status_code in (201, 204) return resp.status_code in (201, 204)
def update_realm_token_settings(self, realm: str, access_code_lifespan: int = 600) -> bool:
"""設定 realm 的授權碼有效期(秒),預設 10 分鐘"""
resp = httpx.put(
self._admin_url(realm),
json={
"accessCodeLifespan": access_code_lifespan,
"actionTokenGeneratedByUserLifespan": access_code_lifespan,
},
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
return resp.status_code in (200, 204)
def send_welcome_email(self, realm: str, user_id: str) -> bool:
"""寄送歡迎信(含設定密碼連結)給新使用者"""
try:
resp = httpx.put(
self._admin_url(f"{realm}/users/{user_id}/execute-actions-email"),
json=["UPDATE_PASSWORD"],
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
return resp.status_code in (200, 204)
except Exception as e:
logger.error(f"KC send_welcome_email({realm}/{user_id}) failed: {e}")
return False
def get_user_uuid(self, realm: str, username: str) -> Optional[str]: def get_user_uuid(self, realm: str, username: str) -> Optional[str]:
resp = httpx.get( resp = httpx.get(
self._admin_url(f"{realm}/users"), self._admin_url(f"{realm}/users"),
params={"username": username, "exact": "true"}, params={"username": username, "exact": "true"},
headers=self._headers(), headers=self._headers(),
timeout=TIMEOUT, timeout=TIMEOUT,
verify=False,
) )
resp.raise_for_status() resp.raise_for_status()
users = resp.json() users = resp.json()
return users[0]["id"] if users else None return users[0]["id"] if users else None
def create_user(self, realm: str, username: str, email: str, password: Optional[str]) -> Optional[str]: def create_user(
self,
realm: str,
username: str,
email: str,
password: Optional[str],
) -> Optional[str]:
payload = { payload = {
"username": username, "username": username,
"email": email, "email": email,
@@ -82,14 +126,121 @@ class KeycloakClient:
"emailVerified": True, "emailVerified": True,
} }
if password: if password:
payload["credentials"] = [{"type": "password", "value": password, "temporary": True}] payload["credentials"] = [
{"type": "password", "value": password, "temporary": True}
]
resp = httpx.post( resp = httpx.post(
self._admin_url(f"{realm}/users"), self._admin_url(f"{realm}/users"),
json=payload, json=payload,
headers=self._headers(), headers=self._headers(),
timeout=TIMEOUT, timeout=TIMEOUT,
verify=False,
) )
if resp.status_code == 201: if resp.status_code == 201:
location = resp.headers.get("Location", "") location = resp.headers.get("Location", "")
return location.rstrip("/").split("/")[-1] return location.rstrip("/").split("/")[-1]
return None return None
def create_public_client(self, realm: str, client_id: str) -> str:
"""建立 Public Client。回傳 'exists' / 'created' / 'failed'"""
resp = httpx.get(
self._admin_url(f"{realm}/clients"),
params={"clientId": client_id},
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
resp.raise_for_status()
if resp.json():
return "exists"
payload = {
"clientId": client_id,
"name": client_id,
"enabled": True,
"publicClient": True,
"standardFlowEnabled": True,
"directAccessGrantsEnabled": False,
"redirectUris": ["*"],
"webOrigins": ["*"],
}
resp = httpx.post(
self._admin_url(f"{realm}/clients"),
json=payload,
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
return "created" if resp.status_code in (201, 204) else "failed"
def create_confidential_client(self, realm: str, client_id: str, redirect_uris: list[str]) -> str:
"""建立 Confidential Client用於 NC OIDC。回傳 'exists' / 'created' / 'failed'"""
resp = httpx.get(
self._admin_url(f"{realm}/clients"),
params={"clientId": client_id},
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
resp.raise_for_status()
if resp.json():
return "exists"
origin = redirect_uris[0].rstrip("/*") if redirect_uris else "*"
payload = {
"clientId": client_id,
"name": client_id,
"enabled": True,
"publicClient": False,
"standardFlowEnabled": True,
"directAccessGrantsEnabled": False,
"redirectUris": redirect_uris,
"webOrigins": [origin],
"protocol": "openid-connect",
}
resp = httpx.post(
self._admin_url(f"{realm}/clients"),
json=payload,
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
return "created" if resp.status_code in (201, 204) else "failed"
def get_client_secret(self, realm: str, client_id: str) -> Optional[str]:
"""取得 Confidential Client 的 Secret"""
resp = httpx.get(
self._admin_url(f"{realm}/clients"),
params={"clientId": client_id},
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
resp.raise_for_status()
clients = resp.json()
if not clients:
return None
client_uuid = clients[0]["id"]
resp = httpx.get(
self._admin_url(f"{realm}/clients/{client_uuid}/client-secret"),
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
if resp.status_code == 200:
return resp.json().get("value")
return None
def get_keycloak_client() -> KeycloakClient:
"""Factory: reads credentials from system settings DB."""
from app.core.database import SessionLocal
from app.models.settings import SystemSettings
db = SessionLocal()
try:
s = db.query(SystemSettings).first()
if s:
return KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
finally:
db.close()
# Fallback to defaults
return KeycloakClient("https://auth.lab.taipei", "admin", "")

View File

@@ -1,24 +1,36 @@
""" """
MailClient — 呼叫 Docker Mailserver Admin API (http://10.1.0.254:8080) MailClient — 透過 SSH + docker exec mailserver 管理 docker-mailserver。
管理 mail domain 和 mailbox 的建立/查詢。 domain 在 docker-mailserver 中是隱式的(由 mailbox 決定),
建立 domain 前必須驗證 MX DNS 設定(對 active 租戶) 所以 domain_exists 檢查是否有任何 @domain 的 mailbox
""" """
import logging import logging
from typing import Optional from typing import Optional
import httpx
import dns.resolver import dns.resolver
from app.core.config import settings from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TIMEOUT = 10.0 MAILSERVER_CONTAINER = "mailserver"
class MailClient: class MailClient:
def __init__(self):
self._base = settings.MAIL_ADMIN_API_URL.rstrip("/") def _ssh_exec(self, cmd: str) -> tuple[int, str]:
self._headers = {"X-API-Key": settings.MAIL_ADMIN_API_KEY} """SSH 到 10.1.0.254 執行指令,回傳 (exit_code, stdout)"""
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
settings.DOCKER_SSH_HOST,
username=settings.DOCKER_SSH_USER,
timeout=15,
)
_, stdout, _ = client.exec_command(cmd)
output = stdout.read().decode().strip()
exit_code = stdout.channel.recv_exit_status()
client.close()
return exit_code, output
def check_mx_dns(self, domain: str) -> bool: def check_mx_dns(self, domain: str) -> bool:
"""驗證 domain 的 MX record 是否指向正確的 mail server""" """驗證 domain 的 MX record 是否指向正確的 mail server"""
@@ -33,49 +45,65 @@ class MailClient:
return False return False
def domain_exists(self, domain: str) -> bool: def domain_exists(self, domain: str) -> bool:
"""檢查 mailserver 是否有任何 @domain 的 mailboxdocker-mailserver 的 domain 由 mailbox 決定)"""
try: try:
resp = httpx.get( code, output = self._ssh_exec(
f"{self._base}/api/v1/domains/{domain}", f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '@{domain}' && echo yes || echo no"
headers=self._headers,
timeout=TIMEOUT,
) )
return resp.status_code == 200 return output.strip() == "yes"
except Exception: except Exception as e:
logger.warning(f"domain_exists({domain}) SSH failed: {e}")
return False return False
def create_domain(self, domain: str) -> bool: def create_domain(self, domain: str) -> bool:
"""
docker-mailserver 不需要顯式建立 domainmailbox 新增時自動處理)。
新增一個 postmaster@ 系統帳號來確保 domain 被識別。
"""
try: try:
resp = httpx.post( import secrets
f"{self._base}/api/v1/domains", passwd = secrets.token_urlsafe(16)
json={"domain": domain}, code, output = self._ssh_exec(
headers=self._headers, f"docker exec {MAILSERVER_CONTAINER} setup email add postmaster@{domain} {passwd} 2>&1"
timeout=TIMEOUT,
) )
return resp.status_code in (200, 201, 204) if code == 0:
logger.info(f"create_domain({domain}): postmaster account created")
return True
# 若帳號已存在視為成功
if "already exists" in output.lower():
return True
logger.error(f"create_domain({domain}) failed (exit {code}): {output}")
return False
except Exception as e: except Exception as e:
logger.error(f"create_domain({domain}) failed: {e}") logger.error(f"create_domain({domain}) SSH failed: {e}")
return False return False
def mailbox_exists(self, email: str) -> bool: def mailbox_exists(self, email: str) -> bool:
try: try:
resp = httpx.get( code, output = self._ssh_exec(
f"{self._base}/api/v1/mailboxes/{email}", f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '{email}' && echo yes || echo no"
headers=self._headers,
timeout=TIMEOUT,
) )
return resp.status_code == 200 return output.strip() == "yes"
except Exception: except Exception as e:
logger.warning(f"mailbox_exists({email}) SSH failed: {e}")
return False return False
def create_mailbox(self, email: str, password: Optional[str], quota_gb: int = 20) -> bool: def create_mailbox(self, email: str, password: Optional[str], quota_gb: int = 20) -> bool:
try: try:
resp = httpx.post( import secrets
f"{self._base}/api/v1/mailboxes", passwd = password or secrets.token_urlsafe(16)
json={"email": email, "password": password or "", "quota": quota_gb}, quota_mb = quota_gb * 1024
headers=self._headers, code, output = self._ssh_exec(
timeout=TIMEOUT, f"docker exec {MAILSERVER_CONTAINER} setup email add {email} {passwd} 2>&1"
) )
return resp.status_code in (200, 201, 204) if code != 0 and "already exists" not in output.lower():
logger.error(f"create_mailbox({email}) failed (exit {code}): {output}")
return False
# 設定配額
self._ssh_exec(
f"docker exec {MAILSERVER_CONTAINER} setup quota set {email} {quota_mb}M 2>/dev/null"
)
return True
except Exception as e: except Exception as e:
logger.error(f"create_mailbox({email}) failed: {e}") logger.error(f"create_mailbox({email}) SSH failed: {e}")
return False return False

View File

@@ -47,6 +47,20 @@ class NextcloudClient:
logger.error(f"NC create_user({username}) failed: {e}") logger.error(f"NC create_user({username}) failed: {e}")
return False return False
def set_user_quota(self, username: str, quota_gb: int) -> bool:
try:
resp = httpx.put(
f"{self._base}/ocs/v1.php/cloud/users/{username}",
auth=self._auth,
headers=OCS_HEADERS,
data={"key": "quota", "value": f"{quota_gb}GB"},
timeout=TIMEOUT,
)
return resp.status_code == 200
except Exception as e:
logger.error(f"NC set_user_quota({username}) failed: {e}")
return False
def get_user_quota_used_gb(self, username: str) -> Optional[float]: def get_user_quota_used_gb(self, username: str) -> Optional[float]:
try: try:
resp = httpx.get( resp = httpx.get(
@@ -57,11 +71,57 @@ class NextcloudClient:
) )
if resp.status_code != 200: if resp.status_code != 200:
return None return None
used_bytes = resp.json().get("ocs", {}).get("data", {}).get("quota", {}).get("used", 0) quota = resp.json().get("ocs", {}).get("data", {}).get("quota", {})
if not isinstance(quota, dict):
return 0.0
used_bytes = quota.get("used", 0) or 0
return round(used_bytes / 1073741824, 4) return round(used_bytes / 1073741824, 4)
except Exception: except Exception:
return None return None
def subscribe_calendar(
self,
username: str,
cal_slug: str,
display_name: str,
ics_url: str,
color: str = "#e9322d",
) -> bool:
"""
為使用者建立訂閱行事曆CalDAV MKCALENDAR with calendarserver source
已存在(405)視為成功201=新建成功。
"""
try:
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<x1:mkcalendar xmlns:x1="urn:ietf:params:xml:ns:caldav">\n'
' <x0:set xmlns:x0="DAV:">\n'
' <x0:prop>\n'
f' <x0:displayname>{display_name}</x0:displayname>\n'
f' <x2:calendar-color xmlns:x2="http://apple.com/ns/ical/">{color}</x2:calendar-color>\n'
' <x3:source xmlns:x3="http://calendarserver.org/ns/">\n'
f' <x0:href>{ics_url}</x0:href>\n'
' </x3:source>\n'
' </x0:prop>\n'
' </x0:set>\n'
'</x1:mkcalendar>'
)
url = f"{self._base}/remote.php/dav/calendars/{username}/{cal_slug}/"
resp = httpx.request(
"MKCALENDAR",
url,
auth=self._auth,
content=body.encode("utf-8"),
headers={"Content-Type": "application/xml; charset=utf-8"},
timeout=TIMEOUT,
verify=False,
)
# 201=建立成功, 405=已存在(Method Not Allowed on existing resource)
return resp.status_code in (201, 405)
except Exception as e:
logger.error(f"NC subscribe_calendar({username}, {cal_slug}) failed: {e}")
return False
def get_total_quota_used_gb(self) -> Optional[float]: def get_total_quota_used_gb(self) -> Optional[float]:
"""Sum all users' quota usage""" """Sum all users' quota usage"""
try: try:

View File

@@ -17,28 +17,33 @@ def dispatch_schedule(schedule_id: int, log_id: int = None, db: Session = None):
When called from manual API, creates its own session and log. When called from manual API, creates its own session and log.
""" """
own_db = db is None own_db = db is None
own_log = False
log_obj = None
if own_db: if own_db:
db = SessionLocal() db = SessionLocal()
if log_id is None: if log_id is None:
from datetime import datetime from app.core.utils import now_tw
from app.models.schedule import ScheduleLog, Schedule from app.models.schedule import ScheduleLog, Schedule
schedule = db.get(Schedule, schedule_id) schedule = db.get(Schedule, schedule_id)
if not schedule: if not schedule:
if own_db: if own_db:
db.close() db.close()
return return
log = ScheduleLog( log_obj = ScheduleLog(
schedule_id=schedule_id, schedule_id=schedule_id,
schedule_name=schedule.name, schedule_name=schedule.name,
started_at=datetime.utcnow(), started_at=now_tw(),
status="running", status="running",
) )
db.add(log) db.add(log_obj)
db.commit() db.commit()
db.refresh(log) db.refresh(log_obj)
log_id = log.id log_id = log_obj.id
own_log = True
final_status = "error"
try: try:
if schedule_id == 1: if schedule_id == 1:
from app.services.scheduler.schedule_tenant import run_tenant_check from app.services.scheduler.schedule_tenant import run_tenant_check
@@ -51,9 +56,16 @@ def dispatch_schedule(schedule_id: int, log_id: int = None, db: Session = None):
run_system_status(log_id, db) run_system_status(log_id, db)
else: else:
logger.warning(f"Unknown schedule_id: {schedule_id}") logger.warning(f"Unknown schedule_id: {schedule_id}")
final_status = "ok"
except Exception as e: except Exception as e:
logger.exception(f"dispatch_schedule({schedule_id}) error: {e}") logger.exception(f"dispatch_schedule({schedule_id}) error: {e}")
raise raise
finally: finally:
# When called from manual trigger (own_log), finalize the log entry
if own_log and log_obj is not None:
from app.core.utils import now_tw
log_obj.ended_at = now_tw()
log_obj.status = final_status
db.commit()
if own_db: if own_db:
db.close() db.close()

View File

@@ -4,6 +4,7 @@ Schedule 2 — 帳號檢查(每 3 分鐘)
""" """
import logging import logging
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.account import Account from app.models.account import Account
@@ -13,16 +14,17 @@ logger = logging.getLogger(__name__)
def run_account_check(schedule_log_id: int, db: Session): def run_account_check(schedule_log_id: int, db: Session):
from app.services.keycloak_client import KeycloakClient
from app.services.mail_client import MailClient from app.services.mail_client import MailClient
from app.services.nextcloud_client import NextcloudClient from app.services.nextcloud_client import NextcloudClient
from app.services.keycloak_client import get_keycloak_client
accounts = ( accounts = (
db.query(Account) db.query(Account)
.filter(Account.is_active == True) .filter(Account.is_active == True)
.all() .all()
) )
kc = KeycloakClient() kc = get_keycloak_client()
mail = MailClient() mail = MailClient()
for account in accounts: for account in accounts:
@@ -32,7 +34,7 @@ def run_account_check(schedule_log_id: int, db: Session):
schedule_log_id=schedule_log_id, schedule_log_id=schedule_log_id,
account_id=account.id, account_id=account.id,
sso_account=account.sso_account, sso_account=account.sso_account,
recorded_at=datetime.utcnow(), recorded_at=now_tw(),
) )
fail_reasons = [] fail_reasons = []
@@ -45,15 +47,19 @@ def run_account_check(schedule_log_id: int, db: Session):
if not account.sso_uuid: if not account.sso_uuid:
account.sso_uuid = sso_uuid account.sso_uuid = sso_uuid
else: else:
sso_uuid = kc.create_user(realm, account.sso_account, account.email, account.default_password) kc_email = account.notification_email or account.email
sso_uuid = kc.create_user(realm, account.sso_account, kc_email, account.default_password)
result.sso_result = sso_uuid is not None result.sso_result = sso_uuid is not None
result.sso_uuid = sso_uuid result.sso_uuid = sso_uuid
if sso_uuid and not account.sso_uuid: if sso_uuid and not account.sso_uuid:
account.sso_uuid = sso_uuid account.sso_uuid = sso_uuid
result.sso_done_at = datetime.utcnow() # 新使用者:寄送歡迎信(含設定密碼連結)
if account.notification_email:
kc.send_welcome_email(realm, sso_uuid)
result.sso_done_at = now_tw()
except Exception as e: except Exception as e:
result.sso_result = False result.sso_result = False
result.sso_done_at = datetime.utcnow() result.sso_done_at = now_tw()
fail_reasons.append(f"sso: {e}") fail_reasons.append(f"sso: {e}")
# [2] Mailbox check (skip if mail domain not ready) # [2] Mailbox check (skip if mail domain not ready)
@@ -65,30 +71,113 @@ def run_account_check(schedule_log_id: int, db: Session):
else: else:
created = mail.create_mailbox(email, account.default_password, account.quota_limit) created = mail.create_mailbox(email, account.default_password, account.quota_limit)
result.mailbox_result = created result.mailbox_result = created
result.mailbox_done_at = datetime.utcnow() result.mailbox_done_at = now_tw()
except Exception as e: except Exception as e:
result.mailbox_result = False result.mailbox_result = False
result.mailbox_done_at = datetime.utcnow() result.mailbox_done_at = now_tw()
fail_reasons.append(f"mailbox: {e}") fail_reasons.append(f"mailbox: {e}")
# [3] NC user check # [3] NC user check
try: try:
nc = NextcloudClient(tenant.domain) from app.core.config import settings as _cfg
nc = NextcloudClient(tenant.domain, _cfg.NC_ADMIN_USER, _cfg.NC_ADMIN_PASSWORD)
nc_exists = nc.user_exists(account.sso_account) nc_exists = nc.user_exists(account.sso_account)
if nc_exists: if nc_exists:
result.nc_result = True result.nc_result = True
else: else:
created = nc.create_user(account.sso_account, account.default_password, account.quota_limit) created = nc.create_user(account.sso_account, account.default_password, account.quota_limit)
result.nc_result = created result.nc_result = created
result.nc_done_at = datetime.utcnow() # 確保 quota 設定正確(無論新建或已存在)
if result.nc_result and account.quota_limit:
nc.set_user_quota(account.sso_account, account.quota_limit)
result.nc_done_at = now_tw()
except Exception as e: except Exception as e:
result.nc_result = False result.nc_result = False
result.nc_done_at = datetime.utcnow() result.nc_done_at = now_tw()
fail_reasons.append(f"nc: {e}") fail_reasons.append(f"nc: {e}")
# [4] Quota # [3.5] NC 台灣國定假日行事曆訂閱
# 前置條件NC 帳號已存在MKCALENDAR 為 idempotent已存在回傳 405 視為成功)
TW_HOLIDAYS_ICS_URL = "https://www.officeholidays.com/ics-clean/taiwan"
if result.nc_result:
try:
cal_ok = nc.subscribe_calendar(
account.sso_account,
"taiwan-public-holidays",
"台灣國定假日",
TW_HOLIDAYS_ICS_URL,
"#e9322d",
)
if cal_ok:
logger.info(f"NC calendar subscribed [{account.sso_account}]: taiwan-public-holidays")
else:
logger.warning(f"NC calendar subscribe failed [{account.sso_account}]")
except Exception as e:
logger.warning(f"NC calendar subscribe error [{account.sso_account}]: {e}")
# [4] NC Mail 帳號設定
# 前置條件NC 帳號已建立 + mailbox 已建立
try: try:
nc = NextcloudClient(tenant.domain) if result.nc_result and result.mailbox_result:
import paramiko
from app.core.config import settings as _cfg
is_active = tenant.status == "active"
nc_container = f"nc-{tenant.code}" if is_active else f"nc-{tenant.code}-test"
email = account.email or f"{account.sso_account}@{tenant.domain}"
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(_cfg.DOCKER_SSH_HOST, username=_cfg.DOCKER_SSH_USER, timeout=15)
def _ssh_run(cmd, timeout=60):
"""執行 SSH 指令並回傳輸出,超時返回空字串"""
_, stdout, _ = ssh.exec_command(cmd)
stdout.channel.settimeout(timeout)
try:
out = stdout.read().decode().strip()
except Exception:
out = ""
return out
# 確認是否已設定occ mail:account:export 回傳帳號列表)
export_out = _ssh_run(
f"docker exec -u www-data {nc_container} "
f"php /var/www/html/occ mail:account:export {account.sso_account} 2>/dev/null"
)
already_set = len(export_out) > 10 and "imap" in export_out.lower()
if already_set:
result.nc_mail_result = True
else:
display = account.legal_name or account.sso_account
create_cmd = (
f"docker exec -u www-data {nc_container} "
f"php /var/www/html/occ mail:account:create "
f"'{account.sso_account}' '{display}' '{email}' "
f"10.1.0.254 143 none '{email}' '{account.default_password}' "
f"10.1.0.254 587 none '{email}' '{account.default_password}' 2>&1"
)
out_text = _ssh_run(create_cmd)
logger.info(f"NC mail:account:create [{account.sso_account}]: {out_text}")
result.nc_mail_result = (
"error" not in out_text.lower() and "exception" not in out_text.lower()
)
ssh.close()
else:
result.nc_mail_result = False
fail_reasons.append(
f"nc_mail: skipped (nc={result.nc_result}, mailbox={result.mailbox_result})"
)
result.nc_mail_done_at = now_tw()
except Exception as e:
result.nc_mail_result = False
result.nc_mail_done_at = now_tw()
fail_reasons.append(f"nc_mail: {e}")
# [5] Quota
try:
nc = NextcloudClient(tenant.domain, _cfg.NC_ADMIN_USER, _cfg.NC_ADMIN_PASSWORD)
result.quota_usage = nc.get_user_quota_used_gb(account.sso_account) result.quota_usage = nc.get_user_quota_used_gb(account.sso_account)
except Exception as e: except Exception as e:
logger.warning(f"Quota check failed for {account.account_code}: {e}") logger.warning(f"Quota check failed for {account.account_code}: {e}")

View File

@@ -5,6 +5,7 @@ Part B: 伺服器 ping 檢查
""" """
import logging import logging
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.server import SystemStatusLog, ServerStatusLog, Server from app.models.server import SystemStatusLog, ServerStatusLog, Server
@@ -64,7 +65,7 @@ def run_system_status(schedule_log_id: int, db: Session):
service_desc=svc["service_desc"], service_desc=svc["service_desc"],
result=result, result=result,
fail_reason=fail_reason, fail_reason=fail_reason,
recorded_at=datetime.utcnow(), recorded_at=now_tw(),
)) ))
# Part B: Server ping # Part B: Server ping
@@ -87,7 +88,7 @@ def run_system_status(schedule_log_id: int, db: Session):
result=result, result=result,
response_time=response_time, response_time=response_time,
fail_reason=fail_reason, fail_reason=fail_reason,
recorded_at=datetime.utcnow(), recorded_at=now_tw(),
)) ))
db.commit() db.commit()

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ Watchdog: APScheduler BackgroundScheduler每 3 分鐘掃描 schedules 表。
""" """
import logging import logging
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from croniter import croniter from croniter import croniter
from sqlalchemy import update from sqlalchemy import update
@@ -24,7 +25,7 @@ def _watchdog_tick():
db.query(Schedule) db.query(Schedule)
.filter( .filter(
Schedule.status == "Waiting", Schedule.status == "Waiting",
Schedule.next_run_at <= datetime.utcnow(), Schedule.next_run_at <= now_tw(),
) )
.all() .all()
) )
@@ -44,7 +45,7 @@ def _watchdog_tick():
log = ScheduleLog( log = ScheduleLog(
schedule_id=schedule.id, schedule_id=schedule.id,
schedule_name=schedule.name, schedule_name=schedule.name,
started_at=datetime.utcnow(), started_at=now_tw(),
status="running", status="running",
) )
db.add(log) db.add(log)
@@ -60,12 +61,12 @@ def _watchdog_tick():
final_status = "error" final_status = "error"
# Update log # Update log
log.ended_at = datetime.utcnow() log.ended_at = now_tw()
log.status = final_status log.status = final_status
# Recalculate next_run_at # Recalculate next_run_at (5-field cron: 分 時 日 月 週)
try: try:
cron = croniter(schedule.cron_timer, datetime.utcnow()) cron = croniter(schedule.cron_timer, now_tw())
next_run = cron.get_next(datetime) next_run = cron.get_next(datetime)
except Exception: except Exception:
next_run = None next_run = None
@@ -76,7 +77,7 @@ def _watchdog_tick():
.where(Schedule.id == schedule.id) .where(Schedule.id == schedule.id)
.values( .values(
status="Waiting", status="Waiting",
last_run_at=datetime.utcnow(), last_run_at=now_tw(),
next_run_at=next_run, next_run_at=next_run,
last_status=final_status, last_status=final_status,
) )

View File

@@ -1,9 +1,11 @@
"""Initial data seed: schedules + servers""" """Initial data seed: schedules + servers + system settings"""
from datetime import datetime from datetime import datetime
from app.core.utils import now_tw, configure_timezone
from croniter import croniter from croniter import croniter
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.schedule import Schedule from app.models.schedule import Schedule
from app.models.server import Server from app.models.server import Server
from app.models.settings import SystemSettings
INITIAL_SCHEDULES = [ INITIAL_SCHEDULES = [
@@ -26,7 +28,7 @@ INITIAL_SERVERS = [
def _calc_next_run(cron_timer: str) -> datetime: def _calc_next_run(cron_timer: str) -> datetime:
# croniter: six-field cron (sec min hour day month weekday) # croniter: six-field cron (sec min hour day month weekday)
cron = croniter(cron_timer, datetime.utcnow()) cron = croniter(cron_timer, now_tw())
return cron.get_next(datetime) return cron.get_next(datetime)
@@ -46,4 +48,13 @@ def seed_initial_data(db: Session) -> None:
if not db.get(Server, sv["id"]): if not db.get(Server, sv["id"]):
db.add(Server(**sv)) db.add(Server(**sv))
# Seed default system settings (id=1)
if not db.get(SystemSettings, 1):
db.add(SystemSettings(id=1))
db.commit() db.commit()
# Apply timezone from settings
s = db.get(SystemSettings, 1)
if s:
configure_timezone(s.timezone)

249
docker/radicale/README.md Normal file
View File

@@ -0,0 +1,249 @@
# Radicale CalDAV/CardDAV Server
Virtual MIS 日曆與聯絡人服務後端。
## 服務資訊
- **服務名稱**: Radicale CalDAV/CardDAV Server
- **容器名稱**: vmis-radicale
- **內部埠號**: 5232
- **外部埠號**: 5232 (開發環境)
- **認證方式**: HTTP Header (X-Remote-User) - 由 Keycloak OAuth2 整合
- **資料儲存**: File System (/data/collections)
## 功能
- ✅ CalDAV - 日曆同步 (RFC 4791)
- ✅ CardDAV - 聯絡人同步 (RFC 6352)
- ✅ HTTP Header 認證
- ✅ 多使用者支援
- ✅ Web 管理介面
## 目錄結構
```
radicale/
├── config/
│ └── config # Radicale 配置檔
├── data/ # 資料目錄(自動建立)
│ └── collections/ # 日曆與聯絡人資料
├── docker-compose.yml # Docker Compose 配置
└── README.md # 本文件
```
## 部署步驟
### 1. 建立資料目錄
```bash
mkdir -p data/collections
```
### 2. 設定權限
```bash
# 確保資料目錄權限正確UID=1000, GID=1000
sudo chown -R 1000:1000 data/
chmod -R 750 data/
```
### 3. 啟動服務
```bash
# 啟動
docker-compose up -d
# 查看日誌
docker-compose logs -f
# 停止
docker-compose down
```
### 4. 驗證服務
```bash
# 檢查服務狀態
docker-compose ps
# 測試連線
curl -I http://localhost:5232
# 預期回應: HTTP/1.1 200 OK
```
## 整合架構
### Virtual MIS 整合
```
Virtual MIS Backend
↓ caldav_service.py
Radicale (CalDAV/CardDAV)
↓ File System
/data/collections/
├── {username}/
│ ├── {calendar-uuid}/ # 日曆
│ └── {addressbook-uuid}/ # 通訊錄
```
### 認證流程
```
1. 使用者登入 Keycloak SSO
2. Virtual MIS Backend 取得 username
3. Backend 使用 X-Remote-User Header 呼叫 Radicale
4. Radicale 信任 Header直接存取對應使用者資料
```
## 資料結構
### 日曆資料 (CalDAV)
```
/data/collections/{username}/{calendar-uuid}/
├── event-1.ics
├── event-2.ics
└── ...
```
### 聯絡人資料 (CardDAV)
```
/data/collections/{username}/{addressbook-uuid}/
├── contact-1.vcf
├── contact-2.vcf
└── ...
```
## 客戶端配置
### iOS / macOS
**日曆 (CalDAV)**:
```
伺服器: 10.1.0.254:5232 (開發環境)
使用者名稱: {Keycloak username}
密碼: (由 Backend 處理)
使用 SSL: 否 (開發環境) / 是 (正式環境)
```
**聯絡人 (CardDAV)**:
```
伺服器: 10.1.0.254:5232
使用者名稱: {Keycloak username}
密碼: (由 Backend 處理)
使用 SSL: 否 (開發環境) / 是 (正式環境)
```
### Android (DAVx⁵)
```
基礎 URL: http://10.1.0.254:5232/
使用者名稱: {Keycloak username}
密碼: (由 Backend 處理)
```
### Thunderbird
**日曆**:
```
位置: http://10.1.0.254:5232/{username}/{calendar-uuid}/
```
**聯絡人** (需要 CardBook 擴充套件):
```
URL: http://10.1.0.254:5232/{username}/{addressbook-uuid}/
```
## 監控與維護
### 查看日誌
```bash
# 即時日誌
docker-compose logs -f radicale
# 最近 100 行
docker-compose logs --tail=100 radicale
```
### 備份策略
```bash
#!/bin/bash
# 備份腳本
BACKUP_DIR="/backups/radicale"
DATE=$(date +%Y%m%d_%H%M%S)
# 備份資料
tar -czf ${BACKUP_DIR}/radicale_data_${DATE}.tar.gz \
./data/
# 保留最近 30 天
find ${BACKUP_DIR} -name "radicale_data_*.tar.gz" -mtime +30 -delete
```
### 效能監控
```bash
# 查看儲存空間
du -sh data/
# 查看容器資源使用
docker stats vmis-radicale
```
## 故障排除
### 問題 1: 無法連線
**檢查**:
```bash
# 檢查容器狀態
docker-compose ps
# 檢查埠號占用
netstat -tulpn | grep 5232
# 檢查防火牆
sudo ufw status
```
### 問題 2: 認證失敗
**檢查**:
- X-Remote-User Header 是否正確傳遞
- Radicale 配置檔中 auth.type 是否為 http_x_remote_user
- Backend 呼叫時是否正確設定 Header
### 問題 3: 資料無法寫入
**檢查**:
```bash
# 檢查目錄權限
ls -la data/
# 修正權限
sudo chown -R 1000:1000 data/
chmod -R 750 data/
```
## 安全建議
1. **認證**: 使用 Keycloak SSO 統一認證
2. **網路隔離**: 僅允許 Backend 存取 Radicale
3. **HTTPS**: 正式環境必須使用 HTTPS
4. **備份**: 定期備份 data/ 目錄
5. **權限**: 嚴格控制檔案系統權限
## 參考文件
- [Radicale 官方文件](https://radicale.org/v3.html)
- [CalDAV 協議 (RFC 4791)](https://tools.ietf.org/html/rfc4791)
- [CardDAV 協議 (RFC 6352)](https://tools.ietf.org/html/rfc6352)
## 更新記錄
- **2026-03-02**: 建立 Radicale Docker Compose 配置
- **2026-03-02**: 整合 Virtual MIS Calendar & Contacts

View File

@@ -0,0 +1,34 @@
[server]
# 監聽所有介面
hosts = 0.0.0.0:5232
# 最大連線數
max_connections = 100
# 逾時設定
timeout = 60
[auth]
# 使用 HTTP Header 認證 (由 Keycloak OAuth2 Proxy 提供)
type = http_x_remote_user
[storage]
# 檔案系統儲存
type = multifilesystem
# 資料目錄
filesystem_folder = /data/collections
# Hook for changes (optional - for git versioning)
# hook = git add -A && git commit -m "Changes by %(user)s"
[web]
# 啟用 Web 介面
type = internal
[logging]
# 日誌等級
level = info
# 日誌格式
# debug 模式可設為 debug

View File

@@ -0,0 +1,44 @@
version: '3.8'
services:
radicale:
image: tomsquest/docker-radicale:latest
container_name: vmis-radicale
restart: unless-stopped
# 環境變數
environment:
# 設定時區
- TZ=Asia/Taipei
# UID/GID (與 porsche 使用者相同)
- UID=1000
- GID=1000
# 資料卷
volumes:
# 配置檔
- ./config:/config:ro
# 資料目錄
- ./data:/data
# 埠號
ports:
- "5232:5232"
# 網路
networks:
- vmis-network
# 健康檢查
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5232/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
vmis-network:
external: true

View File

@@ -0,0 +1,254 @@
# WebMail Keycloak SSO 整合指南
本指南說明如何將 Roundcube WebMail 與 Keycloak SSO 整合。
## 前置條件
已完成:
- ✅ Keycloak Client 已建立
- ✅ Client ID: `webmail`
- ✅ Client Secret: `CDUcB68SZUEZ2zmiQV4cz22czjw6Sn1q`
- ✅ Realm: `vmis-admin`
## 步驟 1: 連接到 WebMail 主機
```bash
ssh 10.1.0.254
```
## 步驟 2: 查看 Roundcube 容器
```bash
docker ps | grep -i roundcube
```
記錄容器名稱 (例如: `roundcube``webmail`)
## 步驟 3: 查看 Roundcube 配置掛載路徑
```bash
docker inspect <容器名稱> | grep -A 5 "Mounts"
```
找出配置檔案的掛載路徑 (通常是 `/var/www/html/config` 或類似路徑)
## 步驟 4: 安裝 OAuth2 插件
### 方法 A: 如果 Roundcube 使用官方 Docker 映像
1. 進入容器:
```bash
docker exec -it <容器名稱> bash
```
2. 使用 Composer 安裝 OAuth2 插件:
```bash
cd /var/www/html
composer require roundcube/oauth2
```
3. 啟用插件 (編輯 config/config.inc.php):
```bash
vi config/config.inc.php
```
找到 `$config['plugins']` 行,加入 `'oauth2'`:
```php
$config['plugins'] = array(
'oauth2',
// ... 其他插件
);
```
### 方法 B: 如果無法使用 Composer
手動下載並安裝插件:
```bash
cd /var/www/html/plugins
git clone https://github.com/roundcube/roundcubemail-oauth2.git oauth2
```
## 步驟 5: 配置 OAuth2 插件
建立或編輯 `config/oauth2.inc.php`:
```bash
vi /var/www/html/config/oauth2.inc.php
```
加入以下內容:
```php
<?php
/**
* Roundcube OAuth2 Configuration
* Keycloak SSO Integration for WebMail
*/
$config['oauth2'] = array(
'provider' => 'generic',
'client_id' => 'webmail',
'client_secret' => 'CDUcB68SZUEZ2zmiQV4cz22czjw6Sn1q',
'auth_uri' => 'https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/auth',
'token_uri' => 'https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/token',
'identity_uri' => 'https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/userinfo',
'redirect_uri' => 'https://webmail.porscheworld.tw/index.php/login/oauth',
'scope' => 'openid email profile',
'login_redirect' => true,
'username_field' => 'email',
'identity_fields' => array(
'email' => 'email',
'name' => 'name',
'username' => 'preferred_username'
),
);
// 自動登入 (可選)
$config['oauth2_auto_login'] = true;
// 允許傳統登入 (開發階段建議保留)
$config['oauth2_allow_traditional_login'] = true;
```
## 步驟 6: 修改 Roundcube 主配置
編輯 `config/config.inc.php`:
```bash
vi /var/www/html/config/config.inc.php
```
確保以下設定:
```php
// 啟用插件
$config['plugins'] = array(
'oauth2',
// ... 其他插件
);
// IMAP 設定 (根據實際郵件伺服器調整)
$config['default_host'] = 'ssl://10.1.0.254';
$config['default_port'] = 993;
// SMTP 設定
$config['smtp_server'] = 'tls://10.1.0.254';
$config['smtp_port'] = 587;
// 使用者名稱網域 (根據 Keycloak 使用者的 email 設定)
$config['username_domain'] = array(
'porscheworld.tw' => 'porscheworld.tw',
'lab.taipei' => 'lab.taipei',
'ease.taipei' => 'ease.taipei',
);
// 自動完成郵件地址
$config['mail_domain'] = 'lab.taipei';
```
## 步驟 7: 設定郵件地址對應規則
Keycloak 使用者的 `email` 屬性應該對應到郵件伺服器的帳號。
### 確保 Keycloak 使用者有正確的 email
1. 登入 Keycloak Admin Console:
```
https://auth.lab.taipei/admin
```
2. 進入 `vmis-admin` Realm
3. 檢查使用者的 Email 欄位,例如:
- Username: `sysadmin`
- Email: `admin@lab.taipei`
這個 Email 必須是郵件伺服器上實際存在的帳號。
## 步驟 8: 重啟 Roundcube 容器
```bash
docker restart <容器名稱>
```
## 步驟 9: 測試 SSO 登入
1. 清除瀏覽器 Cookie
2. 訪問 WebMail:
```
https://webmail.porscheworld.tw
```
3. 應該會自動重導向到 Keycloak 登入頁面:
```
https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/auth?...
```
4. 使用 Keycloak 帳號登入 (例如: `sysadmin`)
5. 登入成功後,應該會重導向回 WebMail 並自動登入郵件帳號
## 故障排除
### 問題 1: 無法重導向到 Keycloak
**檢查:**
- OAuth2 插件是否正確安裝
- `config/oauth2.inc.php` 是否存在且設定正確
- Roundcube 容器日誌: `docker logs <容器名稱>`
### 問題 2: 登入後顯示「Invalid credentials」
**可能原因:**
- Keycloak 使用者的 email 與郵件伺服器帳號不符
- 郵件伺服器密碼與 Keycloak 密碼不同
**解決方案:**
需要實作「密碼同步」或使用「IMAP OAuth2」:
1. **方案 A: 密碼同步** - Keycloak 密碼變更時同步到郵件伺服器
2. **方案 B: IMAP OAuth2** - 郵件伺服器支援 OAuth2 認證 (需要 Docker Mailserver 配置)
### 問題 3: 重導向 URI 不符
**錯誤訊息:**
```
Invalid redirect_uri
```
**解決方案:**
檢查 Keycloak Client 的 Valid Redirect URIs 設定:
```
https://webmail.porscheworld.tw/*
```
## 進階設定
### 整合 HR Portal 多帳號切換
如果要實現「員工可以使用 SSO 登入後,切換不同的郵件帳號」:
1. 安裝 `multi_accounts` 插件
2. 配置 API 端點連接 HR Portal
3. 從 HR Portal 取得員工授權的郵件帳號列表
詳見: [郵件系統設計文件](P:\porscheworld\2.專案設計區\3.MailSystem\郵件系統設計文件.md)
## 相關連結
- Keycloak Admin: https://auth.lab.taipei/admin
- WebMail: https://webmail.porscheworld.tw
- Roundcube OAuth2 Plugin: https://github.com/roundcube/roundcubemail-oauth2
## 完成檢查清單
- [ ] OAuth2 插件已安裝
- [ ] `config/oauth2.inc.php` 設定完成
- [ ] `config/config.inc.php` 啟用插件
- [ ] Keycloak 使用者 email 正確設定
- [ ] Roundcube 容器已重啟
- [ ] SSO 登入測試成功
- [ ] 郵件收發測試成功

View File

@@ -0,0 +1,225 @@
# Virtual MIS - 系統架構設計
**版本**: v1.0
**日期**: 2026-02-27
**狀態**: 規劃中
---
## 系統架構圖
```
┌─────────────────────────────────────────────────────────────┐
│ 客戶企業 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 員工 A │ │ 員工 B │ │ 員工 C │ │ 管理者 │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
└────────┼─────────────┼─────────────┼─────────────┼─────────┘
│ │ │ │
└─────────────┴─────────────┴─────────────┘
┌─────────▼─────────┐
│ Virtual MIS │
│ 統一入口 (SSO) │
└─────────┬─────────┘
┌─────────────────┼─────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ HR │ │ Service │ │ Billing │
│ Portal │ │ Gateway │ │ System │
└────┬────┘ └────┬────┘ └─────────┘
│ │
│ ┌──────────┼──────────┐
│ │ │ │
┌────▼────▼─┐ ┌────▼────┐ ┌──▼──────┐
│ Mail │ │Calendar │ │ Drive │
│ Service │ │Service │ │ Service │
└───────────┘ └─────────┘ └─────────┘
┌────▼────┐
│ Office │
│ Service │
└─────────┘
```
## 核心模組
### 1. 租戶開通系統 (Tenant Onboarding)
**功能**:
- 自動建立租戶資料
- 網域配置與 DNS 設定
- Keycloak Realm 建立
- 服務初始化
- 管理員帳號建立
**API 端點**:
```
POST /api/v1/tenant-onboarding
GET /api/v1/tenant-onboarding/{tenant_id}
PUT /api/v1/tenant-onboarding/{tenant_id}
DELETE /api/v1/tenant-onboarding/{tenant_id}
```
### 2. 服務整合閘道 (Service Integration Gateway)
**功能**:
- Mail System API 整合
- Calendar System API 整合
- Drive System API 整合
- Office System API 整合
- 服務健康檢查
- 統一錯誤處理
**API 端點**:
```
POST /api/v1/services/mail/create-account
POST /api/v1/services/calendar/create-calendar
POST /api/v1/services/drive/create-storage
POST /api/v1/services/office/grant-access
GET /api/v1/services/health
```
### 3. 計費管理系統 (Billing System)
**功能**:
- 訂閱方案管理
- 使用量追蹤
- 帳單生成
- 金流整合 (綠界/藍新)
- 自動續約/停權
**API 端點**:
```
POST /api/v1/billing/subscribe
GET /api/v1/billing/invoices
POST /api/v1/billing/payment
GET /api/v1/billing/usage/{tenant_id}
```
### 4. 管理後台 (Admin Portal)
**功能**:
- 租戶管理
- 服務監控儀表板
- 使用量報表
- 客戶支援工單
- 系統配置
**頁面**:
```
/admin/dashboard - 總覽儀表板
/admin/tenants - 租戶管理
/admin/services - 服務狀態
/admin/billing - 計費管理
/admin/support - 客戶支援
```
### 5. Landing Page (行銷頁面)
**功能**:
- 服務介紹
- 定價方案
- 免費試用申請
- 客戶見證
- 聯絡表單
**頁面**:
```
/ - 首頁
/pricing - 定價方案
/features - 功能介紹
/trial - 免費試用
/contact - 聯絡我們
```
## 資料庫設計
### 核心表
1. **tenants** - 租戶資料 (繼承 HR Portal)
2. **subscriptions** - 訂閱記錄
3. **invoices** - 帳單記錄
4. **service_usage** - 服務使用記錄
5. **support_tickets** - 客戶支援工單
## 技術選型
### 後端
- **語言**: Python 3.11+
- **框架**: FastAPI 0.115+
- **ORM**: SQLAlchemy 2.0+
- **驗證**: Pydantic v2
- **任務隊列**: Celery + Redis
### 前端
- **框架**: Next.js 15 (App Router)
- **UI 庫**: Tailwind CSS + shadcn/ui
- **狀態管理**: React Hooks + Context
- **表單**: React Hook Form + Zod
### 基礎設施
- **容器化**: Docker + Docker Compose
- **反向代理**: Nginx
- **SSL**: Let's Encrypt (Certbot)
- **監控**: Prometheus + Grafana
## 部署架構
```
Internet
┌───────────────┐
│ Nginx Proxy │ (SSL Termination)
└───────┬───────┘
┌───┴───┬───────┬───────┬───────┐
│ │ │ │ │
Admin Landing API HR Services
Portal Page Gateway Portal (Mail/Cal/Drive)
```
## 擴展性設計
### 水平擴展
- API Gateway 支援多實例
- 使用 Redis 作為共享快取
- 資料庫讀寫分離
### 垂直擴展
- 服務模組化設計
- 微服務架構準備
- 容器資源動態調整
## 安全性設計
1. **認證授權**:
- Keycloak SSO
- JWT Token
- RBAC 權限控制
2. **資料安全**:
- 資料加密傳輸 (TLS)
- 敏感資料加密儲存
- 定期備份
3. **網路安全**:
- CORS 配置
- Rate Limiting
- DDoS 防護
## 監控與告警
- **服務健康**: 每分鐘檢查
- **使用量追蹤**: 即時記錄
- **錯誤日誌**: 集中管理
- **性能指標**: CPU/記憶體/網路
---
**下一步**:
1. 詳細 API 規格設計
2. 資料庫 Schema 設計
3. 開發環境建置

View File

@@ -0,0 +1,213 @@
# Virtual MIS - 商業計畫
**版本**: v1.0
**日期**: 2026-02-27
---
## 市場分析
### 目標市場
**主要客群**:
1. 新創公司 (5-20人)
- 需要專業 IT 服務但沒有專職 IT 人員
- 預算有限,希望降低 IT 成本
2. 中小企業 (20-50人)
- 現有 IT 資源不足
- 需要現代化的協作工具
3. 遠距團隊
- 分散式辦公需求
- 需要統一的協作平台
### 市場規模
- 台灣中小企業數量: 約 159 萬家 (2024)
- 潛在客戶 (10-100人企業): 約 50 萬家
- 目標市場滲透率 (3年): 0.1% = 500 家
- 預估年營收 (3年): NT$ 9,000,000
## 競爭分析
### 主要競爭對手
| 服務商 | 優勢 | 劣勢 | 我們的差異化 |
|--------|------|------|-------------|
| Google Workspace | 知名度高、整合度好 | 價格較高、資料在國外 | 本地化、價格優勢 |
| Microsoft 365 | 功能完整、企業級 | 複雜度高、價格高 | 簡單易用、中小企業專注 |
| Zoho Workplace | 價格便宜 | 介面較舊、支援不佳 | 更好的 UX、在地支援 |
### 我們的優勢
1. **價格優勢**: 比 Google/Microsoft 便宜 50%
2. **在地化**: 資料存放台灣、中文支援
3. **整合性**: 一站式解決方案
4. **彈性**: 可客製化配置
5. **支援**: 在地技術支援
## 商業模式
### 訂閱制 SaaS
**定價策略**:
#### 測試期方案 (前 6 個月)
| 方案 | 人數 | 月費 | 年費 (85折) | 包含服務 |
|------|------|------|------------|---------|
| 基礎版 | 10人 | NT$ 500 | NT$ 5,100 | HR + Mail + Drive (5GB/人) |
| 標準版 | 50人 | NT$ 1,500 | NT$ 15,300 | 基礎 + Calendar + Office |
| 企業版 | 100人 | NT$ 3,000 | NT$ 30,600 | 標準 + 優先支援 + 10GB/人 |
#### 正式方案 (6 個月後)
| 方案 | 人數 | 月費 | 年費 (85折) |
|------|------|------|------------|
| 基礎版 | 10人 | NT$ 800 | NT$ 8,160 |
| 標準版 | 50人 | NT$ 2,500 | NT$ 25,500 |
| 企業版 | 100人 | NT$ 5,000 | NT$ 51,000 |
### 額外服務
- **超量使用**: NT$ 50/人/月
- **額外儲存**: NT$ 100/10GB/月
- **客製化開發**: 另報價
- **專業服務**: NT$ 2,000/小時
## 營收預測
### 第一年 (2026)
| 月份 | 付費客戶 | 平均月費 | 月營收 | 累計營收 |
|------|---------|---------|--------|---------|
| M1-2 | 0 | - | NT$ 0 | NT$ 0 |
| M3 | 3 | NT$ 1,000 | NT$ 3,000 | NT$ 3,000 |
| M4 | 5 | NT$ 1,000 | NT$ 5,000 | NT$ 8,000 |
| M5 | 8 | NT$ 1,000 | NT$ 8,000 | NT$ 16,000 |
| M6 | 12 | NT$ 1,200 | NT$ 14,400 | NT$ 30,400 |
| M7 | 15 | NT$ 1,200 | NT$ 18,000 | NT$ 48,400 |
| M8 | 20 | NT$ 1,500 | NT$ 30,000 | NT$ 78,400 |
| M9 | 25 | NT$ 1,500 | NT$ 37,500 | NT$ 115,900 |
| M10 | 30 | NT$ 1,500 | NT$ 45,000 | NT$ 160,900 |
| M11 | 35 | NT$ 1,500 | NT$ 52,500 | NT$ 213,400 |
| M12 | 40 | NT$ 1,500 | NT$ 60,000 | NT$ 273,400 |
**第一年總營收**: NT$ 273,400
### 第二年目標
- 客戶數: 100 家
- 平均月費: NT$ 2,000
- 年營收: NT$ 2,400,000
### 第三年目標
- 客戶數: 200 家
- 平均月費: NT$ 2,500
- 年營收: NT$ 6,000,000
## 成本結構
### 固定成本 (月)
- 伺服器租用: NT$ 10,000
- 網路頻寬: NT$ 5,000
- SSL 憑證: NT$ 1,000
- 備份儲存: NT$ 2,000
- **小計**: NT$ 18,000/月
### 變動成本
- 客服人力: NT$ 50/客戶/月
- 技術支援: NT$ 30/客戶/月
- 行銷推廣: 營收的 20%
### 損益平衡點
固定成本 / (平均月費 - 變動成本) = 18,000 / (1,500 - 80) ≈ **13 個客戶**
## 行銷策略
### 獲客管道
1. **內容行銷** (免費)
- 撰寫技術部落格
- SEO 優化
- 社群媒體經營
2. **口碑推薦** (低成本)
- 推薦獎勵計畫
- 客戶見證影片
- 案例研究分享
3. **付費廣告** (初期投入)
- Google Ads (關鍵字: 虛擬辦公室、企業郵件)
- Facebook Ads (目標: 中小企業主)
- LinkedIn Ads (B2B 客戶)
4. **合作夥伴**
- 會計師事務所
- 企業顧問公司
- 創業加速器
### 客戶獲取成本 (CAC)
- 目標 CAC: NT$ 3,000/客戶
- 客戶終身價值 (LTV): NT$ 36,000 (假設留存 2 年)
- LTV/CAC 比: 12:1 (健康指標)
## 風險評估
### 主要風險
1. **技術風險**
- 服務穩定性問題
- 資料安全疑慮
- **應對**: 完善測試、定期備份、安全稽核
2. **市場風險**
- 客戶獲取困難
- 競爭加劇
- **應對**: 差異化定位、優質服務
3. **財務風險**
- 初期現金流不足
- 收款困難
- **應對**: 預收年費、自動扣款
## 里程碑
### Phase 1: MVP (Week 1-6)
- [ ] 完成系統開發
- [ ] 內部測試
- [ ] 準備行銷素材
### Phase 2: Beta (Week 7-8)
- [ ] 招募 5 家測試客戶
- [ ] 收集回饋改進
- [ ] 優化使用者體驗
### Phase 3: Launch (Week 9-12)
- [ ] 正式對外發布
- [ ] 啟動行銷活動
- [ ] 目標達成 10 個付費客戶
### Phase 4: Growth (Month 4-12)
- [ ] 持續優化產品
- [ ] 擴大行銷投入
- [ ] 目標達成 40 個付費客戶
## 退場策略
1. **被收購**: 目標估值 NT$ 30,000,000 (3 年後)
2. **持續經營**: 建立穩定現金流
3. **技術授權**: 授權給大型企業
---
**下一步行動**:
1. 完成 MVP 開發
2. 準備行銷素材
3. 洽談 Beta 測試客戶

373
docs/開發規範.md Normal file
View File

@@ -0,0 +1,373 @@
# Virtual MIS - 開發規範
**版本**: v1.0
**日期**: 2026-02-27
---
## 專案結構
```
virtual-mis/
├── backend/ # 後端服務
│ ├── app/
│ │ ├── api/ # API 路由
│ │ │ ├── v1/
│ │ │ │ ├── tenant_onboarding.py
│ │ │ │ ├── service_integration.py
│ │ │ │ └── billing.py
│ │ │ └── router.py
│ │ ├── core/ # 核心配置
│ │ │ ├── config.py
│ │ │ ├── security.py
│ │ │ └── dependencies.py
│ │ ├── db/ # 資料庫
│ │ │ ├── base.py
│ │ │ └── session.py
│ │ ├── models/ # ORM 模型
│ │ ├── schemas/ # Pydantic Schema
│ │ ├── services/ # 業務邏輯
│ │ └── utils/ # 工具函數
│ ├── alembic/ # 資料庫遷移
│ ├── tests/ # 測試
│ ├── .env # 環境變數
│ ├── requirements.txt # 依賴套件
│ └── main.py # 應用入口
├── frontend/ # 前端服務
│ ├── admin-portal/ # 管理後台
│ │ ├── app/ # Next.js App Router
│ │ ├── components/ # React 元件
│ │ ├── lib/ # 工具函數
│ │ ├── public/ # 靜態資源
│ │ └── package.json
│ │
│ └── landing-page/ # 行銷頁面
│ ├── app/
│ ├── components/
│ └── package.json
└── docs/ # 文件
├── architecture/ # 架構設計
├── api-specs/ # API 規格
├── business/ # 商業計畫
└── deployment/ # 部署文件
```
## 開發環境
### 後端環境
**Python 版本**: 3.11+
**Port 配置**:
- 開發環境: `10281`
- 測試環境: `10282`
**資料庫**:
- Host: `10.1.0.20`
- Port: `5433`
- Database: `virtual_mis`
- User: `admin`
**啟動方式**:
```bash
cd D:\_Develop\porscheworld_develop\virtual-mis\backend
START_BACKEND.bat
```
### 前端環境
**Node.js 版本**: 20+
**Port 配置**:
- Admin Portal: `10280`
- Landing Page: `10290`
**啟動方式**:
```bash
cd D:\_Develop\porscheworld_develop\virtual-mis\frontend\admin-portal
START_FRONTEND.bat
```
## 編碼規範
### Python (後端)
1. **命名規範**:
- 檔案名稱: `snake_case.py`
- 類別名稱: `PascalCase`
- 函數名稱: `snake_case`
- 常數: `UPPER_CASE`
2. **程式碼風格**:
- 使用 Black 格式化
- 遵循 PEP 8
- 最大行長: 100 字元
3. **型別標註**:
```python
def get_tenant(tenant_id: int) -> Optional[Tenant]:
"""取得租戶資料"""
pass
```
4. **文件字串**:
```python
def create_tenant(data: TenantCreate) -> Tenant:
"""
建立新租戶
Args:
data: 租戶建立資料
Returns:
Tenant: 建立的租戶物件
Raises:
ValueError: 租戶代碼已存在
"""
pass
```
### TypeScript (前端)
1. **命名規範**:
- 檔案名稱: `kebab-case.tsx`
- 元件名稱: `PascalCase`
- 函數名稱: `camelCase`
- 介面: `PascalCase` (前綴 I 可選)
2. **元件結構**:
```typescript
'use client'
import { useState } from 'react'
interface Props {
title: string
onSubmit: (data: FormData) => void
}
export default function MyComponent({ title, onSubmit }: Props) {
const [loading, setLoading] = useState(false)
return (
<div>
<h1>{title}</h1>
</div>
)
}
```
3. **型別定義**:
```typescript
// types/tenant.ts
export interface Tenant {
id: number
code: string
name: string
status: 'trial' | 'active' | 'suspended'
}
```
## API 設計規範
### RESTful 規範
**URL 命名**:
- 使用名詞複數: `/api/v1/tenants`
- 使用 kebab-case: `/api/v1/tenant-onboarding`
- 版本控制: `/api/v1/`, `/api/v2/`
**HTTP 方法**:
- `GET`: 查詢資料
- `POST`: 建立資料
- `PUT`: 完整更新
- `PATCH`: 部分更新
- `DELETE`: 刪除資料
**回應格式**:
```json
{
"success": true,
"data": {
"id": 1,
"name": "測試公司"
},
"message": "操作成功"
}
```
**錯誤回應**:
```json
{
"success": false,
"error": {
"code": "TENANT_NOT_FOUND",
"message": "找不到指定的租戶",
"details": {}
}
}
```
### Schema 設計
**命名規範**:
- Base Schema: `TenantBase`
- Create Schema: `TenantCreate`
- Update Schema: `TenantUpdate`
- Response Schema: `TenantResponse`
**範例**:
```python
from pydantic import BaseModel, Field
class TenantBase(BaseModel):
"""租戶基礎 Schema"""
code: str = Field(..., max_length=50, description="租戶代碼")
name: str = Field(..., max_length=200, description="公司名稱")
class TenantCreate(TenantBase):
"""建立租戶 Schema"""
admin_email: str = Field(..., description="管理員郵箱")
class TenantResponse(TenantBase):
"""租戶回應 Schema"""
id: int
status: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
```
## 資料庫規範
### Migration 管理
**命名規則**:
```
{timestamp}_{description}.py
例如: 20260227_create_subscriptions_table.py
```
**Migration 內容**:
```python
def upgrade() -> None:
"""升級操作"""
op.create_table(
'subscriptions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('tenant_id', sa.Integer(), nullable=False),
# ...
)
def downgrade() -> None:
"""降級操作"""
op.drop_table('subscriptions')
```
### 表格命名
- 使用複數形式: `subscriptions`, `invoices`
- 使用 snake_case
- 加入前綴表示模組: `billing_invoices`
## Git 工作流程
### 分支策略
- `master`: 生產環境
- `develop`: 開發環境
- `feature/*`: 功能開發
- `hotfix/*`: 緊急修復
### Commit 訊息
**格式**:
```
<類型>: <簡短描述>
<詳細說明>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
```
**類型**:
- `feat`: 新功能
- `fix`: 錯誤修復
- `docs`: 文件更新
- `refactor`: 重構
- `test`: 測試
- `chore`: 雜項
**範例**:
```
feat: 新增租戶開通 API
實作租戶自動開通功能,包含:
- 建立租戶資料
- 配置網域
- 建立 Keycloak Realm
- 初始化服務
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
```
## 測試規範
### 單元測試
**覆蓋率目標**: 80%+
**測試檔案命名**:
```
tests/
├── unit/
│ ├── test_tenant_service.py
│ └── test_billing_service.py
└── integration/
├── test_api_tenants.py
└── test_api_billing.py
```
**測試範例**:
```python
import pytest
from app.services.tenant_service import create_tenant
def test_create_tenant_success():
"""測試建立租戶成功"""
data = TenantCreate(
code="testcompany",
name="測試公司",
admin_email="admin@test.com"
)
tenant = create_tenant(data)
assert tenant.code == "testcompany"
assert tenant.status == "trial"
```
## 安全規範
1. **環境變數**: 所有敏感資訊放在 `.env`
2. **密碼處理**: 使用 bcrypt 雜湊
3. **API 驗證**: 所有 API 需要 JWT token
4. **輸入驗證**: 使用 Pydantic 驗證所有輸入
5. **SQL 注入**: 使用 ORM避免原生 SQL
## 文件規範
1. **README.md**: 每個專案必須包含
2. **API 文件**: 使用 FastAPI 自動生成 (Swagger)
3. **程式碼註解**: 複雜邏輯必須註解
4. **設計文件**: 重要功能需要設計文件
---
**參考資源**:
- [FastAPI 官方文件](https://fastapi.tiangolo.com/)
- [Next.js 官方文件](https://nextjs.org/docs)
- [Python PEP 8](https://peps.python.org/pep-0008/)

View File

@@ -0,0 +1,8 @@
# Production Environment Variables
NEXT_PUBLIC_API_URL=https://vmis.lab.taipei/api
# Keycloak Configuration
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.lab.taipei
NEXT_PUBLIC_KEYCLOAK_REALM=vmis-admin
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=vmis-admin-portal
NEXT_PUBLIC_REDIRECT_URI=https://vmis.lab.taipei/callback

39
frontend/admin-portal/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,4 @@
@echo off
echo Starting VMIS Admin Portal at http://localhost:10280
echo Press Ctrl+C to stop
python -m http.server 10280

View File

@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>帳號管理 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/2.0.3/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link active" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-people me-1"></i>帳號管理</span>
<div class="ms-auto d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="tenant-filter" style="width:180px" onchange="loadTable()">
<option value="">全部租戶</option>
</select>
<button class="btn btn-primary btn-sm" onclick="openCreate()">
<i class="bi bi-plus-lg me-1"></i>新增帳號
</button>
</div>
</div>
<div id="content">
<div class="card shadow-sm">
<div class="card-body p-0">
<table id="tbl" class="table table-hover mb-0 w-100">
<thead>
<tr>
<th>帳號編碼</th>
<th>租戶</th>
<th>SSO 帳號</th>
<th>系統郵件</th>
<th>法定姓名</th>
<th class="text-center">啟用</th>
<th class="text-center">SSO</th>
<th class="text-center">郵箱</th>
<th class="text-center">Drive</th>
<th class="text-center">Mail</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="formModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="formTitle">新增帳號</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="f-id">
<div class="row g-3">
<div class="col-12">
<label class="form-label">租戶 <span class="text-danger">*</span></label>
<select class="form-select" id="f-tenant"></select>
</div>
<div class="col-md-6">
<label class="form-label">SSO 帳號 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-sso" placeholder="alice">
</div>
<div class="col-md-6">
<label class="form-label">通知郵件 <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="f-notify-email" placeholder="alice@gmail.com">
</div>
<div class="col-md-6">
<label class="form-label">法定姓名</label>
<input type="text" class="form-control" id="f-legal-name">
</div>
<div class="col-md-6">
<label class="form-label">英文姓名</label>
<input type="text" class="form-control" id="f-eng-name">
</div>
<div class="col-md-6">
<label class="form-label">配額 (GB)</label>
<input type="number" class="form-control" id="f-quota" value="20" min="1">
</div>
<div class="col-md-6">
<label class="form-label">預設密碼</label>
<input type="text" class="form-control" id="f-password" placeholder="留空自動產生">
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="f-active" checked>
<label class="form-check-label" for="f-active">啟用</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveForm()">儲存</button>
</div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header"><h6 class="modal-title">確認</h6></div>
<div class="modal-body" id="confirm-body"></div>
<div class="modal-footer">
<button id="confirm-cancel" class="btn btn-secondary btn-sm">取消</button>
<button id="confirm-ok" class="btn btn-danger btn-sm">確定</button>
</div>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.bootstrap5.min.js"></script>
<script src="js/api.js"></script>
<script>
let dt;
let tenants = [];
async function init() {
try { tenants = await apiFetch('/tenants'); } catch (e) { toast('無法載入租戶列表:' + e.message, 'error'); }
const sel = document.getElementById('tenant-filter');
const fSel = document.getElementById('f-tenant');
tenants.forEach(t => {
sel.insertAdjacentHTML('beforeend', `<option value="${t.id}">${t.name} (${t.code})</option>`);
fSel.insertAdjacentHTML('beforeend', `<option value="${t.id}">${t.name} (${t.code})</option>`);
});
loadTable();
}
async function loadTable() {
const tid = document.getElementById('tenant-filter').value;
const qs = tid ? `?tenant_id=${tid}` : '';
let rows;
try { rows = await apiFetch(`/accounts${qs}`); } catch (e) { toast('無法載入帳號資料:' + e.message, 'error'); return; }
const data = rows.map(a => {
const lights = a.lights || {};
return [
`<strong>${a.account_code}</strong>`,
`<small>${a.tenant_name || ''}</small>`,
a.sso_account,
`<small class="text-muted">${a.email || '—'}</small>`,
a.legal_name || '—',
a.is_active
? '<span class="badge bg-success-subtle text-success">啟用</span>'
: '<span class="badge bg-secondary-subtle text-secondary">停用</span>',
lightHtml(lights.sso_result),
lightHtml(lights.mailbox_result),
lightHtml(lights.nc_result),
lightHtml(lights.nc_mail_result),
`<button class="btn btn-outline-primary btn-sm me-1" onclick='editRow(${JSON.stringify(a)})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteRow(${a.id},'${a.sso_account}')"><i class="bi bi-trash"></i></button>`,
];
});
if (dt) {
dt.clear().rows.add(data).draw();
} else {
dt = $('#tbl').DataTable({
data,
columns: [
{}, {}, {}, {}, {},
{ className: 'text-center' },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
],
language: { url: 'https://cdn.datatables.net/plug-ins/2.0.3/i18n/zh-HANT.json' },
pageLength: 25,
order: [[0, 'asc']],
});
}
}
function clearForm() {
['f-id','f-sso','f-notify-email','f-legal-name','f-eng-name','f-password'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('f-quota').value = 20;
document.getElementById('f-active').checked = true;
if (tenants.length) document.getElementById('f-tenant').value = tenants[0].id;
}
function openCreate() {
clearForm();
document.getElementById('formTitle').textContent = '新增帳號';
document.getElementById('f-tenant').disabled = false;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
function editRow(a) {
clearForm();
document.getElementById('formTitle').textContent = '編輯帳號';
document.getElementById('f-id').value = a.id;
document.getElementById('f-tenant').value = a.tenant_id;
document.getElementById('f-tenant').disabled = true;
document.getElementById('f-sso').value = a.sso_account;
document.getElementById('f-notify-email').value = a.notification_email || '';
document.getElementById('f-legal-name').value = a.legal_name || '';
document.getElementById('f-eng-name').value = a.english_name || '';
document.getElementById('f-quota').value = a.quota_limit;
document.getElementById('f-active').checked = a.is_active;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
async function saveForm() {
const id = document.getElementById('f-id').value;
const payload = {
tenant_id: parseInt(document.getElementById('f-tenant').value),
sso_account: document.getElementById('f-sso').value.trim(),
notification_email: document.getElementById('f-notify-email').value.trim(),
legal_name: document.getElementById('f-legal-name').value.trim() || null,
english_name: document.getElementById('f-eng-name').value.trim() || null,
quota_limit: parseInt(document.getElementById('f-quota').value),
default_password: document.getElementById('f-password').value.trim() || null,
is_active: document.getElementById('f-active').checked,
};
try {
if (id) {
const { tenant_id, sso_account, ...updatePayload } = payload;
await apiFetch(`/accounts/${id}`, { method: 'PUT', body: JSON.stringify(updatePayload) });
toast('帳號已更新');
} else {
await apiFetch('/accounts', { method: 'POST', body: JSON.stringify(payload) });
toast('帳號已新增');
}
bootstrap.Modal.getInstance(document.getElementById('formModal')).hide();
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
async function deleteRow(id, name) {
const ok = await confirm(`確定要刪除帳號「${name}」?`);
if (!ok) return;
try {
await apiFetch(`/accounts/${id}`, { method: 'DELETE' });
toast('帳號已刪除');
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
init();
</script>
</body>
</html>

View File

@@ -0,0 +1,161 @@
/* VMIS Admin Portal - Custom Styles */
:root {
--sidebar-width: 220px;
--sidebar-bg: #1a1d23;
--sidebar-active: #0d6efd;
--header-height: 56px;
}
body {
font-size: 0.875rem;
background: #f5f6fa;
}
/* ── Sidebar ── */
#sidebar {
width: var(--sidebar-width);
min-height: 100vh;
background: var(--sidebar-bg);
position: fixed;
top: 0;
left: 0;
z-index: 100;
display: flex;
flex-direction: column;
}
#sidebar .brand {
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 1.2rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
color: #fff;
font-weight: 700;
font-size: 1rem;
letter-spacing: 0.03em;
text-decoration: none;
}
#sidebar .nav-link {
color: rgba(255,255,255,0.65);
padding: 0.6rem 1.2rem;
border-radius: 0;
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
#sidebar .nav-link:hover {
color: #fff;
background: rgba(255,255,255,0.07);
}
#sidebar .nav-link.active {
color: #fff;
background: var(--sidebar-active);
}
#sidebar .nav-section {
color: rgba(255,255,255,0.3);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1rem 1.2rem 0.3rem;
}
/* ── Main Content ── */
#main {
margin-left: var(--sidebar-width);
min-height: 100vh;
display: flex;
flex-direction: column;
}
#topbar {
height: var(--header-height);
background: #fff;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 99;
}
#content {
padding: 1.5rem;
flex: 1;
}
/* ── Status Lights ── */
.light {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
vertical-align: middle;
}
.light-grey { background: #aaaaaa; }
.light-green { background: #28a745; }
.light-red { background: #dc3545; }
/* ── DataTable tweaks ── */
.dataTables_wrapper .dataTables_filter input {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
}
.dataTables_wrapper .dataTables_length select {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
}
table.dataTable thead th {
border-bottom: 2px solid #dee2e6;
background: #f8f9fa;
font-weight: 600;
white-space: nowrap;
}
table.dataTable tbody tr:hover {
background: #f0f4ff;
}
/* ── Cards ── */
.stat-card {
border: none;
border-radius: 0.75rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
/* ── Availability bar ── */
.avail-bar {
height: 6px;
border-radius: 3px;
background: #e9ecef;
overflow: hidden;
}
.avail-fill {
height: 100%;
border-radius: 3px;
background: #28a745;
transition: width 0.4s;
}
.avail-fill.warn { background: #ffc107; }
.avail-fill.danger { background: #dc3545; }
/* ── System status matrix ── */
.status-matrix .env-col {
min-width: 120px;
text-align: center;
}
/* ── Modal ── */
.modal-header { background: #f8f9fa; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VMIS Admin Portal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-speedometer2 me-1"></i>儀表板</span>
<div class="ms-auto text-muted small" id="last-refresh"></div>
</div>
<div id="content">
<!-- Summary Cards -->
<div class="row g-3 mb-4" id="summary-cards">
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-2 bg-primary-subtle"><i class="bi bi-building fs-4 text-primary"></i></div>
<div>
<div class="text-muted small">租戶總數</div>
<div class="fs-4 fw-bold" id="cnt-tenants"></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-2 bg-success-subtle"><i class="bi bi-people fs-4 text-success"></i></div>
<div>
<div class="text-muted small">帳號總數</div>
<div class="fs-4 fw-bold" id="cnt-accounts"></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-2 bg-warning-subtle"><i class="bi bi-hdd-network fs-4 text-warning"></i></div>
<div>
<div class="text-muted small">伺服器</div>
<div class="fs-4 fw-bold" id="cnt-servers"></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-2 bg-info-subtle"><i class="bi bi-activity fs-4 text-info"></i></div>
<div>
<div class="text-muted small">系統狀態</div>
<div id="sys-status-summary" class="fw-bold"></div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Schedules status -->
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-header bg-white d-flex align-items-center">
<span class="fw-semibold">排程狀態</span>
<a href="schedules.html" class="ms-auto btn btn-outline-secondary btn-sm">管理</a>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody id="sched-rows"></tbody>
</table>
</div>
</div>
</div>
<!-- Servers quick status -->
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-header bg-white d-flex align-items-center">
<span class="fw-semibold">伺服器</span>
<a href="servers.html" class="ms-auto btn btn-outline-secondary btn-sm">詳細</a>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody id="server-rows"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/api.js"></script>
<script>
const STATUS_BADGE = {
Waiting: '<span class="badge bg-secondary">Waiting</span>',
Going: '<span class="badge bg-warning text-dark">Going</span>',
};
const RESULT_BADGE = {
ok: '<span class="badge bg-success">ok</span>',
error: '<span class="badge bg-danger">error</span>',
};
async function loadDashboard() {
const [r_tenants, r_accounts, r_servers, r_schedules, r_sysStatus] = await Promise.allSettled([
apiFetch('/tenants'),
apiFetch('/accounts'),
apiFetch('/servers'),
apiFetch('/schedules'),
apiFetch('/system-status'),
]);
const tenants = r_tenants.status === 'fulfilled' ? r_tenants.value : null;
const accounts = r_accounts.status === 'fulfilled' ? r_accounts.value : null;
const servers = r_servers.status === 'fulfilled' ? r_servers.value : null;
const schedules = r_schedules.status === 'fulfilled' ? r_schedules.value : null;
const sysStatus = r_sysStatus.status === 'fulfilled' ? r_sysStatus.value : null;
document.getElementById('cnt-tenants').textContent = tenants ? tenants.length : '—';
document.getElementById('cnt-accounts').textContent = accounts ? accounts.length : '—';
document.getElementById('cnt-servers').textContent = servers ? servers.length : '—';
// System status summary
if (!sysStatus) {
document.getElementById('sys-status-summary').innerHTML = '<span class="text-muted">無法取得</span>';
} else if (sysStatus.length === 0) {
document.getElementById('sys-status-summary').innerHTML = '<span class="text-muted">無資料</span>';
} else {
const bad = sysStatus.filter(r => !r.result).length;
document.getElementById('sys-status-summary').innerHTML = bad === 0
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>全部正常</span>'
: `<span class="text-danger"><i class="bi bi-exclamation-circle-fill me-1"></i>${bad} 項異常</span>`;
}
// Schedules
if (schedules) {
const schedRows = schedules.map(s => `
<tr>
<td class="ps-3">${s.name}</td>
<td><code class="small">${s.cron_timer}</code></td>
<td>${STATUS_BADGE[s.status] || s.status}</td>
<td>${s.last_status ? (RESULT_BADGE[s.last_status] || s.last_status) : '<span class="text-muted">—</span>'}</td>
<td class="text-muted small pe-3">${fmtDt(s.next_run_at)}</td>
</tr>`).join('');
document.getElementById('sched-rows').innerHTML = schedRows;
}
// Servers
if (servers) {
const svrRows = servers.map(s => `
<tr>
<td class="ps-3">${lightHtml(s.last_result)}</td>
<td><strong>${s.name}</strong></td>
<td><code class="small">${s.ip_address}</code></td>
<td class="text-muted small pe-3">${s.last_response_time != null ? s.last_response_time.toFixed(1) + ' ms' : '—'}</td>
</tr>`).join('');
document.getElementById('server-rows').innerHTML = svrRows;
}
document.getElementById('last-refresh').textContent = '更新:' + new Date().toLocaleTimeString('zh-TW', { hour12: false });
}
loadDashboard();
setInterval(loadDashboard, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,184 @@
/* VMIS Admin Portal - API utilities */
const API = 'http://localhost:10281/api/v1';
/* ── Auth state ── */
let _sysSettings = null;
let _kcInstance = null;
let _accessToken = null;
/* ── Keycloak SSO guard ── */
async function _loadKcScript(src) {
return new Promise((resolve) => {
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve(true);
s.onerror = () => resolve(false);
document.head.appendChild(s);
});
}
async function _initKcAuth(settings) {
// 嘗試路徑順序:新版(Quarkus) → 舊版(WildFly /auth) → CDN
const candidates = [
`${settings.keycloak_url}/js/keycloak.js`,
`${settings.keycloak_url}/auth/js/keycloak.js`,
'https://cdn.jsdelivr.net/npm/keycloak-js/dist/keycloak.min.js',
];
let loaded = false;
for (const src of candidates) {
loaded = await _loadKcScript(src);
if (loaded) { console.info('KC JS 載入成功:', src); break; }
}
if (!loaded) { console.warn('KC JS 全部來源載入失敗,跳過 SSO 守衛'); return; }
try {
const kc = new Keycloak({
url: settings.keycloak_url,
realm: settings.keycloak_realm,
clientId: settings.keycloak_client || 'vmis-portal',
});
const authenticated = await kc.init({
onLoad: 'login-required',
pkceMethod: 'S256',
checkLoginIframe: false,
});
if (authenticated) {
_kcInstance = kc;
_accessToken = kc.token;
setInterval(() => {
kc.updateToken(60)
.then(refreshed => { if (refreshed) _accessToken = kc.token; })
.catch(() => kc.login());
}, 30000);
// 在 topbar 插入使用者資訊與登出按鈕
const topbar = document.getElementById('topbar');
if (topbar) {
const username = kc.tokenParsed?.preferred_username || kc.tokenParsed?.name || '';
const btn = document.createElement('div');
btn.className = 'ms-3 d-flex align-items-center gap-2';
btn.innerHTML = `
<span class="text-muted small"><i class="bi bi-person-circle me-1"></i>${username}</span>
<button class="btn btn-outline-secondary btn-sm" onclick="_kcInstance.logout()">
<i class="bi bi-box-arrow-right me-1"></i>登出
</button>`;
topbar.appendChild(btn);
}
}
} catch (e) {
console.error('KC 初始化失敗:', e);
}
}
/* ── Global settings (loaded on every page) ── */
async function loadSysSettings() {
try {
_sysSettings = await apiFetch('/settings');
// Apply site title: prepend page name if title contains " — "
if (_sysSettings.site_title) {
const cur = document.title;
const sep = cur.indexOf(' — ');
const pageName = sep >= 0 ? cur.substring(0, sep) : cur;
document.title = `${pageName}${_sysSettings.site_title}`;
}
// Inject version badge into topbar if element exists
const topbar = document.getElementById('topbar');
if (topbar && _sysSettings.version) {
const badge = document.createElement('span');
badge.className = 'badge bg-secondary-subtle text-secondary ms-2';
badge.textContent = `v${_sysSettings.version}`;
topbar.querySelector('.fw-semibold')?.after(badge);
}
// SSO guard若已啟用觸發 Keycloak 認證
if (_sysSettings.sso_enabled && _sysSettings.keycloak_url && _sysSettings.keycloak_realm) {
await _initKcAuth(_sysSettings);
}
} catch (e) {
// Silently ignore if settings not yet available
}
}
// Auto-load on every page
document.addEventListener('DOMContentLoaded', loadSysSettings);
async function apiFetch(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;
const resp = await fetch(API + path, { headers, ...options });
if (!resp.ok) {
// Token 過期 → 重新登入
if (resp.status === 401 && _kcInstance) { _kcInstance.login(); return; }
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw Object.assign(new Error(err.detail || resp.statusText), { status: resp.status });
}
if (resp.status === 204) return null;
return resp.json();
}
/* ── light helper ── */
function lightHtml(val) {
if (val === null || val === undefined) return '<span class="light light-grey" title="無紀錄"></span>';
return val
? '<span class="light light-green" title="正常"></span>'
: '<span class="light light-red" title="異常"></span>';
}
/* ── availability color ── */
function availClass(pct) {
if (pct === null || pct === undefined) return '';
if (pct >= 99) return '';
if (pct >= 95) return 'warn';
return 'danger';
}
function availBar(pct) {
if (pct === null || pct === undefined) return '<span class="text-muted">—</span>';
const cls = availClass(pct);
return `
<div class="d-flex align-items-center gap-2">
<div class="avail-bar flex-grow-1">
<div class="avail-fill ${cls}" style="width:${pct}%"></div>
</div>
<small class="${cls === 'danger' ? 'text-danger' : cls === 'warn' ? 'text-warning' : 'text-success'}">${pct}%</small>
</div>`;
}
/* ── toast ── */
function toast(msg, type = 'success') {
const container = document.getElementById('toast-container');
if (!container) return;
const id = 'toast-' + Date.now();
const icon = type === 'success' ? '✓' : '✗';
const bg = type === 'success' ? 'text-bg-success' : 'text-bg-danger';
container.insertAdjacentHTML('beforeend', `
<div id="${id}" class="toast align-items-center ${bg} border-0 show" role="alert">
<div class="d-flex">
<div class="toast-body">${icon} ${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
setTimeout(() => document.getElementById(id)?.remove(), 3500);
}
/* ── confirm modal ── */
function confirm(msg) {
return new Promise(resolve => {
document.getElementById('confirm-body').textContent = msg;
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('confirmModal'));
document.getElementById('confirm-ok').onclick = () => { modal.hide(); resolve(true); };
document.getElementById('confirm-cancel').onclick = () => { modal.hide(); resolve(false); };
modal.show();
});
}
/* ── format datetime ── */
// 後端已依設定時區儲存,直接顯示資料庫原始時間,不做轉換
function fmtDt(iso) {
if (!iso) return '—';
return iso.replace('T', ' ').substring(0, 19);
}
function fmtDate(iso) {
if (!iso) return '—';
return iso.substring(0, 10);
}

View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>排程執行紀錄 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/2.0.3/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="css/style.css">
<style>
.result-dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:3px; }
.dot-ok { background:#198754; }
.dot-fail { background:#dc3545; }
.dot-na { background:#adb5bd; }
tr.log-row { cursor:pointer; }
tr.log-row:hover td { background:#f8f9fa; }
.detail-row td { padding:0!important; }
.detail-panel { padding:12px 16px; background:#f8f9fa; border-top:1px solid #dee2e6; }
</style>
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link active" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-list-ul me-1"></i>排程執行紀錄</span>
<div class="ms-auto d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="schedule-filter" style="width:180px" onchange="loadTable()">
<option value="">全部排程</option>
</select>
</div>
</div>
<div id="content">
<div class="card shadow-sm">
<div class="card-body p-0">
<table id="tbl" class="table table-hover mb-0 w-100">
<thead>
<tr>
<th>ID</th>
<th>排程名稱</th>
<th>開始時間</th>
<th>結束時間</th>
<th>耗時</th>
<th class="text-center">結果</th>
<th class="text-center">詳細</th>
</tr>
</thead>
<tbody id="tbl-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/api.js"></script>
<script>
let allRows = [];
let expandedLogId = null;
function durationHtml(started, ended) {
if (!ended) return '<span class="text-muted">—</span>';
const ms = new Date(ended) - new Date(started);
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec/60)}m ${sec%60}s`;
}
function dot(val) {
if (val === true) return '<span class="result-dot dot-ok"></span>';
if (val === false) return '<span class="result-dot dot-fail"></span>';
return '<span class="result-dot dot-na"></span>';
}
function statusBadge(s) {
if (s === 'ok') return '<span class="badge bg-success">ok</span>';
if (s === 'error') return '<span class="badge bg-danger">error</span>';
if (s === 'running') return '<span class="badge bg-warning text-dark">running</span>';
return `<span class="badge bg-secondary">${s}</span>`;
}
function renderTenantResults(results) {
if (!results.length) return '<p class="text-muted mb-0 small">無租戶資料</p>';
let html = `<table class="table table-sm table-bordered mb-0 small">
<thead class="table-light"><tr>
<th>租戶</th><th class="text-center">Traefik</th><th class="text-center">SSO</th>
<th class="text-center">Mailbox</th><th class="text-center">NC</th>
<th class="text-center">OO</th><th class="text-center">Quota(GB)</th>
<th>失敗原因</th>
</tr></thead><tbody>`;
for (const r of results) {
html += `<tr>
<td>${r.tenant_name || r.tenant_id}</td>
<td class="text-center">${dot(r.traefik_status)}</td>
<td class="text-center">${dot(r.sso_result)}</td>
<td class="text-center">${dot(r.mailbox_result)}</td>
<td class="text-center">${dot(r.nc_result)}</td>
<td class="text-center">${dot(r.office_result)}</td>
<td class="text-center">${r.quota_usage != null ? r.quota_usage.toFixed(2) : '—'}</td>
<td class="text-danger small">${r.fail_reason || ''}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderAccountResults(results) {
if (!results.length) return '<p class="text-muted mb-0 small">無帳號資料</p>';
let html = `<table class="table table-sm table-bordered mb-0 small">
<thead class="table-light"><tr>
<th>帳號</th><th class="text-center">SSO</th>
<th class="text-center">Mailbox</th><th class="text-center">NC</th>
<th class="text-center">Mail</th>
<th class="text-center">Quota(GB)</th><th>失敗原因</th>
</tr></thead><tbody>`;
for (const r of results) {
html += `<tr>
<td>${r.sso_account || r.account_id}</td>
<td class="text-center">${dot(r.sso_result)}</td>
<td class="text-center">${dot(r.mailbox_result)}</td>
<td class="text-center">${dot(r.nc_result)}</td>
<td class="text-center">${dot(r.nc_mail_result)}</td>
<td class="text-center">${r.quota_usage != null ? r.quota_usage.toFixed(2) : '—'}</td>
<td class="text-danger small">${r.fail_reason || ''}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
async function toggleDetail(logId, scheduleId, btn) {
const detailRowId = `detail-${logId}`;
const existing = document.getElementById(detailRowId);
if (existing) {
existing.remove();
btn.innerHTML = '<i class="bi bi-chevron-down"></i>';
expandedLogId = null;
return;
}
// Close previous
if (expandedLogId) {
const prev = document.getElementById(`detail-${expandedLogId}`);
if (prev) prev.remove();
const prevBtn = document.querySelector(`[data-log="${expandedLogId}"]`);
if (prevBtn) prevBtn.innerHTML = '<i class="bi bi-chevron-down"></i>';
}
expandedLogId = logId;
btn.innerHTML = '<i class="bi bi-chevron-up"></i>';
const logRow = document.getElementById(`row-${logId}`);
if (!logRow) return;
const detailRow = document.createElement('tr');
detailRow.id = detailRowId;
detailRow.className = 'detail-row';
detailRow.innerHTML = `<td colspan="7"><div class="detail-panel"><span class="text-muted small">載入中...</span></div></td>`;
logRow.after(detailRow);
try {
const data = await apiFetch(`/schedules/${scheduleId}/logs/${logId}/results`);
let inner = '';
if (data.tenant_results && data.tenant_results.length > 0) {
inner = renderTenantResults(data.tenant_results);
} else if (data.account_results && data.account_results.length > 0) {
inner = renderAccountResults(data.account_results);
} else {
inner = '<span class="text-muted small">無詳細結果</span>';
}
detailRow.querySelector('.detail-panel').innerHTML = inner;
} catch (e) {
detailRow.querySelector('.detail-panel').innerHTML = `<span class="text-danger small">${e.message}</span>`;
}
}
async function init() {
let schedules;
try { schedules = await apiFetch('/schedules'); } catch (e) { toast('無法載入排程列表:' + e.message, 'error'); return; }
const sel = document.getElementById('schedule-filter');
schedules.forEach(s => {
sel.insertAdjacentHTML('beforeend', `<option value="${s.id}">${s.name}</option>`);
});
const params = new URLSearchParams(window.location.search);
if (params.get('schedule_id')) sel.value = params.get('schedule_id');
loadTable();
}
async function loadTable() {
const sid = document.getElementById('schedule-filter').value;
let rows = [];
try {
if (sid) {
rows = await apiFetch(`/schedules/${sid}/logs`);
} else {
const schedules = await apiFetch('/schedules');
const all = await Promise.allSettled(schedules.map(s => apiFetch(`/schedules/${s.id}/logs`)));
rows = all.filter(r => r.status === 'fulfilled').flatMap(r => r.value)
.sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
}
} catch (e) { toast('無法載入紀錄:' + e.message, 'error'); return; }
const tbody = document.getElementById('tbl-body');
tbody.innerHTML = '';
expandedLogId = null;
for (const r of rows) {
const scheduleId = r.schedule_id;
const tr = document.createElement('tr');
tr.id = `row-${r.id}`;
tr.className = 'log-row';
tr.innerHTML = `
<td>${r.id}</td>
<td>${r.schedule_name}</td>
<td>${fmtDt(r.started_at)}</td>
<td>${fmtDt(r.ended_at)}</td>
<td>${durationHtml(r.started_at, r.ended_at)}</td>
<td class="text-center">${statusBadge(r.status)}</td>
<td class="text-center">
${r.status !== 'running' ? `<button class="btn btn-sm btn-outline-secondary py-0 px-1" data-log="${r.id}" onclick="toggleDetail(${r.id}, ${scheduleId}, this)"><i class="bi bi-chevron-down"></i></button>` : '—'}
</td>`;
tbody.appendChild(tr);
}
}
init();
</script>
</body>
</html>

View File

@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>排程管理 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link active" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-clock me-1"></i>排程管理</span>
</div>
<div id="content">
<div id="cards" class="row g-3"></div>
</div>
</div>
<!-- Edit Cron Modal -->
<div class="modal fade" id="cronModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">修改 Cron</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="c-id">
<label class="form-label">Cron 表達式 <small class="text-muted">(六碼:秒 分 時 日 月 週)</small></label>
<input type="text" class="form-control font-monospace" id="c-cron" placeholder="0 */3 * * * *">
<div class="form-text">範例:<code>0 */3 * * * *</code> = 每3分鐘<code>0 0 8 * * *</code> = 每日08:00</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveCron()">儲存</button>
</div>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/api.js"></script>
<script>
const STATUS_BADGE = {
Waiting: '<span class="badge bg-secondary">Waiting</span>',
Going: '<span class="badge bg-warning text-dark"><i class="bi bi-arrow-repeat spin me-1"></i>Going</span>',
};
const RESULT_BADGE = {
ok: '<span class="badge bg-success">ok</span>',
error: '<span class="badge bg-danger">error</span>',
};
function cardHtml(s) {
const statusBadge = STATUS_BADGE[s.status] || s.status;
const resultBadge = s.last_status ? (RESULT_BADGE[s.last_status] || s.last_status) : '<span class="text-muted">—</span>';
return `
<div class="col-md-4">
<div class="card stat-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h6 class="mb-0">${s.name}</h6>
${statusBadge}
</div>
<div class="mb-2">
<small class="text-muted">Cron</small><br>
<code class="fs-6">${s.cron_timer}</code>
</div>
<div class="row g-2 text-sm mb-3">
<div class="col-6">
<small class="text-muted d-block">上次執行</small>
<span class="small">${fmtDt(s.last_run_at)}</span>
</div>
<div class="col-6">
<small class="text-muted d-block">下次執行</small>
<span class="small">${fmtDt(s.next_run_at)}</span>
</div>
<div class="col-6">
<small class="text-muted d-block">上次結果</small>
${resultBadge}
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm flex-fill"
onclick="openCron(${s.id},'${s.cron_timer}')">
<i class="bi bi-pencil me-1"></i>Cron
</button>
<button class="btn btn-outline-primary btn-sm flex-fill"
onclick="runNow(${s.id},'${s.name}')"
${s.status === 'Going' ? 'disabled' : ''}>
<i class="bi bi-play-fill me-1"></i>立即執行
</button>
<a class="btn btn-outline-secondary btn-sm" href="schedule-logs.html?schedule_id=${s.id}">
<i class="bi bi-list-ul"></i>
</a>
</div>
</div>
</div>
</div>`;
}
async function loadCards() {
let schedules;
try { schedules = await apiFetch('/schedules'); } catch (e) { toast('無法載入排程資料:' + e.message, 'error'); return; }
document.getElementById('cards').innerHTML = schedules.map(cardHtml).join('');
}
function openCron(id, cron) {
document.getElementById('c-id').value = id;
document.getElementById('c-cron').value = cron;
new bootstrap.Modal(document.getElementById('cronModal')).show();
}
async function saveCron() {
const id = document.getElementById('c-id').value;
const cron_timer = document.getElementById('c-cron').value.trim();
try {
await apiFetch(`/schedules/${id}`, {
method: 'PUT',
body: JSON.stringify({ cron_timer }),
});
toast('Cron 已更新');
bootstrap.Modal.getInstance(document.getElementById('cronModal')).hide();
loadCards();
} catch (e) {
toast(e.message, 'error');
}
}
async function runNow(id, name) {
try {
await apiFetch(`/schedules/${id}/run`, { method: 'POST' });
toast(`${name}」已觸發執行`);
setTimeout(loadCards, 1000);
} catch (e) {
toast(e.message, 'error');
}
}
// Add spin animation
const style = document.createElement('style');
style.textContent = `.spin { animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }`;
document.head.appendChild(style);
loadCards();
// Auto refresh every 10s to catch status changes
setInterval(loadCards, 10000);
</script>
</body>
</html>

View File

@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>伺服器狀態 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/2.0.3/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link active" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-hdd-network me-1"></i>伺服器狀態</span>
<div class="ms-auto">
<button class="btn btn-primary btn-sm" onclick="openCreate()">
<i class="bi bi-plus-lg me-1"></i>新增伺服器
</button>
</div>
</div>
<div id="content">
<div class="card shadow-sm">
<div class="card-body p-0">
<table id="tbl" class="table table-hover mb-0 w-100">
<thead>
<tr>
<th>排序</th>
<th>主機名稱</th>
<th>IP 位址</th>
<th>說明</th>
<th class="text-center">狀態</th>
<th>回應時間</th>
<th style="min-width:160px">可用率 30天</th>
<th style="min-width:160px">可用率 90天</th>
<th style="min-width:160px">可用率 365天</th>
<th class="text-center">啟用</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="formModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="formTitle">新增伺服器</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="f-id">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">主機名稱 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-name" placeholder="home">
</div>
<div class="col-md-4">
<label class="form-label">排序</label>
<input type="number" class="form-control" id="f-sort" value="0" min="0">
</div>
<div class="col-12">
<label class="form-label">IP 位址 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-ip" placeholder="10.1.0.254">
</div>
<div class="col-12">
<label class="form-label">說明</label>
<input type="text" class="form-control" id="f-desc" placeholder="核心服務主機">
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="f-active" checked>
<label class="form-check-label" for="f-active">啟用(納入排程檢查)</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveForm()">儲存</button>
</div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header"><h6 class="modal-title">確認</h6></div>
<div class="modal-body" id="confirm-body"></div>
<div class="modal-footer">
<button id="confirm-cancel" class="btn btn-secondary btn-sm">取消</button>
<button id="confirm-ok" class="btn btn-danger btn-sm">確定</button>
</div>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.bootstrap5.min.js"></script>
<script src="js/api.js"></script>
<script>
let dt;
function rtHtml(ms) {
if (ms === null || ms === undefined) return '<span class="text-muted">—</span>';
return `<span class="text-success">${ms.toFixed(1)} ms</span>`;
}
async function loadTable() {
let rows;
try { rows = await apiFetch('/servers'); } catch (e) { toast('無法載入伺服器資料:' + e.message, 'error'); return; }
const data = rows.map(s => {
const av = s.availability || {};
return [
s.sort_order,
`<strong>${s.name}</strong>`,
`<code>${s.ip_address}</code>`,
`<small class="text-muted">${s.description || '—'}</small>`,
lightHtml(s.last_result),
rtHtml(s.last_response_time),
availBar(av.availability_30d),
availBar(av.availability_90d),
availBar(av.availability_365d),
s.is_active
? '<span class="badge bg-success-subtle text-success">啟用</span>'
: '<span class="badge bg-secondary-subtle text-secondary">停用</span>',
`<button class="btn btn-outline-primary btn-sm me-1" onclick='editRow(${JSON.stringify(s)})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteRow(${s.id},'${s.name}')"><i class="bi bi-trash"></i></button>`,
];
});
if (dt) {
dt.clear().rows.add(data).draw();
} else {
dt = $('#tbl').DataTable({
data,
columns: [
{ type: 'num' }, {}, { className: 'font-monospace' }, {},
{ className: 'text-center', orderable: false },
{},
{ orderable: false },
{ orderable: false },
{ orderable: false },
{ className: 'text-center' },
{ className: 'text-center', orderable: false },
],
language: { url: 'https://cdn.datatables.net/plug-ins/2.0.3/i18n/zh-HANT.json' },
pageLength: 25,
order: [[0, 'asc']],
});
}
}
function clearForm() {
document.getElementById('f-id').value = '';
document.getElementById('f-name').value = '';
document.getElementById('f-ip').value = '';
document.getElementById('f-desc').value = '';
document.getElementById('f-sort').value = 0;
document.getElementById('f-active').checked = true;
}
function openCreate() {
clearForm();
document.getElementById('formTitle').textContent = '新增伺服器';
new bootstrap.Modal(document.getElementById('formModal')).show();
}
function editRow(s) {
clearForm();
document.getElementById('formTitle').textContent = '編輯伺服器';
document.getElementById('f-id').value = s.id;
document.getElementById('f-name').value = s.name;
document.getElementById('f-ip').value = s.ip_address;
document.getElementById('f-desc').value = s.description || '';
document.getElementById('f-sort').value = s.sort_order;
document.getElementById('f-active').checked = s.is_active;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
async function saveForm() {
const id = document.getElementById('f-id').value;
const payload = {
name: document.getElementById('f-name').value.trim(),
ip_address: document.getElementById('f-ip').value.trim(),
description: document.getElementById('f-desc').value.trim() || null,
sort_order: parseInt(document.getElementById('f-sort').value),
is_active: document.getElementById('f-active').checked,
};
try {
if (id) {
await apiFetch(`/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) });
toast('伺服器已更新');
} else {
await apiFetch('/servers', { method: 'POST', body: JSON.stringify(payload) });
toast('伺服器已新增');
}
bootstrap.Modal.getInstance(document.getElementById('formModal')).hide();
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
async function deleteRow(id, name) {
const ok = await confirm(`確定要刪除伺服器「${name}」?`);
if (!ok) return;
try {
await apiFetch(`/servers/${id}`, { method: 'DELETE' });
toast('伺服器已刪除');
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
loadTable();
</script>
</body>
</html>

View File

@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>系統設定 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link active" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-gear me-1"></i>系統設定</span>
<div class="ms-auto text-muted small" id="last-update"></div>
</div>
<div id="content">
<!-- 初始化提示 -->
<div id="init-alert" class="alert alert-warning d-flex align-items-center gap-2 mb-4" style="display:none!important">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
<div>
<strong>系統初始化提示:</strong>
請依序完成以下步驟以啟動 SSO 認證:<br>
<span class="badge bg-secondary me-1">1</span>設定 Keycloak 連線資訊並測試連線
<span class="badge bg-secondary mx-1">2</span>初始化 SSO Realm
<span class="badge bg-secondary mx-1">3</span>建立管理租戶 (is_manager=true) 及帳號
<span class="badge bg-secondary mx-1">4</span>啟用 SSO
</div>
</div>
<div class="row g-4">
<!-- 基本設定 -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">
<i class="bi bi-sliders me-1 text-primary"></i>基本設定
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold">系統標題</label>
<input type="text" class="form-control" id="s-title" placeholder="VMIS Admin Portal">
<div class="form-text">瀏覽器 tab 顯示的標題文字</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">版本宣告</label>
<input type="text" class="form-control" id="s-version" placeholder="2.0.0">
<div class="form-text">顯示於頁面底部的系統版本號</div>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">資料時區</label>
<select class="form-select" id="s-timezone">
<option value="Asia/Taipei">Asia/Taipei (UTC+8)</option>
<option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option>
<option value="Asia/Singapore">Asia/Singapore (UTC+8)</option>
<option value="Asia/Shanghai">Asia/Shanghai (UTC+8)</option>
<option value="UTC">UTC (UTC+0)</option>
<option value="America/New_York">America/New_York (UTC-5)</option>
<option value="Europe/London">Europe/London (UTC+0)</option>
</select>
<div class="form-text">資料庫紀錄儲存使用的時區,變更後需重啟後端才完全生效</div>
</div>
</div>
</div>
</div>
<!-- SSO 設定 -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">
<i class="bi bi-shield-lock me-1 text-warning"></i>SSO 驗證設定
</div>
<div class="card-body">
<!-- SSO 啟用 -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="s-sso" role="switch">
<label class="form-check-label fw-semibold" for="s-sso">啟用 SSO 登入</label>
</div>
<div class="form-text mt-1">
啟用後,進入系統必須通過 Keycloak 登入驗證。<br>
<strong class="text-danger">前提:需有管理租戶 (is_manager=true) 及其帳號。</strong>
</div>
</div>
<hr>
<!-- Master Realm 管理帳號 -->
<p class="text-muted small mb-2"><i class="bi bi-database me-1"></i>Master Realm 管理帳號(後端操作租戶 realm 用)</p>
<div class="mb-3">
<label class="form-label fw-semibold">Keycloak URL</label>
<input type="text" class="form-control" id="s-kc-url" placeholder="https://auth.lab.taipei">
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">管理帳號</label>
<input type="text" class="form-control" id="s-kc-admin-user" placeholder="admin">
</div>
<div class="col-6">
<label class="form-label fw-semibold">管理密碼</label>
<div class="input-group">
<input type="password" class="form-control" id="s-kc-admin-pass" placeholder="••••••••">
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('s-kc-admin-pass',this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- 測試連線 -->
<div class="d-flex align-items-center gap-2 mb-3">
<button class="btn btn-outline-primary btn-sm" onclick="testKeycloak()">
<i class="bi bi-plug me-1"></i>測試連線
</button>
<span id="kc-test-result" class="small"></span>
</div>
<hr>
<!-- Admin Portal SSO -->
<p class="text-muted small mb-2"><i class="bi bi-browser-chrome me-1"></i>Admin Portal 前端登入設定</p>
<div class="mb-3">
<label class="form-label fw-semibold">前端 Client ID</label>
<input type="text" class="form-control" id="s-kc-client" placeholder="vmis-portal">
<div class="form-text">前端登入用的 Keycloak Public Client ID。SSO Realm 由<strong>管理租戶</strong>的 Keycloak Realm 決定,請先至<a href="tenants.html">租戶管理</a>設定 is_manager=true 的租戶。</div>
</div>
<!-- 初始化 SSO Realm -->
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-warning btn-sm" onclick="initSsoRealm()">
<i class="bi bi-shield-plus me-1"></i>初始化 SSO Realm
</button>
<span id="kc-init-result" class="small"></span>
</div>
<div class="form-text mt-1">
在管理租戶的 Realm 中建立前端 Client ID依序儲存設定 → 測試連線 → 建立管理租戶 → 再執行)
</div>
</div>
</div>
</div>
<!-- 目前版本資訊(唯讀) -->
<div class="col-12">
<div class="card shadow-sm border-0 bg-light">
<div class="card-body d-flex align-items-center gap-4 py-3">
<div>
<small class="text-muted d-block">系統名稱</small>
<span class="fw-semibold" id="info-title"></span>
</div>
<div>
<small class="text-muted d-block">版本</small>
<span class="badge bg-primary" id="info-version"></span>
</div>
<div>
<small class="text-muted d-block">時區</small>
<span id="info-tz"></span>
</div>
<div>
<small class="text-muted d-block">SSO</small>
<span id="info-sso"></span>
</div>
<div>
<small class="text-muted d-block">Keycloak Realm</small>
<span id="info-realm" class="font-monospace small"></span>
</div>
<div>
<small class="text-muted d-block">管理租戶</small>
<span id="info-manager"></span>
</div>
<div>
<small class="text-muted d-block">最後更新</small>
<span id="info-updated"></span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-primary px-4" onclick="saveSettings()">
<i class="bi bi-check-lg me-1"></i>儲存設定
</button>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/api.js"></script>
<script>
function togglePass(id, btn) {
const el = document.getElementById(id);
const isPass = el.type === 'password';
el.type = isPass ? 'text' : 'password';
btn.innerHTML = isPass ? '<i class="bi bi-eye-slash"></i>' : '<i class="bi bi-eye"></i>';
}
async function loadSettings() {
const s = await apiFetch('/settings');
document.getElementById('s-title').value = s.site_title;
document.getElementById('s-version').value = s.version;
document.getElementById('s-timezone').value = s.timezone;
document.getElementById('s-sso').checked = s.sso_enabled;
document.getElementById('s-kc-url').value = s.keycloak_url;
document.getElementById('s-kc-admin-user').value = s.keycloak_admin_user;
document.getElementById('s-kc-admin-pass').value = s.keycloak_admin_pass;
document.getElementById('s-kc-client').value = s.keycloak_client;
// Info bar
document.getElementById('info-title').textContent = s.site_title;
document.getElementById('info-version').textContent = s.version;
document.getElementById('info-tz').textContent = s.timezone;
document.getElementById('info-sso').innerHTML = s.sso_enabled
? '<span class="badge bg-success">啟用</span>'
: '<span class="badge bg-secondary">停用</span>';
document.getElementById('info-realm').textContent = s.keycloak_realm || '—';
document.getElementById('info-updated').textContent = fmtDt(s.updated_at);
document.getElementById('last-update').textContent = '最後更新:' + fmtDt(s.updated_at);
if (s.site_title) document.title = `系統設定 — ${s.site_title}`;
// 載入管理租戶狀態
await loadManagerStatus();
}
async function loadManagerStatus() {
try {
const tenants = await apiFetch('/tenants');
const manager = tenants.find(t => t.is_manager && t.is_active);
const el = document.getElementById('info-manager');
if (manager) {
el.innerHTML = `<span class="badge bg-success">${manager.name}</span>`;
// 若沒有帳號也要提示
const accounts = await apiFetch(`/accounts?tenant_id=${manager.id}&is_active=true`);
if (accounts.length === 0) {
el.innerHTML += ' <span class="badge bg-warning text-dark">尚無帳號</span>';
} else {
el.innerHTML += ` <small class="text-muted">(${accounts.length} 帳號)</small>`;
}
} else {
el.innerHTML = '<span class="badge bg-danger">尚未建立</span>';
document.getElementById('init-alert').style.removeProperty('display');
}
} catch (e) {
document.getElementById('info-manager').textContent = '—';
}
}
async function saveSettings() {
const payload = {
site_title: document.getElementById('s-title').value.trim(),
version: document.getElementById('s-version').value.trim(),
timezone: document.getElementById('s-timezone').value,
sso_enabled: document.getElementById('s-sso').checked,
keycloak_url: document.getElementById('s-kc-url').value.trim(),
keycloak_admin_user: document.getElementById('s-kc-admin-user').value.trim(),
keycloak_admin_pass: document.getElementById('s-kc-admin-pass').value,
keycloak_client: document.getElementById('s-kc-client').value.trim(),
};
try {
await apiFetch('/settings', { method: 'PUT', body: JSON.stringify(payload) });
toast('設定已儲存');
loadSettings();
} catch (e) {
toast(e.message, 'error');
}
}
async function testKeycloak() {
const el = document.getElementById('kc-test-result');
el.innerHTML = '<span class="text-muted">測試中...</span>';
try {
const r = await apiFetch('/settings/test-keycloak', { method: 'POST' });
el.innerHTML = `<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>${r.message}</span>`;
} catch (e) {
el.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`;
}
}
async function initSsoRealm() {
const el = document.getElementById('kc-init-result');
el.innerHTML = '<span class="text-muted">初始化中...</span>';
try {
const r = await apiFetch('/settings/init-sso-realm', { method: 'POST' });
el.innerHTML = `<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>完成Realm: ${r.realm}</span>`;
toast('SSO Realm 初始化完成:' + r.details.join('、'));
loadSettings();
} catch (e) {
el.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`;
toast(e.message, 'error');
}
}
loadSettings();
</script>
</body>
</html>

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>系統狀態 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="css/style.css">
<style>
.matrix-table th, .matrix-table td { vertical-align: middle; text-align: center; }
.matrix-table th:first-child, .matrix-table td:first-child { text-align: left; }
.env-badge-test { background: #cfe2ff; color: #084298; }
.env-badge-prod { background: #d1e7dd; color: #0a3622; }
.service-icon { font-size: 1.1rem; margin-right: 0.35rem; }
.light-lg { width: 18px; height: 18px; }
.log-time { font-size: 0.78rem; color: #6c757d; }
.history-dot {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
margin: 1px;
}
</style>
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link active" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-activity me-1"></i>系統狀態</span>
<div class="ms-auto text-muted small" id="last-update">載入中...</div>
</div>
<div id="content">
<!-- Matrix -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-white d-flex align-items-center">
<span class="fw-semibold">基礎設施狀態矩陣</span>
<span class="ms-2 text-muted small">(最新一次執行結果)</span>
</div>
<div class="card-body">
<div id="matrix-area">
<p class="text-muted text-center py-4">尚無資料,請等待系統狀態排程執行(每日 08:00</p>
</div>
</div>
</div>
<!-- Legend -->
<div class="d-flex gap-3 mb-4 text-sm">
<span><span class="light light-green me-1"></span>正常</span>
<span><span class="light light-red me-1"></span>異常</span>
<span><span class="light light-grey me-1"></span>無資料</span>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/api.js"></script>
<script>
const SERVICE_ICON = {
traefik: 'bi-diagram-3',
keycloak: 'bi-shield-lock',
mail: 'bi-envelope',
db: 'bi-database',
};
const SERVICE_LABEL = {
traefik: 'Traefik',
keycloak: 'Keycloak',
mail: 'Mail Server',
db: 'PostgreSQL',
};
async function load() {
const rows = await apiFetch('/system-status');
if (!rows || rows.length === 0) {
document.getElementById('last-update').textContent = '尚無執行紀錄';
return;
}
// Group by service_name, environment
// rows: [{ environment, service_name, service_desc, result, fail_reason, recorded_at }, ...]
const latest = {};
rows.forEach(r => {
const key = `${r.environment}__${r.service_name}`;
if (!latest[key]) latest[key] = r;
});
// Update time from first row
const anyRow = rows[0];
document.getElementById('last-update').textContent = '最後更新:' + fmtDt(anyRow.recorded_at);
// Build matrix: services × environments
const services = ['traefik', 'keycloak', 'mail', 'db'];
const envs = ['test', 'prod'];
const envLabel = { test: '測試環境', prod: '正式環境' };
let html = `
<table class="table matrix-table table-bordered mb-0">
<thead>
<tr>
<th style="width:200px">服務</th>
${envs.map(e => `<th><span class="badge ${e === 'test' ? 'env-badge-test' : 'env-badge-prod'}">${envLabel[e]}</span></th>`).join('')}
</tr>
</thead>
<tbody>`;
services.forEach(svc => {
html += `<tr>
<td class="text-start">
<i class="bi ${SERVICE_ICON[svc] || 'bi-circle'} service-icon text-primary"></i>
${SERVICE_LABEL[svc] || svc}
</td>`;
envs.forEach(env => {
const r = latest[`${env}__${svc}`];
if (!r) {
html += `<td><span class="light light-grey light-lg"></span></td>`;
} else {
const cls = r.result ? 'light-green' : 'light-red';
const title = r.fail_reason ? ` title="${r.fail_reason}"` : '';
html += `<td>
<span class="light ${cls} light-lg"${title}></span>
${!r.result && r.fail_reason
? `<br><small class="text-danger">${r.fail_reason.substring(0, 60)}${r.fail_reason.length > 60 ? '...' : ''}</small>`
: ''}
<div class="log-time">${fmtDt(r.recorded_at)}</div>
</td>`;
}
});
html += '</tr>';
});
html += `</tbody></table>`;
document.getElementById('matrix-area').innerHTML = html;
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>租戶管理 — VMIS Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/2.0.3/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="img/logo.png">
</head>
<body>
<!-- Sidebar -->
<nav id="sidebar">
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
<div class="pt-2">
<div class="nav-section">租戶服務</div>
<a class="nav-link active" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
<div class="nav-section">基礎設施</div>
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
<div class="nav-section">排程</div>
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
<div class="nav-section">系統</div>
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-building me-1"></i>租戶管理</span>
<div class="ms-auto">
<button class="btn btn-primary btn-sm" onclick="openCreate()">
<i class="bi bi-plus-lg me-1"></i>新增租戶
</button>
</div>
</div>
<div id="content">
<div class="card shadow-sm">
<div class="card-body p-0">
<table id="tbl" class="table table-hover mb-0 w-100">
<thead>
<tr>
<th>租戶代碼</th>
<th>中文名稱</th>
<th>網域</th>
<th>狀態</th>
<th class="text-center">啟用</th>
<th class="text-center">SSO</th>
<th class="text-center">郵箱</th>
<th class="text-center">Drive</th>
<th class="text-center">Office</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="formModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="formTitle">新增租戶</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="f-id">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">租戶代碼 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-code" placeholder="porsche">
</div>
<div class="col-md-4">
<label class="form-label">前置碼 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-prefix" placeholder="PC">
</div>
<div class="col-md-4">
<label class="form-label">狀態</label>
<select class="form-select" id="f-status">
<option value="trial">試用 (trial)</option>
<option value="active">正式 (active)</option>
<option value="inactive">停用 (inactive)</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">中文名稱 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-name" placeholder="匠耘科技">
</div>
<div class="col-md-6">
<label class="form-label">英文名稱</label>
<input type="text" class="form-control" id="f-name-eng" placeholder="Jiang Yun Tech">
</div>
<div class="col-md-6">
<label class="form-label">網域 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-domain" placeholder="porsche.lab.taipei">
</div>
<div class="col-md-6">
<label class="form-label">統一編號</label>
<input type="text" class="form-control" id="f-tax-id">
</div>
<div class="col-md-4">
<label class="form-label">每帳號配額 (GB)</label>
<input type="number" class="form-control" id="f-quota-user" value="20" min="1">
</div>
<div class="col-md-4">
<label class="form-label">租戶總配額 (GB)</label>
<input type="number" class="form-control" id="f-quota-total" value="200" min="1">
</div>
<div class="col-md-4">
<label class="form-label">員工數上限</label>
<input type="number" class="form-control" id="f-emp-limit" value="50" min="1">
</div>
<div class="col-md-6">
<label class="form-label">聯絡人</label>
<input type="text" class="form-control" id="f-contact">
</div>
<div class="col-md-6">
<label class="form-label">聯絡人郵件</label>
<input type="email" class="form-control" id="f-contact-email">
</div>
<div class="col-12">
<label class="form-label">備註</label>
<textarea class="form-control" id="f-note" rows="2"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="f-active" checked>
<label class="form-check-label" for="f-active">啟用</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="f-manager">
<label class="form-check-label" for="f-manager">
管理租戶 <small class="text-muted">(系統維護用,全系統只能有一個)</small>
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveForm()">儲存</button>
</div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header"><h6 class="modal-title">確認</h6></div>
<div class="modal-body" id="confirm-body"></div>
<div class="modal-footer">
<button id="confirm-cancel" class="btn btn-secondary btn-sm">取消</button>
<button id="confirm-ok" class="btn btn-danger btn-sm">確定</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.bootstrap5.min.js"></script>
<script src="js/api.js"></script>
<script>
let dt;
const statusBadge = s => ({
trial: '<span class="badge bg-warning text-dark">試用</span>',
active: '<span class="badge bg-success">正式</span>',
inactive: '<span class="badge bg-secondary">停用</span>',
}[s] || s);
async function loadTable() {
let rows;
try { rows = await apiFetch('/tenants'); } catch (e) { toast('無法載入租戶資料:' + e.message, 'error'); return; }
const data = rows.map(t => {
const lights = t.lights || {};
return [
`<strong>${t.code}</strong>`,
t.name,
`<small class="text-muted">${t.domain}</small>`,
statusBadge(t.status),
t.is_active
? '<span class="badge bg-success-subtle text-success">啟用</span>'
: '<span class="badge bg-secondary-subtle text-secondary">停用</span>',
lightHtml(lights.sso_result),
lightHtml(lights.mailbox_result),
lightHtml(lights.nc_result),
lightHtml(lights.office_result),
`<button class="btn btn-outline-primary btn-sm me-1" onclick='editRow(${JSON.stringify(t)})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteRow(${t.id},'${t.name}')"><i class="bi bi-trash"></i></button>`,
];
});
if (dt) {
dt.clear().rows.add(data).draw();
} else {
dt = $('#tbl').DataTable({
data,
columns: [
{}, {}, {}, {},
{ className: 'text-center' },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
],
language: { url: 'https://cdn.datatables.net/plug-ins/2.0.3/i18n/zh-HANT.json' },
pageLength: 25,
order: [[0, 'asc']],
});
}
}
function clearForm() {
['f-id','f-code','f-prefix','f-name','f-name-eng','f-domain','f-tax-id','f-contact','f-contact-email','f-note'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('f-quota-user').value = 20;
document.getElementById('f-quota-total').value = 200;
document.getElementById('f-emp-limit').value = 50;
document.getElementById('f-status').value = 'trial';
document.getElementById('f-active').checked = true;
document.getElementById('f-manager').checked = false;
}
function openCreate() {
clearForm();
document.getElementById('formTitle').textContent = '新增租戶';
new bootstrap.Modal(document.getElementById('formModal')).show();
}
function editRow(t) {
clearForm();
document.getElementById('formTitle').textContent = '編輯租戶';
document.getElementById('f-id').value = t.id;
document.getElementById('f-code').value = t.code;
document.getElementById('f-prefix').value = t.prefix;
document.getElementById('f-name').value = t.name;
document.getElementById('f-name-eng').value = t.name_eng || '';
document.getElementById('f-domain').value = t.domain;
document.getElementById('f-tax-id').value = t.tax_id || '';
document.getElementById('f-quota-user').value = t.quota_per_user;
document.getElementById('f-quota-total').value = t.total_quota;
document.getElementById('f-emp-limit').value = t.employee_limit || 50;
document.getElementById('f-contact').value = t.contact || '';
document.getElementById('f-contact-email').value = t.contact_email || '';
document.getElementById('f-note').value = t.note || '';
document.getElementById('f-status').value = t.status;
document.getElementById('f-active').checked = t.is_active;
document.getElementById('f-manager').checked = t.is_manager || false;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
async function saveForm() {
const id = document.getElementById('f-id').value;
const payload = {
code: document.getElementById('f-code').value.trim(),
prefix: document.getElementById('f-prefix').value.trim(),
name: document.getElementById('f-name').value.trim(),
name_eng: document.getElementById('f-name-eng').value.trim() || null,
domain: document.getElementById('f-domain').value.trim(),
tax_id: document.getElementById('f-tax-id').value.trim() || null,
quota_per_user: parseInt(document.getElementById('f-quota-user').value),
total_quota: parseInt(document.getElementById('f-quota-total').value),
employee_limit: parseInt(document.getElementById('f-emp-limit').value),
contact: document.getElementById('f-contact').value.trim() || null,
contact_email: document.getElementById('f-contact-email').value.trim() || null,
note: document.getElementById('f-note').value.trim() || null,
status: document.getElementById('f-status').value,
is_active: document.getElementById('f-active').checked,
is_manager: document.getElementById('f-manager').checked,
};
try {
if (id) {
await apiFetch(`/tenants/${id}`, { method: 'PUT', body: JSON.stringify(payload) });
toast('租戶已更新');
} else {
await apiFetch('/tenants', { method: 'POST', body: JSON.stringify(payload) });
toast('租戶已新增');
}
bootstrap.Modal.getInstance(document.getElementById('formModal')).hide();
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
async function deleteRow(id, name) {
const ok = await confirm(`確定要刪除租戶「${name}」?\n(關聯帳號將一併刪除)`);
if (!ok) return;
try {
await apiFetch(`/tenants/${id}`, { method: 'DELETE' });
toast('租戶已刪除');
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
loadTable();
</script>
</body>
</html>