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 [2/2] 啟動 FastAPI 服務...
echo API Server: http://localhost:10181
echo API Docs: http://localhost:10181/docs
echo API Server: http://localhost:10281
echo API Docs: http://localhost:10281/docs
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

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,
mailbox_result=result.mailbox_result,
nc_result=result.nc_result,
nc_mail_result=result.nc_mail_result,
quota_usage=result.quota_usage,
)
@@ -60,6 +61,7 @@ def list_accounts(
@router.post("", response_model=AccountResponse, status_code=201)
def create_account(payload: AccountCreate, db: Session = Depends(get_db)):
import secrets
tenant = db.get(Tenant, payload.tenant_id)
if not tenant:
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)
email = f"{payload.sso_account}@{tenant.domain}"
data = payload.model_dump()
if not data.get("default_password"):
data["default_password"] = account_code # 預設密碼 = 帳號編碼,使用者首次登入後必須變更
account = Account(
**payload.model_dump(),
**data,
seq_no=seq_no,
account_code=account_code,
email=email,

View File

@@ -1,5 +1,5 @@
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.include_router(tenants.router)
@@ -7,3 +7,4 @@ api_router.include_router(accounts.router)
api_router.include_router(schedules.router)
api_router.include_router(servers.router)
api_router.include_router(status.router)
api_router.include_router(settings.router)

View File

@@ -1,12 +1,16 @@
from typing import List
from datetime import datetime
from app.core.utils import now_tw
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from croniter import croniter
from app.core.database import get_db
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"])
@@ -29,9 +33,9 @@ def update_schedule_cron(schedule_id: int, payload: ScheduleUpdate, db: Session
s = db.get(Schedule, schedule_id)
if not s:
raise HTTPException(status_code=404, detail="Schedule not found")
# Validate cron expression
# Validate cron expression (5-field: 分 時 日 月 週)
try:
cron = croniter(payload.cron_timer, datetime.utcnow())
cron = croniter(payload.cron_timer, now_tw())
next_run = cron.get_next(datetime)
except Exception:
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()
)
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 datetime import datetime, timedelta
from app.core.utils import now_tw
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, case
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]:
since = datetime.utcnow() - timedelta(days=days)
since = now_tw() - timedelta(days=days)
row = (
db.query(
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"
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_PORT: int = 10281

View File

@@ -8,6 +8,7 @@ engine = create_engine(
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
connect_args={"options": "-c timezone=Asia/Taipei"},
)
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.result import TenantScheduleResult, AccountScheduleResult
from app.models.server import Server, ServerStatusLog, SystemStatusLog
from app.models.settings import SystemSettings
__all__ = [
"Tenant", "Account", "Schedule", "ScheduleLog",
"TenantScheduleResult", "AccountScheduleResult",
"Server", "ServerStatusLog", "SystemStatusLog",
"SystemSettings",
]

View File

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

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -28,7 +29,7 @@ class TenantScheduleResult(Base):
fail_reason = Column(Text)
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")
tenant = relationship("Tenant", back_populates="schedule_results")
@@ -52,9 +53,12 @@ class AccountScheduleResult(Base):
nc_result = Column(Boolean)
nc_done_at = Column(DateTime)
nc_mail_result = Column(Boolean)
nc_mail_done_at = Column(DateTime)
fail_reason = Column(Text)
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")
account = relationship("Account", back_populates="schedule_results")

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -14,7 +15,7 @@ class Schedule(Base):
last_run_at = Column(DateTime)
next_run_at = Column(DateTime)
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")

View File

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

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

View File

@@ -32,6 +32,7 @@ class AccountStatusLight(BaseModel):
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

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Optional
from typing import Optional, List
from pydantic import BaseModel
@@ -31,3 +31,39 @@ class ScheduleLogResponse(BaseModel):
class Config:
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)
管理租戶的 NC / OO 容器
DockerClient — paramiko SSH (遠端 docker / traefik 查詢)
所有容器都在 10.1.0.254,透過 SSH 操作
3-state 回傳: None=未設定(灰), True=正常(綠), False=異常(紅)
"""
import logging
from typing import Optional
@@ -12,60 +13,9 @@ logger = logging.getLogger(__name__)
class DockerClient:
def __init__(self):
self._docker = None
def _get_docker(self):
if self._docker is None:
import docker
self._docker = docker.from_env()
return self._docker
def check_traefik_route(self, domain: str) -> bool:
"""
Traefik API: GET http://localhost:8080/api/http/routers
驗證 routers 中包含 domain且 routers 數量 > 0
"""
try:
resp = httpx.get("http://localhost:8080/api/overview", timeout=5.0)
if resp.status_code != 200:
return False
data = resp.json()
# Verify actual routes exist (functional check)
http_count = data.get("http", {}).get("routers", {}).get("total", 0)
if http_count == 0:
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:
logger.warning(f"Traefik check failed for {domain}: {e}")
return False
def ensure_container_running(self, container_name: str, tenant_code: str, realm: str) -> bool:
"""Check container status; start if exited; deploy via SSH if not found."""
try:
docker_client = self._get_docker()
container = docker_client.containers.get(container_name)
if container.status == "running":
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:
def _ssh(self):
"""建立 SSH 連線到 10.1.0.254"""
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
@@ -74,8 +24,63 @@ class DockerClient:
username=settings.DOCKER_SSH_USER,
timeout=15,
)
return client
def check_traefik_domain(self, domain: str) -> Optional[bool]:
"""
None = domain 在 Traefik 沒有路由設定(灰)
True = 路由存在且服務存活(綠)
False = 路由存在但服務不通(紅)
"""
try:
resp = httpx.get(f"http://{settings.DOCKER_SSH_HOST}:8080/api/http/routers", timeout=5.0)
if resp.status_code != 200:
return False
routers = resp.json()
route_found = any(domain in str(r.get("rule", "")) for r in routers)
if not route_found:
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
except Exception as e:
logger.warning(f"Traefik check failed for {domain}: {e}")
return False
def check_container_ssh(self, container_name: str) -> Optional[bool]:
"""
SSH 到 10.1.0.254 查詢容器狀態。
None = 容器不存在(未部署)
True = 容器正在執行
False = 容器存在但未執行exited/paused
"""
try:
client = self._ssh()
_, stdout, _ = client.exec_command(
f"docker inspect --format='{{{{.State.Status}}}}' {container_name} 2>/dev/null"
)
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}"
stdin, stdout, stderr = client.exec_command(
_, stdout, _ = client.exec_command(
f"cd {deploy_dir} && docker compose up -d 2>&1"
)
exit_status = stdout.channel.recv_exit_status()
@@ -84,3 +89,19 @@ class DockerClient:
except Exception as e:
logger.error(f"SSH compose up failed for {tenant_code}: {e}")
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 套件。
管理租戶 realm 及帳號的建立/查詢
使用 master realm admin 帳密取得管理 token管理租戶 realm 及帳號。
"""
import logging
from typing import Optional
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
TIMEOUT = 10.0
class KeycloakClient:
def __init__(self):
self._base = settings.KEYCLOAK_URL.rstrip("/")
def __init__(self, base_url: str, admin_user: str, admin_pass: str):
self._base = base_url.rstrip("/")
self._admin_user = admin_user
self._admin_pass = admin_pass
self._admin_token: Optional[str] = None
def _get_admin_token(self) -> str:
"""取得 vmis-admin realm 的 admin access token"""
url = f"{self._base}/realms/{settings.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token"
"""取得 master realm 的 admin access tokenResource Owner Password"""
url = f"{self._base}/realms/master/protocol/openid-connect/token"
resp = httpx.post(
url,
data={
"grant_type": "client_credentials",
"client_id": settings.KEYCLOAK_ADMIN_CLIENT_ID,
"client_secret": settings.KEYCLOAK_ADMIN_CLIENT_SECRET,
"grant_type": "password",
"client_id": "admin-cli",
"username": self._admin_user,
"password": self._admin_pass,
},
timeout=TIMEOUT,
verify=False,
)
resp.raise_for_status()
return resp.json()["access_token"]
@@ -43,7 +45,12 @@ class KeycloakClient:
def realm_exists(self, realm: str) -> bool:
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
except Exception:
return False
@@ -60,21 +67,58 @@ class KeycloakClient:
json=payload,
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
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]:
resp = httpx.get(
self._admin_url(f"{realm}/users"),
params={"username": username, "exact": "true"},
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
resp.raise_for_status()
users = resp.json()
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 = {
"username": username,
"email": email,
@@ -82,14 +126,121 @@ class KeycloakClient:
"emailVerified": True,
}
if password:
payload["credentials"] = [{"type": "password", "value": password, "temporary": True}]
payload["credentials"] = [
{"type": "password", "value": password, "temporary": True}
]
resp = httpx.post(
self._admin_url(f"{realm}/users"),
json=payload,
headers=self._headers(),
timeout=TIMEOUT,
verify=False,
)
if resp.status_code == 201:
location = resp.headers.get("Location", "")
return location.rstrip("/").split("/")[-1]
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)
管理 mail domain 和 mailbox 的建立/查詢。
建立 domain 前必須驗證 MX DNS 設定(對 active 租戶)
MailClient — 透過 SSH + docker exec mailserver 管理 docker-mailserver。
domain 在 docker-mailserver 中是隱式的(由 mailbox 決定),
所以 domain_exists 檢查是否有任何 @domain 的 mailbox
"""
import logging
from typing import Optional
import httpx
import dns.resolver
from app.core.config import settings
logger = logging.getLogger(__name__)
TIMEOUT = 10.0
MAILSERVER_CONTAINER = "mailserver"
class MailClient:
def __init__(self):
self._base = settings.MAIL_ADMIN_API_URL.rstrip("/")
self._headers = {"X-API-Key": settings.MAIL_ADMIN_API_KEY}
def _ssh_exec(self, cmd: str) -> tuple[int, str]:
"""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:
"""驗證 domain 的 MX record 是否指向正確的 mail server"""
@@ -33,49 +45,65 @@ class MailClient:
return False
def domain_exists(self, domain: str) -> bool:
"""檢查 mailserver 是否有任何 @domain 的 mailboxdocker-mailserver 的 domain 由 mailbox 決定)"""
try:
resp = httpx.get(
f"{self._base}/api/v1/domains/{domain}",
headers=self._headers,
timeout=TIMEOUT,
code, output = self._ssh_exec(
f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '@{domain}' && echo yes || echo no"
)
return resp.status_code == 200
except Exception:
return output.strip() == "yes"
except Exception as e:
logger.warning(f"domain_exists({domain}) SSH failed: {e}")
return False
def create_domain(self, domain: str) -> bool:
"""
docker-mailserver 不需要顯式建立 domainmailbox 新增時自動處理)。
新增一個 postmaster@ 系統帳號來確保 domain 被識別。
"""
try:
resp = httpx.post(
f"{self._base}/api/v1/domains",
json={"domain": domain},
headers=self._headers,
timeout=TIMEOUT,
import secrets
passwd = secrets.token_urlsafe(16)
code, output = self._ssh_exec(
f"docker exec {MAILSERVER_CONTAINER} setup email add postmaster@{domain} {passwd} 2>&1"
)
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:
logger.error(f"create_domain({domain}) failed: {e}")
logger.error(f"create_domain({domain}) SSH failed: {e}")
return False
def mailbox_exists(self, email: str) -> bool:
try:
resp = httpx.get(
f"{self._base}/api/v1/mailboxes/{email}",
headers=self._headers,
timeout=TIMEOUT,
code, output = self._ssh_exec(
f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '{email}' && echo yes || echo no"
)
return resp.status_code == 200
except Exception:
return output.strip() == "yes"
except Exception as e:
logger.warning(f"mailbox_exists({email}) SSH failed: {e}")
return False
def create_mailbox(self, email: str, password: Optional[str], quota_gb: int = 20) -> bool:
try:
resp = httpx.post(
f"{self._base}/api/v1/mailboxes",
json={"email": email, "password": password or "", "quota": quota_gb},
headers=self._headers,
timeout=TIMEOUT,
import secrets
passwd = password or secrets.token_urlsafe(16)
quota_mb = quota_gb * 1024
code, output = self._ssh_exec(
f"docker exec {MAILSERVER_CONTAINER} setup email add {email} {passwd} 2>&1"
)
return resp.status_code in (200, 201, 204)
except Exception as e:
logger.error(f"create_mailbox({email}) failed: {e}")
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:
logger.error(f"create_mailbox({email}) SSH failed: {e}")
return False

View File

@@ -47,6 +47,20 @@ class NextcloudClient:
logger.error(f"NC create_user({username}) failed: {e}")
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]:
try:
resp = httpx.get(
@@ -57,11 +71,57 @@ class NextcloudClient:
)
if resp.status_code != 200:
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)
except Exception:
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]:
"""Sum all users' quota usage"""
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.
"""
own_db = db is None
own_log = False
log_obj = None
if own_db:
db = SessionLocal()
if log_id is None:
from datetime import datetime
from app.core.utils import now_tw
from app.models.schedule import ScheduleLog, Schedule
schedule = db.get(Schedule, schedule_id)
if not schedule:
if own_db:
db.close()
return
log = ScheduleLog(
log_obj = ScheduleLog(
schedule_id=schedule_id,
schedule_name=schedule.name,
started_at=datetime.utcnow(),
started_at=now_tw(),
status="running",
)
db.add(log)
db.add(log_obj)
db.commit()
db.refresh(log)
log_id = log.id
db.refresh(log_obj)
log_id = log_obj.id
own_log = True
final_status = "error"
try:
if schedule_id == 1:
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)
else:
logger.warning(f"Unknown schedule_id: {schedule_id}")
final_status = "ok"
except Exception as e:
logger.exception(f"dispatch_schedule({schedule_id}) error: {e}")
raise
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:
db.close()

View File

@@ -4,6 +4,7 @@ Schedule 2 — 帳號檢查(每 3 分鐘)
"""
import logging
from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy.orm import Session
from app.models.account import Account
@@ -13,16 +14,17 @@ logger = logging.getLogger(__name__)
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.nextcloud_client import NextcloudClient
from app.services.keycloak_client import get_keycloak_client
accounts = (
db.query(Account)
.filter(Account.is_active == True)
.all()
)
kc = KeycloakClient()
kc = get_keycloak_client()
mail = MailClient()
for account in accounts:
@@ -32,7 +34,7 @@ def run_account_check(schedule_log_id: int, db: Session):
schedule_log_id=schedule_log_id,
account_id=account.id,
sso_account=account.sso_account,
recorded_at=datetime.utcnow(),
recorded_at=now_tw(),
)
fail_reasons = []
@@ -45,15 +47,19 @@ def run_account_check(schedule_log_id: int, db: Session):
if not account.sso_uuid:
account.sso_uuid = sso_uuid
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_uuid = sso_uuid
if sso_uuid and not account.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:
result.sso_result = False
result.sso_done_at = datetime.utcnow()
result.sso_done_at = now_tw()
fail_reasons.append(f"sso: {e}")
# [2] Mailbox check (skip if mail domain not ready)
@@ -65,30 +71,113 @@ def run_account_check(schedule_log_id: int, db: Session):
else:
created = mail.create_mailbox(email, account.default_password, account.quota_limit)
result.mailbox_result = created
result.mailbox_done_at = datetime.utcnow()
result.mailbox_done_at = now_tw()
except Exception as e:
result.mailbox_result = False
result.mailbox_done_at = datetime.utcnow()
result.mailbox_done_at = now_tw()
fail_reasons.append(f"mailbox: {e}")
# [3] NC user check
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)
if nc_exists:
result.nc_result = True
else:
created = nc.create_user(account.sso_account, account.default_password, account.quota_limit)
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:
result.nc_result = False
result.nc_done_at = datetime.utcnow()
result.nc_done_at = now_tw()
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:
nc = NextcloudClient(tenant.domain)
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:
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)
except Exception as e:
logger.warning(f"Quota check failed for {account.account_code}: {e}")

