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:
@@ -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
|
||||
|
||||
33
backend/alembic/versions/002_add_system_settings.py
Normal file
33
backend/alembic/versions/002_add_system_settings.py
Normal 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')
|
||||
@@ -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')
|
||||
23
backend/alembic/versions/004_add_nc_mail_result.py
Normal file
23
backend/alembic/versions/004_add_nc_mail_result.py
Normal 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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
135
backend/app/api/v1/settings.py
Normal file
135
backend/app/api/v1/settings.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
18
backend/app/core/utils.py
Normal 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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
22
backend/app/models/settings.py
Normal file
22
backend/app/models/settings.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
31
backend/app/schemas/settings.py
Normal file
31
backend/app/schemas/settings.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
@@ -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 token(Resource 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", "")
|
||||
|
||||
@@ -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 的 mailbox(docker-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 不需要顯式建立 domain(mailbox 新增時自動處理)。
|
||||
新增一個 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
249
docker/radicale/README.md
Normal 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
|
||||
34
docker/radicale/config/config
Normal file
34
docker/radicale/config/config
Normal 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
|
||||
44
docker/radicale/docker-compose.yml
Normal file
44
docker/radicale/docker-compose.yml
Normal 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
|
||||
254
docs/WebMail_SSO_Integration_Guide.md
Normal file
254
docs/WebMail_SSO_Integration_Guide.md
Normal 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 登入測試成功
|
||||
- [ ] 郵件收發測試成功
|
||||
225
docs/architecture/01-系統架構設計.md
Normal file
225
docs/architecture/01-系統架構設計.md
Normal 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. 開發環境建置
|
||||
213
docs/business/商業計畫.md
Normal file
213
docs/business/商業計畫.md
Normal 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
373
docs/開發規範.md
Normal 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/)
|
||||
8
frontend/admin-portal/.env.production
Normal file
8
frontend/admin-portal/.env.production
Normal 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
39
frontend/admin-portal/.gitignore
vendored
Normal 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
|
||||
4
frontend/admin-portal/START_FRONTEND.bat
Normal file
4
frontend/admin-portal/START_FRONTEND.bat
Normal 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
|
||||
284
frontend/admin-portal/accounts.html
Normal file
284
frontend/admin-portal/accounts.html
Normal 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>
|
||||
161
frontend/admin-portal/css/style.css
Normal file
161
frontend/admin-portal/css/style.css
Normal 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; }
|
||||
BIN
frontend/admin-portal/img/logo.png
Normal file
BIN
frontend/admin-portal/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
198
frontend/admin-portal/index.html
Normal file
198
frontend/admin-portal/index.html
Normal 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>
|
||||
184
frontend/admin-portal/js/api.js
Normal file
184
frontend/admin-portal/js/api.js
Normal 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);
|
||||
}
|
||||
251
frontend/admin-portal/schedule-logs.html
Normal file
251
frontend/admin-portal/schedule-logs.html
Normal 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>
|
||||
177
frontend/admin-portal/schedules.html
Normal file
177
frontend/admin-portal/schedules.html
Normal 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>
|
||||
253
frontend/admin-portal/servers.html
Normal file
253
frontend/admin-portal/servers.html
Normal 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>
|
||||
324
frontend/admin-portal/settings.html
Normal file
324
frontend/admin-portal/settings.html
Normal 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>
|
||||
163
frontend/admin-portal/system-status.html
Normal file
163
frontend/admin-portal/system-status.html
Normal 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>
|
||||
321
frontend/admin-portal/tenants.html
Normal file
321
frontend/admin-portal/tenants.html
Normal 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>
|
||||
Reference in New Issue
Block a user