Compare commits
2
Commits
78dde4fd0a
...
562c28b6cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
562c28b6cb | ||
|
|
7a04fa7c84 |
@@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
@@ -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_"}
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
+562
-404
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<TabKey>('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<Show[]>([]);
|
||||
readonly events$ = new BehaviorSubject<Event[]>([]);
|
||||
readonly events$ = new BehaviorSubject<StationEvent[]>([]);
|
||||
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
||||
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
||||
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
||||
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
||||
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
||||
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
|
||||
|
||||
mobileBuildDefaults: MobileBuildDefaults | null = null;
|
||||
selectedMobileBuildId: number | null = null;
|
||||
selectedMobileBuild: MobileBuildRequest | null = null;
|
||||
mobileBuildStatusMessage = '';
|
||||
mobileBuildErrorMessage = '';
|
||||
mobileBuildPreflightIssues: string[] = [];
|
||||
mobileBuildBusy = false;
|
||||
|
||||
buildAndroid = true;
|
||||
buildIos = true;
|
||||
androidApplicationId = '';
|
||||
iosBundleId = '';
|
||||
buildVersionName = '1.0.0';
|
||||
buildNumber = '1';
|
||||
buildReleaseProfile = 'release';
|
||||
|
||||
androidKeystoreFile: File | null = null;
|
||||
androidDescriptorFile: File | null = null;
|
||||
iosCertificateFile: File | null = null;
|
||||
iosProfileFile: File | null = null;
|
||||
|
||||
// ── Theme tab state ──────────────────────────────────────────
|
||||
|
||||
@@ -209,9 +238,184 @@ export class AdminComponent implements OnInit {
|
||||
this.reloadTeamMembers(),
|
||||
this.reloadCommunityHighlights(),
|
||||
this.reloadUnderwriters(),
|
||||
this.reloadMobileBuildDefaults(),
|
||||
this.reloadMobileBuilds(),
|
||||
]);
|
||||
}
|
||||
|
||||
async reloadMobileBuildDefaults(): Promise<void> {
|
||||
try {
|
||||
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
|
||||
} catch {
|
||||
this.mobileBuildDefaults = null;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadMobileBuilds(): Promise<void> {
|
||||
const data = await firstValueFrom(this.mobileBuildService.listRequests());
|
||||
this.mobileBuilds$.next(data);
|
||||
|
||||
if (this.selectedMobileBuildId !== null) {
|
||||
const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null;
|
||||
this.selectedMobileBuild = selected;
|
||||
if (!selected) {
|
||||
this.selectedMobileBuildId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMobileBuildSelected(buildId: number): void {
|
||||
this.selectedMobileBuildId = buildId;
|
||||
const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null;
|
||||
this.selectedMobileBuild = selected;
|
||||
this.mobileBuildPreflightIssues = selected?.preflight_report
|
||||
? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files && input.files.length > 0 ? input.files[0] : null;
|
||||
|
||||
if (target === 'android-keystore') this.androidKeystoreFile = file;
|
||||
if (target === 'android-descriptor') this.androidDescriptorFile = file;
|
||||
if (target === 'ios-certificate') this.iosCertificateFile = file;
|
||||
if (target === 'ios-profile') this.iosProfileFile = file;
|
||||
}
|
||||
|
||||
private getErrorMessage(err: unknown, fallback: string): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string };
|
||||
const detail = maybeErr.error?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) return detail.join(' | ');
|
||||
if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message;
|
||||
if (typeof maybeErr.message === 'string') return maybeErr.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private resetMobileBuildMessages(): void {
|
||||
this.mobileBuildStatusMessage = '';
|
||||
this.mobileBuildErrorMessage = '';
|
||||
}
|
||||
|
||||
private validateBuildInputs(): string | null {
|
||||
if (!this.buildAndroid && !this.buildIos) {
|
||||
return 'Select at least one platform.';
|
||||
}
|
||||
|
||||
if (this.buildAndroid && !this.androidApplicationId.trim()) {
|
||||
return 'Android application ID is required.';
|
||||
}
|
||||
|
||||
if (this.buildIos && !this.iosBundleId.trim()) {
|
||||
return 'iOS bundle ID is required.';
|
||||
}
|
||||
|
||||
if (!this.buildVersionName.trim() || !this.buildNumber.trim()) {
|
||||
return 'Version name and build number are required.';
|
||||
}
|
||||
|
||||
if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) {
|
||||
return 'Android requires both keystore and descriptor files.';
|
||||
}
|
||||
|
||||
if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) {
|
||||
return 'iOS requires both certificate and provisioning profile files.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async createAndUploadMobileBuildRequest(): Promise<void> {
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildPreflightIssues = [];
|
||||
const inputError = this.validateBuildInputs();
|
||||
if (inputError) {
|
||||
this.mobileBuildErrorMessage = inputError;
|
||||
return;
|
||||
}
|
||||
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
||||
platform_android: this.buildAndroid,
|
||||
platform_ios: this.buildIos,
|
||||
android_application_id: this.androidApplicationId.trim() || undefined,
|
||||
ios_bundle_id: this.iosBundleId.trim() || undefined,
|
||||
version_name: this.buildVersionName.trim(),
|
||||
build_number: this.buildNumber.trim(),
|
||||
release_profile: this.buildReleaseProfile.trim() || 'release',
|
||||
}));
|
||||
|
||||
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile));
|
||||
}
|
||||
|
||||
if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) {
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile));
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile));
|
||||
}
|
||||
|
||||
this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(request.id);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runPreflightForSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId));
|
||||
this.mobileBuildPreflightIssues = result.issues;
|
||||
this.mobileBuildStatusMessage = result.passed
|
||||
? 'Preflight passed. Build can be triggered.'
|
||||
: 'Preflight failed. Resolve the listed issues.';
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId));
|
||||
this.mobileBuildStatusMessage = `Build #${request.id} queued.`;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
}
|
||||
|
||||
getArtifactDownloadHref(path: string): string {
|
||||
return `${getAppConfig().apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
// ── Show CRUD ───────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { getAppConfig } from './app-config.service';
|
||||
import {
|
||||
MobileBuildCreatePayload,
|
||||
MobileBuildDefaults,
|
||||
MobileBuildPreflightResult,
|
||||
MobileBuildRequest,
|
||||
MobileBuildUploadedFile,
|
||||
} from '../interfaces/mobile-build';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MobileBuildService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/mobile-builds`;
|
||||
|
||||
getDefaults(): Observable<MobileBuildDefaults> {
|
||||
return this.http.get<MobileBuildDefaults>(`${this.baseUrl}/defaults`);
|
||||
}
|
||||
|
||||
listRequests(): Observable<MobileBuildRequest[]> {
|
||||
return this.http.get<MobileBuildRequest[]>(`${this.baseUrl}/requests`);
|
||||
}
|
||||
|
||||
getRequest(id: number): Observable<MobileBuildRequest> {
|
||||
return this.http.get<MobileBuildRequest>(`${this.baseUrl}/requests/${id}`);
|
||||
}
|
||||
|
||||
createRequest(payload: MobileBuildCreatePayload): Observable<MobileBuildRequest> {
|
||||
return this.http.post<MobileBuildRequest>(`${this.baseUrl}/requests`, payload);
|
||||
}
|
||||
|
||||
uploadFile(
|
||||
requestId: number,
|
||||
platform: 'android' | 'ios',
|
||||
fileKind: string,
|
||||
file: File,
|
||||
): Observable<MobileBuildUploadedFile> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const params = new HttpParams()
|
||||
.set('platform', platform)
|
||||
.set('file_kind', fileKind);
|
||||
|
||||
return this.http.post<MobileBuildUploadedFile>(
|
||||
`${this.baseUrl}/requests/${requestId}/files`,
|
||||
form,
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
preflight(requestId: number): Observable<MobileBuildPreflightResult> {
|
||||
return this.http.post<MobileBuildPreflightResult>(
|
||||
`${this.baseUrl}/requests/${requestId}/preflight`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
trigger(requestId: number): Observable<MobileBuildRequest> {
|
||||
return this.http.post<MobileBuildRequest>(
|
||||
`${this.baseUrl}/requests/${requestId}/trigger`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user