From 7a04fa7c8440f17fb701d3af1979a3119944bc8b Mon Sep 17 00:00:00 2001 From: kyle Date: Sat, 4 Jul 2026 18:40:29 +0000 Subject: [PATCH] sets default admin creds in docker compose. first pass remote builder feature --- .claude/CLAUDE.md | 39 ++ .../app/__pycache__/config.cpython-312.pyc | Bin 1198 -> 2076 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 13774 -> 13898 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 11711 -> 14879 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 12860 -> 15439 bytes .../__pycache__/mobile_builds.cpython-312.pyc | Bin 0 -> 32720 bytes backend/app/api/mobile_builds.py | 618 ++++++++++++++++++ backend/app/config.py | 14 + backend/app/main.py | 3 +- backend/app/models.py | 60 ++ backend/app/schemas.py | 72 ++ docker-compose.yml | 2 + src/app/admin/admin.component.html | 195 ++++++ src/app/admin/admin.component.scss | 140 ++++ src/app/admin/admin.component.ts | 214 +++++- src/app/interfaces/mobile-build.ts | 69 ++ src/app/services/mobile-build.service.ts | 69 ++ 17 files changed, 1489 insertions(+), 6 deletions(-) create mode 100644 backend/app/api/__pycache__/mobile_builds.cpython-312.pyc create mode 100644 backend/app/api/mobile_builds.py create mode 100644 src/app/interfaces/mobile-build.ts create mode 100644 src/app/services/mobile-build.service.ts 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 c96e43d110ebf0f922d5f457c9de61efd650fe41..0f0569ae15fa4bb3f2676f569cfb05a5a085ad29 100644 GIT binary patch delta 1113 zcmZ{i&ui0Q7{{A69d+!7tnIq4>yNc|b#2!O-41FIx~6Vn*;0}WA(xOgnMT_tB(1nI zEdvjND2#g8!Nb6dColS6cnS0p0)kg>6ZQv~_g#wXoPqcIzMtoPzt8(V5BYNAy#PxA5iWX= zTUq_Ojp#_vTkFUTv8J@Gzz(%RkQIJok05cLVYc`^rb1hxf5O!g!#tvB{MVda>@~J3 zMYC6IrCpq+2%O)d7!R`kE8gD`(@STA^mb2N&zn=1CK*>3U9d*Bs( zM{OvAs9P7#p>eNDQY9R|D*hj%pta?Fq2AW7c7-T@-cWNW;X&;I&HPn z*6m13Y)F_UH6%fouuNVMYF1*TOT(H=G<%VElcYAf@oN&>t-xee!0u=A_GCpz(tuu! zq_8HW)^lFfq<#5?<=Nh0clq6wSbs2fcvth=KYkO+_{kQk77 zpLB;}Q=q`Q(%P3F=hXwv19pI*_RwdM{wMeA-r2Y-ku%WHNnwCy%@k&!krRD@V&+oP zi6+fd+KHwwN7L&D8awq3&~-Cibe2Ukxn`i@6J~&}naQ%VESsrKC&Q+huN&y{XZHZD zn;FvZp^4M-040C=Z&A7U?G?CTFMSrRY!2VUuC%VVK;-*242ZL}7vY1!1j5pCY-*3)qc4 zWPv6W$$zz>Z?;m{m70pWu701^@cafupKITcdS9xDEX_MRf%vvWwLc(nV~%=^6g z>do8PKNtScI@bJtuLNUl`ed;;dbgvST)G>3d#V(fi4ckUu1hmfx*Ie`{h(hlLOYo2 zvV-oq?p(orFlH2o#9VYQ-A4mVdD^+++=|Cpy!gFHk^SVnI6;&H@#2LR*{&>H%!->N z(uVc|T_q72_KJT<7s(2@qh0Pn$en6S4*|W_mi7UC-In$P{nVBY0G*WERW%57sVyA> z`a+KE-?=vt@wXfb(!@M@dSFFvwT(z@ck!a=a|XX#I4ns#N+p`qdP=F86ri1bQpN1{ zp10;SMaTYcTzAwRN?M$9Hi%F3x@J3Uf616rHI|#Jsv5NxUHhCs9(fcpZvEwX=_P~W zR&ao1tY^Uxndk>!+kLjeRej!ee#>eVJ^&#;h%$uGH_s}$f}vGQx@pUKz0OC(W>c~43x5X7*@JkQ&^iWkUktdBR+=1q-OmWp7xNEbtd|+BYh31EknH= zZEVjswE`!(Ro@Yjm&hZ-nO#JJi?5)9@9sNR6FEg{8*ZH#baX9KC%hs;3 z2pL=oB=co8*}4mf=3OwCwywt3QjDYr4n6@-Wem=2=oR?-nmWg^JNyC4J)xyjq+k4= ze%|vX2AZ8}p1y|5Wt6&5GshJ4;pb2$P+GN3mJC|e*irrhVgz2Z+acC73GdYn37>W8 ts^uCNIqAH9oA`-*}!90#h30kM{1u&THs1|_}=D~X-RJq3GQb4|Fq=CGz>sH*6c zYV8}TDwb+0hCM}W9LxXJjghM3M=6M!?@66glZ81!piwN~DXq7tB$S$sRJ^5Kajg8k z)rPns%G2zQ?-L5J75|F;*5Bp}Pbd|G7R*vALrkSmwDeWYs1T~8Q<(zMvBI228O_+f zz{>!Nu-6?Cq}ty)Lg-|eU#L^JWYL_Sb$rWY-J(N0L?eJ#O z_Z31}_N(t9lG%g)cv}oCivS&9u+ROn>P`8x>F03WsLE$fC`oj)Oi9wTQdZ8;`GNJ- zT$WaBW#D`x((D`2@1nW|Qi<0?!QE**x1J#-kjoN<94$i_5fff>?oL(vi9CYlXCMa! z&?pE38A?(XNv1T7-h_Z;TGa8o9e15+9<+@BnTfGzom)qR#bfc_5=5^9HUX4_cNJr5 zvbEUa)pdCG@zd+{7UV4e9E;ustZ_Jj6fWlUWZ5Ltsml^0iPQ8pPnhueaGT=7>r8`| zTP6I((CMxnoQ(R>u6_E%C}LY`Wbi51%hS9bL$~Oq%IPD(efCD3Mq&0tectId zz%8l*sa?Q2JD6FJ!GbOWW&!SHG|U$bT-L}L`kcoQ|C>J%i_K2e8;1h??DQ-{tmBmG HgGIjqS$-4C diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 8011a13b8e8a7545dbee9dbca92cd8488b69a724..46affd95f825b7058d4ddf7f8e3d42adc7f64111 100644 GIT binary patch delta 3411 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 KJ0!RcqVpd~hp?sq diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 550b972750025c3f0cbc4d2356146f0e5de34420..5cbaa2362383345057cf9ba8692d9f150d60d283 100644 GIT binary patch delta 2204 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 literal 0 HcmV?d00001 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 + + + +
+
+

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

+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +

Required Files

+
+ + +
+ +
+ + +
+ + @if (mobileBuildStatusMessage) { +

{{ mobileBuildStatusMessage }}

+ } + @if (mobileBuildErrorMessage) { +

{{ mobileBuildErrorMessage }}

+ } + + +
+
+ +
+

Build Requests

+ @if (mobileBuilds$ | async; as builds) { + + + + + + + + + + + + + @for (build of builds; track build.id) { + + + + + + + + + } @empty { + + } + +
IDPlatformsVersionStatusPreflightActions
#{{ 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' }} + +
No build requests yet.
+ } +
+ + @if (selectedMobileBuild; as build) { +
+
+

Selected Request #{{ build.id }}

+
+ + + +
+
+ + @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`, + {}, + ); + } +}