diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 65c1b12..546bd70 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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`. +### 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 - **Re-skin:** Edit colors in `_variables.scss` only. diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc index c96e43d..0f0569a 100644 Binary files a/backend/app/__pycache__/config.cpython-312.pyc and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 8f4e07b..226b83e 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 8011a13..46affd9 100644 Binary files a/backend/app/__pycache__/models.cpython-312.pyc and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 550b972..5cbaa23 100644 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc b/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc new file mode 100644 index 0000000..8d5e91d Binary files /dev/null and b/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc differ diff --git a/backend/app/api/mobile_builds.py b/backend/app/api/mobile_builds.py new file mode 100644 index 0000000..0303263 --- /dev/null +++ b/backend/app/api/mobile_builds.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py index fe79eea..af675bf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,6 +18,20 @@ class Settings(BaseSettings): LOGS_DIR: str = "/logs" 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_"} diff --git a/backend/app/main.py b/backend/app/main.py index e481008..d60179b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ from app.config import settings from app.database import async_session, engine from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter 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): @@ -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(underwriters.router, prefix="/api/underwriters", tags=["underwriters"]) 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) if settings.ENV != "production": diff --git a/backend/app/models.py b/backend/app/models.py index ee83c65..c936f02 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -235,3 +235,63 @@ class LogParseState(Base): key = Column(String(20), unique=True, nullable=False, default="default") last_parsed_date = Column(Date, nullable=False) 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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 94200af..aef385d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -348,3 +348,75 @@ class GeoStatResponse(BaseModel): class ParseResultResponse(BaseModel): files_processed: 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] diff --git a/docker-compose.yml b/docker-compose.yml index 551fb6f..378a548 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,8 @@ services: KMTN_DATABASE_URL: >- postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]' + KMTN_ADMIN_USERNAME : "admin" + KMTN_ADMIN_PASSWORD : "123" ports: - "8000:8000" volumes: diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index b0b89c8..94dd3a8 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -47,6 +47,11 @@ [class.active]="activeTab() === 'underwriters'" (click)="activeTab.set('underwriters')" >Underwriters + Mobile Builds + @if (activeTab() === 'mobile-builds') { + + + Mobile Build Orchestration + Refresh Requests + + + + + Derived Defaults + @if (mobileBuildDefaults; as defaults) { + + Tenant: {{ defaults.tenant_key }} + App Name: {{ defaults.app_name }} + Support Email: {{ defaults.support_email }} + Website: {{ defaults.website }} + Stream URL: {{ defaults.stream_url || '(not set)' }} + + } @else { + Defaults unavailable. Check station config. + } + + + + Create Build Request + + + + + Android + + + + iOS + + + + + + Android Application ID + + + + iOS Bundle ID + + + + + + + Version Name + + + + Build Number + + + + Release Profile + + + + + Required Files + + + Android Keystore (.jks/.keystore) + + + + Android Descriptor (JSON) + + + + + + + iOS Certificate (.p12) + + + + iOS Provisioning Profile (.mobileprovision) + + + + + @if (mobileBuildStatusMessage) { + {{ mobileBuildStatusMessage }} + } + @if (mobileBuildErrorMessage) { + {{ mobileBuildErrorMessage }} + } + + + {{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }} + + + + + + Build Requests + @if (mobileBuilds$ | async; as builds) { + + + + ID + Platforms + Version + Status + Preflight + Actions + + + + @for (build of builds; track build.id) { + + #{{ build.id }} + + {{ build.platform_android ? 'Android' : '' }} + {{ build.platform_android && build.platform_ios ? ' + ' : '' }} + {{ build.platform_ios ? 'iOS' : '' }} + + {{ build.version_name }} ({{ build.build_number }}) + {{ build.status }} + {{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }} + + Select + + + } @empty { + No build requests yet. + } + + + } + + + @if (selectedMobileBuild; as build) { + + + Selected Request #{{ build.id }} + + Run Preflight + Trigger Build + Refresh + + + + @if (mobileBuildPreflightIssues.length > 0) { + + Preflight issues: + + @for (issue of mobileBuildPreflightIssues; track issue) { + {{ issue }} + } + + + } + + @if (build.error_message) { + Error: {{ build.error_message }} + } + + Artifacts + + @for (artifact of build.artifacts; track artifact.id) { + + + {{ artifact.file_name }} + {{ artifact.platform }} • {{ artifact.artifact_type }} • {{ artifact.sha256.slice(0, 12) }}… + + Download + + } @empty { + No artifacts available yet. + } + + + Build Log + {{ build.build_log || 'No logs yet.' }} + + } + + } + @if (activeTab() === 'stats') { diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index 8318f82..d771886 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -408,3 +408,143 @@ 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; + } +} diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 25a8f8c..33f15ba 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -4,12 +4,16 @@ import { FormsModule } from '@angular/forms'; import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { Show } from '../interfaces/show'; -import { Event } from '../interfaces/event'; +import { Event as StationEvent } from '../interfaces/event'; import { StationConfig } from '../interfaces/station-config'; import { HistoryEntry } from '../interfaces/history-entry'; import { TeamMember } from '../interfaces/team-member'; import { CommunityHighlight } from '../interfaces/community-highlight'; import { Underwriter } from '../interfaces/underwriter'; +import { + MobileBuildDefaults, + MobileBuildRequest, +} from '../interfaces/mobile-build'; import { ShowService } from '../services/show.service'; import { EventService } from '../services/event.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 { CommunityHighlightService } from '../services/community-highlight.service'; import { UnderwriterService } from '../services/underwriter.service'; +import { MobileBuildService } from '../services/mobile-build.service'; import { AdminShowFormComponent } from './admin-show-form.component'; import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminStationFormComponent } from './admin-station-form.component'; @@ -38,8 +43,9 @@ import { HarmonyRule, ColorValidation, } 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 { color_primary: string; @@ -90,12 +96,13 @@ export class AdminComponent implements OnInit { private teamMemberService = inject(TeamMemberService); private communityHighlightService = inject(CommunityHighlightService); private underwriterService = inject(UnderwriterService); + private mobileBuildService = inject(MobileBuildService); activeTab = signal('shows'); /** Item currently being edited (set by editXxx, cleared on save/close). */ editingShow: Show | null = null; - editingEvent: Event | null = null; + editingEvent: StationEvent | null = null; editingHistory: HistoryEntry | null = null; editingTeam: TeamMember | 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. */ readonly shows$ = new BehaviorSubject([]); - readonly events$ = new BehaviorSubject([]); + readonly events$ = new BehaviorSubject([]); readonly stationConfig$ = new BehaviorSubject(null); readonly historyEntries$ = new BehaviorSubject([]); readonly teamMembers$ = new BehaviorSubject([]); readonly communityHighlights$ = new BehaviorSubject([]); readonly underwriters$ = new BehaviorSubject([]); + readonly mobileBuilds$ = new BehaviorSubject([]); + + 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 ────────────────────────────────────────── @@ -209,9 +238,184 @@ export class AdminComponent implements OnInit { this.reloadTeamMembers(), this.reloadCommunityHighlights(), this.reloadUnderwriters(), + this.reloadMobileBuildDefaults(), + this.reloadMobileBuilds(), ]); } + async reloadMobileBuildDefaults(): Promise { + try { + this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults()); + } catch { + this.mobileBuildDefaults = null; + } + } + + async reloadMobileBuilds(): Promise { + 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 { + 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 { + 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 { + 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 { + if (!this.selectedMobileBuildId) return; + await this.reloadMobileBuilds(); + this.onMobileBuildSelected(this.selectedMobileBuildId); + } + + getArtifactDownloadHref(path: string): string { + return `${getAppConfig().apiBaseUrl}${path}`; + } + // ── Show CRUD ─────────────────────────────────────────── openShowForm(): void { @@ -248,7 +452,7 @@ export class AdminComponent implements OnInit { this.showEventForm = true; } - editEvent(event: Event): void { + editEvent(event: StationEvent): void { this.editingEvent = event; this.showEventForm = true; } diff --git a/src/app/interfaces/mobile-build.ts b/src/app/interfaces/mobile-build.ts new file mode 100644 index 0000000..52d6f3a --- /dev/null +++ b/src/app/interfaces/mobile-build.ts @@ -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[]; +} diff --git a/src/app/services/mobile-build.service.ts b/src/app/services/mobile-build.service.ts new file mode 100644 index 0000000..1b4aede --- /dev/null +++ b/src/app/services/mobile-build.service.ts @@ -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 { + return this.http.get(`${this.baseUrl}/defaults`); + } + + listRequests(): Observable { + return this.http.get(`${this.baseUrl}/requests`); + } + + getRequest(id: number): Observable { + return this.http.get(`${this.baseUrl}/requests/${id}`); + } + + createRequest(payload: MobileBuildCreatePayload): Observable { + return this.http.post(`${this.baseUrl}/requests`, payload); + } + + uploadFile( + requestId: number, + platform: 'android' | 'ios', + fileKind: string, + file: File, + ): Observable { + const form = new FormData(); + form.append('file', file); + const params = new HttpParams() + .set('platform', platform) + .set('file_kind', fileKind); + + return this.http.post( + `${this.baseUrl}/requests/${requestId}/files`, + form, + { params }, + ); + } + + preflight(requestId: number): Observable { + return this.http.post( + `${this.baseUrl}/requests/${requestId}/preflight`, + {}, + ); + } + + trigger(requestId: number): Observable { + return this.http.post( + `${this.baseUrl}/requests/${requestId}/trigger`, + {}, + ); + } +}
Defaults unavailable. Check station config.
{{ mobileBuildStatusMessage }}
{{ mobileBuildErrorMessage }}
Error: {{ build.error_message }}
No artifacts available yet.
{{ build.build_log || 'No logs yet.' }}