Compare commits

...
2 Commits
Author SHA1 Message Date
kfj001 562c28b6cb additional files 2026-07-04 18:40:40 +00:00
kfj001 7a04fa7c84 sets default admin creds in docker compose. first pass remote builder feature 2026-07-04 18:40:29 +00:00
17 changed files with 1856 additions and 410 deletions
+39
View File
@@ -50,6 +50,45 @@ Nginx access logs → shared volume → FastAPI log parser → PostgreSQL → ad
**Injecting test data:** Run `backend/inject_test_logs.py` (or the inline docker compose command in README.md) to write test log entries for yesterday, then trigger parsing via `GET /api/parse`. **Injecting test data:** Run `backend/inject_test_logs.py` (or the inline docker compose command in README.md) to write test log entries for yesterday, then trigger parsing via `GET /api/parse`.
### Admin dashboard — mobile build orchestration (implemented)
Admin users can create mobile build requests, upload platform signing files, run preflight checks, trigger remote builds, monitor logs/status, and download generated artifacts.
**High-level flow:**
1. Admin creates a build request (platforms, identifiers, version/build numbers, release profile).
2. Admin uploads exactly two files per selected platform:
- Android: keystore + descriptor JSON
- iOS: `.p12` certificate + `.mobileprovision` profile
3. Backend preflight validates request completeness, file presence, platform requirements, and basic iOS CarPlay entitlement marker.
4. Backend dispatches build via SSH to external build workers:
- Android worker runs `./do_android_build.sh`
- iOS worker runs `./do_ios_build.sh`
5. Backend copies generated `.aab`/`.ipa` artifacts back, stores metadata/checksums, and exposes admin download URLs.
**SSH contract (current implementation):**
- Connect as user `buildbot` (password-auth expected in the build-control environment).
- Remote app directory is `~/mobile_app`.
- Uploaded signing files are copied into `~/mobile_app` before script execution.
- Build scripts are executed from `~/mobile_app`.
**Key backend files:**
- [backend/app/api/mobile_builds.py](backend/app/api/mobile_builds.py) — mobile build endpoints, preflight, SSH execution, artifact collection
- [backend/app/models.py](backend/app/models.py) — `MobileBuildRequest`, `MobileBuildUploadedFile`, `MobileBuildArtifact`
- [backend/app/schemas.py](backend/app/schemas.py) — mobile build request/response schemas
- [backend/app/config.py](backend/app/config.py) — `KMTN_MOBILE_BUILD_*` settings (hosts, scripts, paths)
- [backend/app/main.py](backend/app/main.py) — router registration at `/api/mobile-builds`
**Key frontend files:**
- [src/app/admin/admin.component.ts](src/app/admin/admin.component.ts) — mobile build tab state/actions
- [src/app/admin/admin.component.html](src/app/admin/admin.component.html) — upload/preflight/trigger/logs/download UI
- [src/app/admin/admin.component.scss](src/app/admin/admin.component.scss) — mobile build panel styling
- [src/app/services/mobile-build.service.ts](src/app/services/mobile-build.service.ts) — API client for mobile build endpoints
- [src/app/interfaces/mobile-build.ts](src/app/interfaces/mobile-build.ts) — frontend build models
**Current scope:**
- Build artifact generation only (`.aab` and `.ipa`).
- No automatic App Store Connect / Google Play upload.
## Common tasks ## Common tasks
- **Re-skin:** Edit colors in `_variables.scss` only. - **Re-skin:** Edit colors in `_variables.scss` only.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+618
View File
@@ -0,0 +1,618 @@
import asyncio
import hashlib
import os
import shlex
import subprocess
import uuid
from datetime import datetime
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.config import settings
from app.database import async_session, get_session
from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig
from app.schemas import (
MobileBuildArtifactResponse,
MobileBuildCreate,
MobileBuildDefaultsResponse,
MobileBuildPreflightResponse,
MobileBuildRequestResponse,
MobileBuildUploadedFileResponse,
)
from app.user_models import User
router = APIRouter()
_ALLOWED_KINDS = {
"android": {"keystore", "descriptor"},
"ios": {"certificate", "profile"},
}
_RUNNING_TASKS: set[int] = set()
def _utc_iso(value: datetime | None) -> str | None:
if value is None:
return None
return value.replace(microsecond=0).isoformat() + "Z"
def _hash_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _ensure_dirs() -> tuple[Path, Path]:
upload_dir = Path(settings.MOBILE_BUILD_UPLOAD_DIR)
artifact_dir = Path(settings.MOBILE_BUILD_ARTIFACT_DIR)
upload_dir.mkdir(parents=True, exist_ok=True)
artifact_dir.mkdir(parents=True, exist_ok=True)
return upload_dir, artifact_dir
def _check_mobileprovision_for_carplay(path: Path) -> bool:
try:
raw = path.read_bytes()
except Exception:
return False
text = raw.decode("latin-1", errors="ignore").lower()
return "carplay" in text
def _build_ssh_prefix(host: str) -> list[str]:
if not host:
raise RuntimeError("Build host is not configured")
return [
"sshpass",
"-p",
settings.MOBILE_BUILD_SSH_PASSWORD,
"ssh",
"-p",
str(settings.MOBILE_BUILD_SSH_PORT),
"-o",
"StrictHostKeyChecking=no",
f"{settings.MOBILE_BUILD_SSH_USER}@{host}",
]
def _build_scp_prefix(host: str) -> list[str]:
if not host:
raise RuntimeError("Build host is not configured")
return [
"sshpass",
"-p",
settings.MOBILE_BUILD_SSH_PASSWORD,
"scp",
"-P",
str(settings.MOBILE_BUILD_SSH_PORT),
"-o",
"StrictHostKeyChecking=no",
]
def _run_sync_command(cmd: list[str]) -> tuple[int, str]:
result = subprocess.run(cmd, capture_output=True, text=True)
output = (result.stdout or "") + ("\n" + result.stderr if result.stderr else "")
return result.returncode, output.strip()
async def _run_command(cmd: list[str]) -> tuple[int, str]:
return await asyncio.to_thread(_run_sync_command, cmd)
async def _append_log(session: AsyncSession, request: MobileBuildRequest, line: str) -> None:
if request.build_log:
request.build_log += "\n"
request.build_log += line
request.updated_at = datetime.utcnow()
await session.commit()
async def _load_files(session: AsyncSession, request_id: int) -> list[MobileBuildUploadedFile]:
result = await session.execute(
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request_id)
)
return list(result.scalars().all())
def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUploadedFile]) -> list[str]:
issues: list[str] = []
if not request.platform_android and not request.platform_ios:
issues.append("Select at least one platform.")
if request.platform_android and not request.android_application_id:
issues.append("Android application ID is required when Android is selected.")
if request.platform_ios and not request.ios_bundle_id:
issues.append("iOS bundle ID is required when iOS is selected.")
if not request.version_name.strip():
issues.append("Version name is required.")
if not request.build_number.strip():
issues.append("Build number is required.")
if request.platform_android and not settings.MOBILE_BUILD_ANDROID_HOST:
issues.append("Android build host is not configured.")
if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST:
issues.append("iOS build host is not configured.")
grouped: dict[str, dict[str, MobileBuildUploadedFile]] = {"android": {}, "ios": {}}
for f in files:
grouped.setdefault(f.platform, {})[f.file_kind] = f
if request.platform_android:
present = set(grouped.get("android", {}).keys())
expected = _ALLOWED_KINDS["android"]
if present != expected:
missing = sorted(expected - present)
extra = sorted(present - expected)
if missing:
issues.append(f"Android requires files for kinds: {', '.join(missing)}")
if extra:
issues.append(f"Android has unsupported file kinds: {', '.join(extra)}")
if request.platform_ios:
present = set(grouped.get("ios", {}).keys())
expected = _ALLOWED_KINDS["ios"]
if present != expected:
missing = sorted(expected - present)
extra = sorted(present - expected)
if missing:
issues.append(f"iOS requires files for kinds: {', '.join(missing)}")
if extra:
issues.append(f"iOS has unsupported file kinds: {', '.join(extra)}")
profile = grouped.get("ios", {}).get("profile")
if profile:
profile_path = Path(profile.stored_path)
if not _check_mobileprovision_for_carplay(profile_path):
issues.append("iOS provisioning profile does not appear to include CarPlay entitlement text.")
return issues
def _to_file_response(row: MobileBuildUploadedFile) -> MobileBuildUploadedFileResponse:
return MobileBuildUploadedFileResponse(
id=row.id,
request_id=row.request_id,
platform=row.platform,
file_kind=row.file_kind,
original_name=row.original_name,
size=row.size,
uploaded_at=_utc_iso(row.uploaded_at) or "",
)
def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactResponse:
return MobileBuildArtifactResponse(
id=row.id,
request_id=row.request_id,
platform=row.platform,
artifact_type=row.artifact_type,
file_name=row.file_name,
sha256=row.sha256,
size=row.size,
created_at=_utc_iso(row.created_at) or "",
download_url=f"/api/mobile-builds/artifacts/{row.id}/download",
)
async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -> MobileBuildRequestResponse:
files_result = await session.execute(
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request.id)
)
artifact_result = await session.execute(
select(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request.id)
)
files = list(files_result.scalars().all())
artifacts = list(artifact_result.scalars().all())
return MobileBuildRequestResponse(
id=request.id,
tenant_key=request.tenant_key,
platform_android=request.platform_android,
platform_ios=request.platform_ios,
android_application_id=request.android_application_id,
ios_bundle_id=request.ios_bundle_id,
version_name=request.version_name,
build_number=request.build_number,
release_profile=request.release_profile,
status=request.status,
preflight_passed=request.preflight_passed,
preflight_report=request.preflight_report,
build_log=request.build_log,
error_message=request.error_message,
created_at=_utc_iso(request.created_at) or "",
updated_at=_utc_iso(request.updated_at) or "",
started_at=_utc_iso(request.started_at),
completed_at=_utc_iso(request.completed_at),
files=[_to_file_response(f) for f in files],
artifacts=[_to_artifact_response(a) for a in artifacts],
)
async def _copy_worker_artifact(
session: AsyncSession,
request: MobileBuildRequest,
platform: Literal["android", "ios"],
host: str,
artifact_glob: str,
) -> None:
_, artifact_dir = _ensure_dirs()
local_dir = artifact_dir / f"request_{request.id}" / platform
local_dir.mkdir(parents=True, exist_ok=True)
ssh_prefix = _build_ssh_prefix(host)
list_cmd = ssh_prefix + [f"ls -1t {shlex.quote(artifact_glob)} 2>/dev/null | head -n 1"]
code, out = await _run_command(list_cmd)
if code != 0 or not out:
raise RuntimeError(f"No {platform} artifact found using glob {artifact_glob}")
remote_path = out.splitlines()[-1].strip()
if not remote_path:
raise RuntimeError(f"No {platform} artifact path returned by worker")
file_name = Path(remote_path).name
local_path = local_dir / file_name
scp_cmd = _build_scp_prefix(host) + [
f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}",
str(local_path),
]
code, scp_out = await _run_command(scp_cmd)
if code != 0:
raise RuntimeError(f"Failed to copy {platform} artifact: {scp_out}")
artifact = MobileBuildArtifact(
request_id=request.id,
platform=platform,
artifact_type="aab" if platform == "android" else "ipa",
file_name=file_name,
file_path=str(local_path),
sha256=_hash_file(local_path),
size=local_path.stat().st_size,
)
session.add(artifact)
await session.commit()
async def _upload_files_to_worker(
files: list[MobileBuildUploadedFile], host: str
) -> None:
scp_prefix = _build_scp_prefix(host)
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/"
for row in files:
local_path = Path(row.stored_path)
if not local_path.exists():
raise RuntimeError(f"Uploaded file missing: {row.original_name}")
cmd = scp_prefix + [str(local_path), remote_target]
code, out = await _run_command(cmd)
if code != 0:
raise RuntimeError(f"Failed to upload file {row.original_name}: {out}")
async def _run_platform_build(
session: AsyncSession,
request: MobileBuildRequest,
platform: Literal["android", "ios"],
host: str,
script_name: str,
) -> None:
files = await _load_files(session, request.id)
platform_files = [f for f in files if f.platform == platform]
await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker")
await _upload_files_to_worker(platform_files, host)
ssh_prefix = _build_ssh_prefix(host)
cmd = ssh_prefix + [
(
f"cd {settings.MOBILE_BUILD_REMOTE_APP_DIR} && "
f"chmod +x ./{script_name} && "
f"./{script_name}"
)
]
await _append_log(session, request, f"[{platform}] Executing {script_name} on {host}")
code, out = await _run_command(cmd)
await _append_log(session, request, f"[{platform}] Exit code {code}\n{out}")
if code != 0:
raise RuntimeError(f"{platform} build failed with exit code {code}")
artifact_glob = (
settings.MOBILE_BUILD_ANDROID_ARTIFACT_GLOB
if platform == "android"
else settings.MOBILE_BUILD_IOS_ARTIFACT_GLOB
)
await _copy_worker_artifact(session, request, platform, host, artifact_glob)
await _append_log(session, request, f"[{platform}] Artifact copied successfully")
async def _execute_request(request_id: int) -> None:
async with async_session() as session:
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
return
request.status = "building"
request.error_message = None
request.started_at = datetime.utcnow()
request.updated_at = datetime.utcnow()
await session.commit()
try:
if request.platform_android:
await _run_platform_build(
session,
request,
"android",
settings.MOBILE_BUILD_ANDROID_HOST,
settings.MOBILE_BUILD_ANDROID_SCRIPT,
)
if request.platform_ios:
await _run_platform_build(
session,
request,
"ios",
settings.MOBILE_BUILD_IOS_HOST,
settings.MOBILE_BUILD_IOS_SCRIPT,
)
request.status = "completed"
request.completed_at = datetime.utcnow()
request.updated_at = datetime.utcnow()
await session.commit()
except Exception as exc:
request.status = "failed"
request.error_message = str(exc)
request.completed_at = datetime.utcnow()
request.updated_at = datetime.utcnow()
await _append_log(session, request, f"Build failed: {exc}")
await session.commit()
finally:
_RUNNING_TASKS.discard(request_id)
@router.get("/defaults", response_model=MobileBuildDefaultsResponse)
async def get_mobile_build_defaults(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
cfg = result.scalar_one_or_none()
if cfg is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Station config not found")
app_name = f"{cfg.name_primary} {cfg.name_secondary}".strip()
return MobileBuildDefaultsResponse(
tenant_key=cfg.key,
app_name=app_name,
tagline=cfg.tagline,
support_email=cfg.email,
website=cfg.website,
stream_url=cfg.stream_url,
stream_metadata_url=cfg.stream_metadata_url,
)
@router.get("/requests", response_model=list[MobileBuildRequestResponse])
async def list_mobile_build_requests(
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).order_by(MobileBuildRequest.created_at.desc())
)
rows = list(result.scalars().all())
return [await _hydrate_request(session, row) for row in rows]
@router.post("/requests", response_model=MobileBuildRequestResponse, status_code=201)
async def create_mobile_build_request(
payload: MobileBuildCreate,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_admin_user),
):
request = MobileBuildRequest(
tenant_key="default",
platform_android=payload.platform_android,
platform_ios=payload.platform_ios,
android_application_id=payload.android_application_id,
ios_bundle_id=payload.ios_bundle_id,
version_name=payload.version_name,
build_number=payload.build_number,
release_profile=payload.release_profile,
status="draft",
created_by_id=user.id,
updated_at=datetime.utcnow(),
)
session.add(request)
await session.commit()
await session.refresh(request)
return await _hydrate_request(session, request)
@router.get("/requests/{request_id}", response_model=MobileBuildRequestResponse)
async def get_mobile_build_request(
request_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
return await _hydrate_request(session, request)
@router.post("/requests/{request_id}/files", response_model=MobileBuildUploadedFileResponse)
async def upload_mobile_build_file(
request_id: int,
platform: Literal["android", "ios"] = Query(...),
file_kind: str = Query(...),
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
if file_kind not in _ALLOWED_KINDS[platform]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file_kind '{file_kind}' for platform '{platform}'",
)
request_result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = request_result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
upload_dir, _ = _ensure_dirs()
req_dir = upload_dir / f"request_{request_id}"
req_dir.mkdir(parents=True, exist_ok=True)
suffix = Path(file.filename or "upload.bin").suffix
local_name = f"{platform}_{file_kind}_{uuid.uuid4().hex}{suffix}"
local_path = req_dir / local_name
payload = await file.read()
local_path.write_bytes(payload)
sha = hashlib.sha256(payload).hexdigest()
size = len(payload)
await session.execute(
delete(MobileBuildUploadedFile).where(
MobileBuildUploadedFile.request_id == request_id,
MobileBuildUploadedFile.platform == platform,
MobileBuildUploadedFile.file_kind == file_kind,
)
)
row = MobileBuildUploadedFile(
request_id=request_id,
platform=platform,
file_kind=file_kind,
original_name=file.filename or local_name,
stored_path=str(local_path),
sha256=sha,
size=size,
)
session.add(row)
request.preflight_passed = False
request.preflight_report = ""
request.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(row)
return _to_file_response(row)
@router.post("/requests/{request_id}/preflight", response_model=MobileBuildPreflightResponse)
async def preflight_mobile_build(
request_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
files = await _load_files(session, request_id)
issues = _preflight_issues(request, files)
request.preflight_passed = len(issues) == 0
request.preflight_report = "\n".join(issues)
request.updated_at = datetime.utcnow()
await session.commit()
return MobileBuildPreflightResponse(passed=request.preflight_passed, issues=issues)
@router.post("/requests/{request_id}/trigger", response_model=MobileBuildRequestResponse)
async def trigger_mobile_build(
request_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
files = await _load_files(session, request_id)
issues = _preflight_issues(request, files)
if issues:
request.preflight_passed = False
request.preflight_report = "\n".join(issues)
request.updated_at = datetime.utcnow()
await session.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=issues)
if request.status in {"queued", "building"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Build is already in progress")
request.status = "queued"
request.preflight_passed = True
request.preflight_report = ""
request.error_message = None
request.build_log = ""
request.started_at = None
request.completed_at = None
request.updated_at = datetime.utcnow()
await session.execute(
delete(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request_id)
)
await session.commit()
if request_id not in _RUNNING_TASKS:
_RUNNING_TASKS.add(request_id)
asyncio.create_task(_execute_request(request_id))
return await _hydrate_request(session, request)
@router.get("/artifacts/{artifact_id}/download")
async def download_mobile_build_artifact(
artifact_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id)
)
row = result.scalar_one_or_none()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact not found")
path = Path(row.file_path)
if not path.exists():
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Artifact file no longer exists")
return FileResponse(path=str(path), filename=row.file_name, media_type="application/octet-stream")
+14
View File
@@ -18,6 +18,20 @@ class Settings(BaseSettings):
LOGS_DIR: str = "/logs" LOGS_DIR: str = "/logs"
GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb" GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb"
# Mobile build orchestration
MOBILE_BUILD_SSH_USER: str = "buildbot"
MOBILE_BUILD_SSH_PASSWORD: str = "buildbot"
MOBILE_BUILD_SSH_PORT: int = 22
MOBILE_BUILD_ANDROID_HOST: str = ""
MOBILE_BUILD_IOS_HOST: str = ""
MOBILE_BUILD_REMOTE_APP_DIR: str = "~/mobile_app"
MOBILE_BUILD_ANDROID_SCRIPT: str = "do_android_build.sh"
MOBILE_BUILD_IOS_SCRIPT: str = "do_ios_build.sh"
MOBILE_BUILD_UPLOAD_DIR: str = "/tmp/kmtn_mobile_build_uploads"
MOBILE_BUILD_ARTIFACT_DIR: str = "/tmp/kmtn_mobile_build_artifacts"
MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "~/mobile_app/build/app/outputs/bundle/release/*.aab"
MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "~/mobile_app/build/ios/ipa/*.ipa"
model_config = {"env_prefix": "KMTN_"} model_config = {"env_prefix": "KMTN_"}
+2 -1
View File
@@ -10,7 +10,7 @@ from app.config import settings
from app.database import async_session, engine from app.database import async_session, engine
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
from app.user_models import User from app.user_models import User
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds
def _cleanup_orphaned_sequences(sync_conn): def _cleanup_orphaned_sequences(sync_conn):
@@ -223,6 +223,7 @@ app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
app.include_router(storage.router, prefix="/api/storage", tags=["storage"]) app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"]) app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
# Dev-only admin router (disabled in production) # Dev-only admin router (disabled in production)
if settings.ENV != "production": if settings.ENV != "production":
+60
View File
@@ -235,3 +235,63 @@ class LogParseState(Base):
key = Column(String(20), unique=True, nullable=False, default="default") key = Column(String(20), unique=True, nullable=False, default="default")
last_parsed_date = Column(Date, nullable=False) last_parsed_date = Column(Date, nullable=False)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# ── Mobile Build Orchestration ─────────────────────────────────
class MobileBuildRequest(Base):
__tablename__ = "mobile_build_requests"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_key = Column(String(80), nullable=False, default="default")
platform_android = Column(Boolean, nullable=False, default=False)
platform_ios = Column(Boolean, nullable=False, default=False)
android_application_id = Column(String(200), nullable=True)
ios_bundle_id = Column(String(200), nullable=True)
version_name = Column(String(40), nullable=False)
build_number = Column(String(40), nullable=False)
release_profile = Column(String(80), nullable=False, default="release")
status = Column(String(20), nullable=False, default="draft")
preflight_passed = Column(Boolean, nullable=False, default=False)
preflight_report = Column(Text, nullable=False, default="")
build_log = Column(Text, nullable=False, default="")
error_message = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
class MobileBuildUploadedFile(Base):
__tablename__ = "mobile_build_uploaded_files"
id = Column(Integer, primary_key=True, autoincrement=True)
request_id = Column(Integer, ForeignKey("mobile_build_requests.id", ondelete="CASCADE"), nullable=False, index=True)
platform = Column(String(20), nullable=False)
file_kind = Column(String(40), nullable=False)
original_name = Column(String(260), nullable=False)
stored_path = Column(String(800), nullable=False)
sha256 = Column(String(64), nullable=False)
size = Column(Integer, nullable=False)
uploaded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
UniqueConstraint("request_id", "platform", "file_kind", name="uq_mobile_build_file_kind"),
)
class MobileBuildArtifact(Base):
__tablename__ = "mobile_build_artifacts"
id = Column(Integer, primary_key=True, autoincrement=True)
request_id = Column(Integer, ForeignKey("mobile_build_requests.id", ondelete="CASCADE"), nullable=False, index=True)
platform = Column(String(20), nullable=False)
artifact_type = Column(String(20), nullable=False)
file_name = Column(String(260), nullable=False)
file_path = Column(String(800), nullable=False)
sha256 = Column(String(64), nullable=False)
size = Column(Integer, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+72
View File
@@ -348,3 +348,75 @@ class GeoStatResponse(BaseModel):
class ParseResultResponse(BaseModel): class ParseResultResponse(BaseModel):
files_processed: int files_processed: int
rows_inserted: int rows_inserted: int
# ── Mobile Build Orchestration ───────────────────────────
class MobileBuildDefaultsResponse(BaseModel):
tenant_key: str
app_name: str
tagline: str
support_email: str
website: str
stream_url: str
stream_metadata_url: str
class MobileBuildCreate(BaseModel):
platform_android: bool = False
platform_ios: bool = False
android_application_id: Optional[str] = None
ios_bundle_id: Optional[str] = None
version_name: str
build_number: str
release_profile: str = "release"
class MobileBuildUploadedFileResponse(BaseModel):
id: int
request_id: int
platform: str
file_kind: str
original_name: str
size: int
uploaded_at: str
class MobileBuildArtifactResponse(BaseModel):
id: int
request_id: int
platform: str
artifact_type: str
file_name: str
sha256: str
size: int
created_at: str
download_url: str
class MobileBuildRequestResponse(BaseModel):
id: int
tenant_key: str
platform_android: bool
platform_ios: bool
android_application_id: Optional[str]
ios_bundle_id: Optional[str]
version_name: str
build_number: str
release_profile: str
status: str
preflight_passed: bool
preflight_report: str
build_log: str
error_message: Optional[str]
created_at: str
updated_at: str
started_at: Optional[str]
completed_at: Optional[str]
files: list[MobileBuildUploadedFileResponse]
artifacts: list[MobileBuildArtifactResponse]
class MobileBuildPreflightResponse(BaseModel):
passed: bool
issues: list[str]
+2
View File
@@ -28,6 +28,8 @@ services:
KMTN_DATABASE_URL: >- KMTN_DATABASE_URL: >-
postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]' KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
KMTN_ADMIN_USERNAME : "admin"
KMTN_ADMIN_PASSWORD : "123"
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
+235 -77
View File
@@ -7,51 +7,23 @@
<!-- Tabs --> <!-- Tabs -->
<div class="admin-tabs"> <div class="admin-tabs">
<button <button class="tab-btn" [class.active]="activeTab() === 'shows'" (click)="activeTab.set('shows')">Shows</button>
class="tab-btn" <button class="tab-btn" [class.active]="activeTab() === 'events'"
[class.active]="activeTab() === 'shows'" (click)="activeTab.set('events')">Events</button>
(click)="activeTab.set('shows')" <button class="tab-btn" [class.active]="activeTab() === 'history'"
>Shows</button> (click)="activeTab.set('history')">History</button>
<button <button class="tab-btn" [class.active]="activeTab() === 'team'" (click)="activeTab.set('team')">Team</button>
class="tab-btn" <button class="tab-btn" [class.active]="activeTab() === 'community'"
[class.active]="activeTab() === 'events'" (click)="activeTab.set('community')">Community</button>
(click)="activeTab.set('events')" <button class="tab-btn" [class.active]="activeTab() === 'station'"
>Events</button> (click)="activeTab.set('station')">Station</button>
<button <button class="tab-btn" [class.active]="activeTab() === 'theme'"
class="tab-btn" (click)="activeTab.set('theme'); onTabChanged()">Theme</button>
[class.active]="activeTab() === 'history'" <button class="tab-btn" [class.active]="activeTab() === 'underwriters'"
(click)="activeTab.set('history')" (click)="activeTab.set('underwriters')">Underwriters</button>
>History</button> <button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'"
<button (click)="activeTab.set('mobile-builds')">Mobile Builds</button>
class="tab-btn" <button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
[class.active]="activeTab() === 'team'"
(click)="activeTab.set('team')"
>Team</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'community'"
(click)="activeTab.set('community')"
>Community</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'station'"
(click)="activeTab.set('station')"
>Station</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'theme'"
(click)="activeTab.set('theme'); onTabChanged()"
>Theme</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'underwriters'"
(click)="activeTab.set('underwriters')"
>Underwriters</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'stats'"
(click)="activeTab.set('stats')"
>Stats</button>
</div> </div>
<!-- Shows Tab --> <!-- Shows Tab -->
@@ -92,7 +64,9 @@
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td></tr> <tr>
<td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -132,7 +106,9 @@
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td></tr> <tr>
<td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -172,7 +148,9 @@
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td></tr> <tr>
<td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -212,7 +190,9 @@
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td></tr> <tr>
<td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -252,7 +232,9 @@
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td></tr> <tr>
<td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -336,10 +318,7 @@
<span class="harmony-rule">{{ getHarmonyLabel() }}</span> <span class="harmony-rule">{{ getHarmonyLabel() }}</span>
</div> </div>
<label class="auto-variants-toggle"> <label class="auto-variants-toggle">
<input <input type="checkbox" [(ngModel)]="themeAutoVariants">
type="checkbox"
[(ngModel)]="themeAutoVariants"
>
Auto-generate variants Auto-generate variants
</label> </label>
</div> </div>
@@ -356,11 +335,8 @@
@for (role of colorRoles; track role.field) { @for (role of colorRoles; track role.field) {
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)"> <div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
<div class="color-swatch" [style.background]="themeColors[role.field]"> <div class="color-swatch" [style.background]="themeColors[role.field]">
<input <input type="color" [value]="themeColors[role.field]"
type="color" (input)="onThemeColorChange(role.field, $event.target.value)">
[value]="themeColors[role.field]"
(input)="onThemeColorChange(role.field, $event.target.value)"
>
</div> </div>
<div class="color-info"> <div class="color-info">
<span class="color-name">{{ role.label }}</span> <span class="color-name">{{ role.label }}</span>
@@ -378,22 +354,9 @@
</div> </div>
<div class="theme-actions"> <div class="theme-actions">
<button <button type="button" class="btn btn-outline-theme" (click)="resetTheme()">Reset to Defaults</button>
type="button" <button type="button" class="btn btn-outline-theme" (click)="autoFixTheme()">Auto-Fix Harmony</button>
class="btn btn-outline-theme" <button type="button" class="btn btn-save-theme" (click)="saveTheme()" [disabled]="themeSaving">
(click)="resetTheme()"
>Reset to Defaults</button>
<button
type="button"
class="btn btn-outline-theme"
(click)="autoFixTheme()"
>Auto-Fix Harmony</button>
<button
type="button"
class="btn btn-save-theme"
(click)="saveTheme()"
[disabled]="themeSaving"
>
{{ themeSaving ? 'Saving…' : 'Save Theme' }} {{ themeSaving ? 'Saving…' : 'Save Theme' }}
</button> </button>
</div> </div>
@@ -440,7 +403,9 @@
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td></tr> <tr>
<td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -448,6 +413,198 @@
} }
} }
<!-- Mobile Builds Tab -->
@if (activeTab() === 'mobile-builds') {
<div class="tab-content mobile-builds-panel">
<div class="tab-toolbar">
<h2>Mobile Build Orchestration</h2>
<button class="btn btn-add" (click)="reloadMobileBuilds()">Refresh Requests</button>
</div>
<div class="mobile-build-grid">
<section class="build-card">
<h3>Derived Defaults</h3>
@if (mobileBuildDefaults; as defaults) {
<div class="derived-list">
<div><strong>Tenant:</strong> {{ defaults.tenant_key }}</div>
<div><strong>App Name:</strong> {{ defaults.app_name }}</div>
<div><strong>Support Email:</strong> {{ defaults.support_email }}</div>
<div><strong>Website:</strong> {{ defaults.website }}</div>
<div><strong>Stream URL:</strong> {{ defaults.stream_url || '(not set)' }}</div>
</div>
} @else {
<p class="empty-row">Defaults unavailable. Check station config.</p>
}
</section>
<section class="build-card">
<h3>Create Build Request</h3>
<div class="form-row platform-row">
<label>
<input type="checkbox" [(ngModel)]="buildAndroid">
Android
</label>
<label>
<input type="checkbox" [(ngModel)]="buildIos">
iOS
</label>
</div>
<div class="form-row">
<label>
Android Application ID
<input type="text" [(ngModel)]="androidApplicationId" placeholder="com.example.radio">
</label>
<label>
iOS Bundle ID
<input type="text" [(ngModel)]="iosBundleId" placeholder="com.example.radio">
</label>
</div>
<div class="form-row">
<label>
Version Name
<input type="text" [(ngModel)]="buildVersionName" placeholder="1.0.0">
</label>
<label>
Build Number
<input type="text" [(ngModel)]="buildNumber" placeholder="1">
</label>
<label>
Release Profile
<input type="text" [(ngModel)]="buildReleaseProfile" placeholder="release">
</label>
</div>
<h4>Required Files</h4>
<div class="form-row">
<label>
Android Keystore (.jks/.keystore)
<input type="file" (change)="onFileChosen($event, 'android-keystore')">
</label>
<label>
Android Descriptor (JSON)
<input type="file" accept=".json,application/json" (change)="onFileChosen($event, 'android-descriptor')">
</label>
</div>
<div class="form-row">
<label>
iOS Certificate (.p12)
<input type="file" accept=".p12" (change)="onFileChosen($event, 'ios-certificate')">
</label>
<label>
iOS Provisioning Profile (.mobileprovision)
<input type="file" accept=".mobileprovision" (change)="onFileChosen($event, 'ios-profile')">
</label>
</div>
@if (mobileBuildStatusMessage) {
<p class="status success">{{ mobileBuildStatusMessage }}</p>
}
@if (mobileBuildErrorMessage) {
<p class="status error">{{ mobileBuildErrorMessage }}</p>
}
<button class="btn btn-add" [disabled]="mobileBuildBusy" (click)="createAndUploadMobileBuildRequest()">
{{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }}
</button>
</section>
</div>
<section class="build-card">
<h3>Build Requests</h3>
@if (mobileBuilds$ | async; as builds) {
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Platforms</th>
<th>Version</th>
<th>Status</th>
<th>Preflight</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (build of builds; track build.id) {
<tr [class.selected-build]="selectedMobileBuildId === build.id">
<td>#{{ build.id }}</td>
<td>
{{ build.platform_android ? 'Android' : '' }}
{{ build.platform_android && build.platform_ios ? ' + ' : '' }}
{{ build.platform_ios ? 'iOS' : '' }}
</td>
<td>{{ build.version_name }} ({{ build.build_number }})</td>
<td>{{ build.status }}</td>
<td>{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="onMobileBuildSelected(build.id)">Select</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-row">No build requests yet.</td>
</tr>
}
</tbody>
</table>
}
</section>
@if (selectedMobileBuild; as build) {
<section class="build-card">
<div class="tab-toolbar compact">
<h3>Selected Request #{{ build.id }}</h3>
<div class="action-group">
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="runPreflightForSelectedBuild()">Run
Preflight</button>
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy || !build.preflight_passed"
(click)="triggerSelectedBuild()">Trigger Build</button>
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy"
(click)="refreshSelectedBuild()">Refresh</button>
</div>
</div>
@if (mobileBuildPreflightIssues.length > 0) {
<div class="status error">
<strong>Preflight issues:</strong>
<ul>
@for (issue of mobileBuildPreflightIssues; track issue) {
<li>{{ issue }}</li>
}
</ul>
</div>
}
@if (build.error_message) {
<p class="status error"><strong>Error:</strong> {{ build.error_message }}</p>
}
<h4>Artifacts</h4>
<div class="artifact-list">
@for (artifact of build.artifacts; track artifact.id) {
<div class="artifact-row">
<div>
<strong>{{ artifact.file_name }}</strong>
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
artifact.sha256.slice(0, 12) }}…</div>
</div>
<a class="btn-icon btn-edit" [href]="getArtifactDownloadHref(artifact.download_url)">Download</a>
</div>
} @empty {
<p class="empty-row">No artifacts available yet.</p>
}
</div>
<h4>Build Log</h4>
<pre class="build-log">{{ build.build_log || 'No logs yet.' }}</pre>
</section>
}
</div>
}
<!-- Stats Tab --> <!-- Stats Tab -->
@if (activeTab() === 'stats') { @if (activeTab() === 'stats') {
<div class="tab-content"> <div class="tab-content">
@@ -476,6 +633,7 @@
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" /> <app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
} }
@if (showUnderwriterForm) { @if (showUnderwriterForm) {
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" (closed)="onUnderwriterClosed()" /> <app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()"
(closed)="onUnderwriterClosed()" />
} }
</section> </section>
+140
View File
@@ -408,3 +408,143 @@
color: $neutral-white; color: $neutral-white;
} }
} }
// ── Mobile builds tab ───────────────────────────────────
.mobile-builds-panel {
display: grid;
gap: $spacing-lg;
}
.mobile-build-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: $spacing-md;
}
.build-card {
background: $neutral-white;
border: 1px solid $neutral-light;
border-radius: $radius-md;
padding: $spacing-md;
h3,
h4 {
color: $primary-blue;
margin-top: 0;
}
}
.derived-list {
display: grid;
gap: $spacing-xs;
font-size: 0.92rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: $spacing-sm;
margin-bottom: $spacing-sm;
label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
color: $neutral-dark;
font-weight: 600;
}
input[type='text'],
input[type='file'] {
border: 1px solid $neutral-light;
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
font-size: 0.88rem;
}
}
.platform-row {
grid-template-columns: auto auto;
label {
flex-direction: row;
align-items: center;
font-weight: 600;
}
}
.status {
border-radius: $radius-sm;
padding: $spacing-sm;
margin: $spacing-sm 0;
font-size: 0.9rem;
&.success {
background: rgba($success-green, 0.1);
color: $success-green;
}
&.error {
background: rgba($danger-red, 0.1);
color: $danger-red;
}
ul {
margin: $spacing-xs 0 0 $spacing-md;
}
}
.selected-build {
background: rgba($primary-blue, 0.06);
}
.tab-toolbar.compact {
margin-bottom: $spacing-sm;
}
.action-group {
display: flex;
gap: $spacing-xs;
}
.artifact-list {
display: grid;
gap: $spacing-sm;
margin-bottom: $spacing-md;
}
.artifact-row {
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid $neutral-light;
border-radius: $radius-sm;
padding: $spacing-sm;
}
.artifact-meta {
font-size: 0.78rem;
color: $neutral-medium;
}
.build-log {
background: $neutral-black;
color: $neutral-white;
border-radius: $radius-sm;
padding: $spacing-sm;
max-height: 280px;
overflow: auto;
font-size: 0.8rem;
}
@include responsive(md) {
.mobile-build-grid {
grid-template-columns: 1fr;
}
.action-group {
flex-wrap: wrap;
}
}
+209 -5
View File
@@ -4,12 +4,16 @@ import { FormsModule } from '@angular/forms';
import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Show } from '../interfaces/show'; import { Show } from '../interfaces/show';
import { Event } from '../interfaces/event'; import { Event as StationEvent } from '../interfaces/event';
import { StationConfig } from '../interfaces/station-config'; import { StationConfig } from '../interfaces/station-config';
import { HistoryEntry } from '../interfaces/history-entry'; import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member'; import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight'; import { CommunityHighlight } from '../interfaces/community-highlight';
import { Underwriter } from '../interfaces/underwriter'; import { Underwriter } from '../interfaces/underwriter';
import {
MobileBuildDefaults,
MobileBuildRequest,
} from '../interfaces/mobile-build';
import { ShowService } from '../services/show.service'; import { ShowService } from '../services/show.service';
import { EventService } from '../services/event.service'; import { EventService } from '../services/event.service';
import { StationConfigService } from '../services/station-config.service'; import { StationConfigService } from '../services/station-config.service';
@@ -18,6 +22,7 @@ import { HistoryEntryService } from '../services/history-entry.service';
import { TeamMemberService } from '../services/team-member.service'; import { TeamMemberService } from '../services/team-member.service';
import { CommunityHighlightService } from '../services/community-highlight.service'; import { CommunityHighlightService } from '../services/community-highlight.service';
import { UnderwriterService } from '../services/underwriter.service'; import { UnderwriterService } from '../services/underwriter.service';
import { MobileBuildService } from '../services/mobile-build.service';
import { AdminShowFormComponent } from './admin-show-form.component'; import { AdminShowFormComponent } from './admin-show-form.component';
import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminStationFormComponent } from './admin-station-form.component'; import { AdminStationFormComponent } from './admin-station-form.component';
@@ -38,8 +43,9 @@ import {
HarmonyRule, HarmonyRule,
ColorValidation, ColorValidation,
} from '../utils/color-harmony'; } from '../utils/color-harmony';
import { getAppConfig } from '../services/app-config.service';
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'stats'; type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'mobile-builds' | 'stats';
interface ThemeColorState { interface ThemeColorState {
color_primary: string; color_primary: string;
@@ -90,12 +96,13 @@ export class AdminComponent implements OnInit {
private teamMemberService = inject(TeamMemberService); private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService); private communityHighlightService = inject(CommunityHighlightService);
private underwriterService = inject(UnderwriterService); private underwriterService = inject(UnderwriterService);
private mobileBuildService = inject(MobileBuildService);
activeTab = signal<TabKey>('shows'); activeTab = signal<TabKey>('shows');
/** Item currently being edited (set by editXxx, cleared on save/close). */ /** Item currently being edited (set by editXxx, cleared on save/close). */
editingShow: Show | null = null; editingShow: Show | null = null;
editingEvent: Event | null = null; editingEvent: StationEvent | null = null;
editingHistory: HistoryEntry | null = null; editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | null = null; editingTeam: TeamMember | null = null;
editingCommunity: CommunityHighlight | null = null; editingCommunity: CommunityHighlight | null = null;
@@ -103,12 +110,34 @@ export class AdminComponent implements OnInit {
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */ /** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
readonly shows$ = new BehaviorSubject<Show[]>([]); readonly shows$ = new BehaviorSubject<Show[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]); readonly events$ = new BehaviorSubject<StationEvent[]>([]);
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null); readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]); readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]); readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]); readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]); readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
mobileBuildDefaults: MobileBuildDefaults | null = null;
selectedMobileBuildId: number | null = null;
selectedMobileBuild: MobileBuildRequest | null = null;
mobileBuildStatusMessage = '';
mobileBuildErrorMessage = '';
mobileBuildPreflightIssues: string[] = [];
mobileBuildBusy = false;
buildAndroid = true;
buildIos = true;
androidApplicationId = '';
iosBundleId = '';
buildVersionName = '1.0.0';
buildNumber = '1';
buildReleaseProfile = 'release';
androidKeystoreFile: File | null = null;
androidDescriptorFile: File | null = null;
iosCertificateFile: File | null = null;
iosProfileFile: File | null = null;
// ── Theme tab state ────────────────────────────────────────── // ── Theme tab state ──────────────────────────────────────────
@@ -209,9 +238,184 @@ export class AdminComponent implements OnInit {
this.reloadTeamMembers(), this.reloadTeamMembers(),
this.reloadCommunityHighlights(), this.reloadCommunityHighlights(),
this.reloadUnderwriters(), this.reloadUnderwriters(),
this.reloadMobileBuildDefaults(),
this.reloadMobileBuilds(),
]); ]);
} }
async reloadMobileBuildDefaults(): Promise<void> {
try {
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
} catch {
this.mobileBuildDefaults = null;
}
}
async reloadMobileBuilds(): Promise<void> {
const data = await firstValueFrom(this.mobileBuildService.listRequests());
this.mobileBuilds$.next(data);
if (this.selectedMobileBuildId !== null) {
const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null;
this.selectedMobileBuild = selected;
if (!selected) {
this.selectedMobileBuildId = null;
}
}
}
onMobileBuildSelected(buildId: number): void {
this.selectedMobileBuildId = buildId;
const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null;
this.selectedMobileBuild = selected;
this.mobileBuildPreflightIssues = selected?.preflight_report
? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0)
: [];
}
onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void {
const input = event.target as HTMLInputElement;
const file = input.files && input.files.length > 0 ? input.files[0] : null;
if (target === 'android-keystore') this.androidKeystoreFile = file;
if (target === 'android-descriptor') this.androidDescriptorFile = file;
if (target === 'ios-certificate') this.iosCertificateFile = file;
if (target === 'ios-profile') this.iosProfileFile = file;
}
private getErrorMessage(err: unknown, fallback: string): string {
if (typeof err === 'object' && err !== null) {
const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string };
const detail = maybeErr.error?.detail;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) return detail.join(' | ');
if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message;
if (typeof maybeErr.message === 'string') return maybeErr.message;
}
return fallback;
}
private resetMobileBuildMessages(): void {
this.mobileBuildStatusMessage = '';
this.mobileBuildErrorMessage = '';
}
private validateBuildInputs(): string | null {
if (!this.buildAndroid && !this.buildIos) {
return 'Select at least one platform.';
}
if (this.buildAndroid && !this.androidApplicationId.trim()) {
return 'Android application ID is required.';
}
if (this.buildIos && !this.iosBundleId.trim()) {
return 'iOS bundle ID is required.';
}
if (!this.buildVersionName.trim() || !this.buildNumber.trim()) {
return 'Version name and build number are required.';
}
if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) {
return 'Android requires both keystore and descriptor files.';
}
if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) {
return 'iOS requires both certificate and provisioning profile files.';
}
return null;
}
async createAndUploadMobileBuildRequest(): Promise<void> {
this.resetMobileBuildMessages();
this.mobileBuildPreflightIssues = [];
const inputError = this.validateBuildInputs();
if (inputError) {
this.mobileBuildErrorMessage = inputError;
return;
}
this.mobileBuildBusy = true;
try {
const request = await firstValueFrom(this.mobileBuildService.createRequest({
platform_android: this.buildAndroid,
platform_ios: this.buildIos,
android_application_id: this.androidApplicationId.trim() || undefined,
ios_bundle_id: this.iosBundleId.trim() || undefined,
version_name: this.buildVersionName.trim(),
build_number: this.buildNumber.trim(),
release_profile: this.buildReleaseProfile.trim() || 'release',
}));
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile));
}
if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) {
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile));
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile));
}
this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`;
await this.reloadMobileBuilds();
this.onMobileBuildSelected(request.id);
} catch (err) {
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
} finally {
this.mobileBuildBusy = false;
}
}
async runPreflightForSelectedBuild(): Promise<void> {
if (!this.selectedMobileBuildId) return;
this.resetMobileBuildMessages();
this.mobileBuildBusy = true;
try {
const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId));
this.mobileBuildPreflightIssues = result.issues;
this.mobileBuildStatusMessage = result.passed
? 'Preflight passed. Build can be triggered.'
: 'Preflight failed. Resolve the listed issues.';
await this.reloadMobileBuilds();
this.onMobileBuildSelected(this.selectedMobileBuildId);
} catch (err) {
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.');
} finally {
this.mobileBuildBusy = false;
}
}
async triggerSelectedBuild(): Promise<void> {
if (!this.selectedMobileBuildId) return;
this.resetMobileBuildMessages();
this.mobileBuildBusy = true;
try {
const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId));
this.mobileBuildStatusMessage = `Build #${request.id} queued.`;
await this.reloadMobileBuilds();
this.onMobileBuildSelected(this.selectedMobileBuildId);
} catch (err) {
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.');
} finally {
this.mobileBuildBusy = false;
}
}
async refreshSelectedBuild(): Promise<void> {
if (!this.selectedMobileBuildId) return;
await this.reloadMobileBuilds();
this.onMobileBuildSelected(this.selectedMobileBuildId);
}
getArtifactDownloadHref(path: string): string {
return `${getAppConfig().apiBaseUrl}${path}`;
}
// ── Show CRUD ─────────────────────────────────────────── // ── Show CRUD ───────────────────────────────────────────
openShowForm(): void { openShowForm(): void {
@@ -248,7 +452,7 @@ export class AdminComponent implements OnInit {
this.showEventForm = true; this.showEventForm = true;
} }
editEvent(event: Event): void { editEvent(event: StationEvent): void {
this.editingEvent = event; this.editingEvent = event;
this.showEventForm = true; this.showEventForm = true;
} }
+69
View File
@@ -0,0 +1,69 @@
export interface MobileBuildDefaults {
tenant_key: string;
app_name: string;
tagline: string;
support_email: string;
website: string;
stream_url: string;
stream_metadata_url: string;
}
export interface MobileBuildUploadedFile {
id: number;
request_id: number;
platform: 'android' | 'ios';
file_kind: string;
original_name: string;
size: number;
uploaded_at: string;
}
export interface MobileBuildArtifact {
id: number;
request_id: number;
platform: 'android' | 'ios';
artifact_type: string;
file_name: string;
sha256: string;
size: number;
created_at: string;
download_url: string;
}
export interface MobileBuildRequest {
id: number;
tenant_key: string;
platform_android: boolean;
platform_ios: boolean;
android_application_id: string | null;
ios_bundle_id: string | null;
version_name: string;
build_number: string;
release_profile: string;
status: string;
preflight_passed: boolean;
preflight_report: string;
build_log: string;
error_message: string | null;
created_at: string;
updated_at: string;
started_at: string | null;
completed_at: string | null;
files: MobileBuildUploadedFile[];
artifacts: MobileBuildArtifact[];
}
export interface MobileBuildCreatePayload {
platform_android: boolean;
platform_ios: boolean;
android_application_id?: string;
ios_bundle_id?: string;
version_name: string;
build_number: string;
release_profile: string;
}
export interface MobileBuildPreflightResult {
passed: boolean;
issues: string[];
}
+69
View File
@@ -0,0 +1,69 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { getAppConfig } from './app-config.service';
import {
MobileBuildCreatePayload,
MobileBuildDefaults,
MobileBuildPreflightResult,
MobileBuildRequest,
MobileBuildUploadedFile,
} from '../interfaces/mobile-build';
@Injectable({
providedIn: 'root',
})
export class MobileBuildService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/mobile-builds`;
getDefaults(): Observable<MobileBuildDefaults> {
return this.http.get<MobileBuildDefaults>(`${this.baseUrl}/defaults`);
}
listRequests(): Observable<MobileBuildRequest[]> {
return this.http.get<MobileBuildRequest[]>(`${this.baseUrl}/requests`);
}
getRequest(id: number): Observable<MobileBuildRequest> {
return this.http.get<MobileBuildRequest>(`${this.baseUrl}/requests/${id}`);
}
createRequest(payload: MobileBuildCreatePayload): Observable<MobileBuildRequest> {
return this.http.post<MobileBuildRequest>(`${this.baseUrl}/requests`, payload);
}
uploadFile(
requestId: number,
platform: 'android' | 'ios',
fileKind: string,
file: File,
): Observable<MobileBuildUploadedFile> {
const form = new FormData();
form.append('file', file);
const params = new HttpParams()
.set('platform', platform)
.set('file_kind', fileKind);
return this.http.post<MobileBuildUploadedFile>(
`${this.baseUrl}/requests/${requestId}/files`,
form,
{ params },
);
}
preflight(requestId: number): Observable<MobileBuildPreflightResult> {
return this.http.post<MobileBuildPreflightResult>(
`${this.baseUrl}/requests/${requestId}/preflight`,
{},
);
}
trigger(requestId: number): Observable<MobileBuildRequest> {
return this.http.post<MobileBuildRequest>(
`${this.baseUrl}/requests/${requestId}/trigger`,
{},
);
}
}