View File

@@ -5,6 +5,7 @@ Part B: 伺服器 ping 檢查
"""
import logging
from datetime import datetime
from app.core.utils import now_tw
from sqlalchemy.orm import Session
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"],
result=result,
fail_reason=fail_reason,
recorded_at=datetime.utcnow(),
recorded_at=now_tw(),
))
# Part B: Server ping
@@ -87,7 +88,7 @@ def run_system_status(schedule_log_id: int, db: Session):
result=result,
response_time=response_time,
fail_reason=fail_reason,
recorded_at=datetime.utcnow(),
recorded_at=now_tw(),
))
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
from datetime import datetime
from app.core.utils import now_tw
from apscheduler.schedulers.background import BackgroundScheduler
from croniter import croniter
from sqlalchemy import update
@@ -24,7 +25,7 @@ def _watchdog_tick():
db.query(Schedule)
.filter(
Schedule.status == "Waiting",
Schedule.next_run_at <= datetime.utcnow(),
Schedule.next_run_at <= now_tw(),
)
.all()
)
@@ -44,7 +45,7 @@ def _watchdog_tick():
log = ScheduleLog(
schedule_id=schedule.id,
schedule_name=schedule.name,
started_at=datetime.utcnow(),
started_at=now_tw(),
status="running",
)
db.add(log)
@@ -60,12 +61,12 @@ def _watchdog_tick():
final_status = "error"
# Update log
log.ended_at = datetime.utcnow()
log.ended_at = now_tw()
log.status = final_status
# Recalculate next_run_at
# Recalculate next_run_at (5-field cron: 分 時 日 月 週)
try:
cron = croniter(schedule.cron_timer, datetime.utcnow())
cron = croniter(schedule.cron_timer, now_tw())
next_run = cron.get_next(datetime)
except Exception:
next_run = None
@@ -76,7 +77,7 @@ def _watchdog_tick():
.where(Schedule.id == schedule.id)
.values(
status="Waiting",
last_run_at=datetime.utcnow(),
last_run_at=now_tw(),
next_run_at=next_run,
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 app.core.utils import now_tw, configure_timezone
from croniter import croniter
from sqlalchemy.orm import Session
from app.models.schedule import Schedule
from app.models.server import Server
from app.models.settings import SystemSettings
INITIAL_SCHEDULES = [
@@ -26,7 +28,7 @@ INITIAL_SERVERS = [
def _calc_next_run(cron_timer: str) -> datetime:
# 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)
@@ -46,4 +48,13 @@ def seed_initial_data(db: Session) -> None:
if not db.get(Server, sv["id"]):
db.add(Server(**sv))
# Seed default system settings (id=1)
if not db.get(SystemSettings, 1):
db.add(SystemSettings(id=1))
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>