sets default admin creds in docker compose. first pass remote builder feature
This commit is contained in:
@@ -50,6 +50,45 @@ Nginx access logs → shared volume → FastAPI log parser → PostgreSQL → ad
|
|||||||
|
|
||||||
**Injecting test data:** Run `backend/inject_test_logs.py` (or the inline docker compose command in README.md) to write test log entries for yesterday, then trigger parsing via `GET /api/parse`.
|
**Injecting test data:** Run `backend/inject_test_logs.py` (or the inline docker compose command in README.md) to write test log entries for yesterday, then trigger parsing via `GET /api/parse`.
|
||||||
|
|
||||||
|
### Admin dashboard — mobile build orchestration (implemented)
|
||||||
|
|
||||||
|
Admin users can create mobile build requests, upload platform signing files, run preflight checks, trigger remote builds, monitor logs/status, and download generated artifacts.
|
||||||
|
|
||||||
|
**High-level flow:**
|
||||||
|
1. Admin creates a build request (platforms, identifiers, version/build numbers, release profile).
|
||||||
|
2. Admin uploads exactly two files per selected platform:
|
||||||
|
- Android: keystore + descriptor JSON
|
||||||
|
- iOS: `.p12` certificate + `.mobileprovision` profile
|
||||||
|
3. Backend preflight validates request completeness, file presence, platform requirements, and basic iOS CarPlay entitlement marker.
|
||||||
|
4. Backend dispatches build via SSH to external build workers:
|
||||||
|
- Android worker runs `./do_android_build.sh`
|
||||||
|
- iOS worker runs `./do_ios_build.sh`
|
||||||
|
5. Backend copies generated `.aab`/`.ipa` artifacts back, stores metadata/checksums, and exposes admin download URLs.
|
||||||
|
|
||||||
|
**SSH contract (current implementation):**
|
||||||
|
- Connect as user `buildbot` (password-auth expected in the build-control environment).
|
||||||
|
- Remote app directory is `~/mobile_app`.
|
||||||
|
- Uploaded signing files are copied into `~/mobile_app` before script execution.
|
||||||
|
- Build scripts are executed from `~/mobile_app`.
|
||||||
|
|
||||||
|
**Key backend files:**
|
||||||
|
- [backend/app/api/mobile_builds.py](backend/app/api/mobile_builds.py) — mobile build endpoints, preflight, SSH execution, artifact collection
|
||||||
|
- [backend/app/models.py](backend/app/models.py) — `MobileBuildRequest`, `MobileBuildUploadedFile`, `MobileBuildArtifact`
|
||||||
|
- [backend/app/schemas.py](backend/app/schemas.py) — mobile build request/response schemas
|
||||||
|
- [backend/app/config.py](backend/app/config.py) — `KMTN_MOBILE_BUILD_*` settings (hosts, scripts, paths)
|
||||||
|
- [backend/app/main.py](backend/app/main.py) — router registration at `/api/mobile-builds`
|
||||||
|
|
||||||
|
**Key frontend files:**
|
||||||
|
- [src/app/admin/admin.component.ts](src/app/admin/admin.component.ts) — mobile build tab state/actions
|
||||||
|
- [src/app/admin/admin.component.html](src/app/admin/admin.component.html) — upload/preflight/trigger/logs/download UI
|
||||||
|
- [src/app/admin/admin.component.scss](src/app/admin/admin.component.scss) — mobile build panel styling
|
||||||
|
- [src/app/services/mobile-build.service.ts](src/app/services/mobile-build.service.ts) — API client for mobile build endpoints
|
||||||
|
- [src/app/interfaces/mobile-build.ts](src/app/interfaces/mobile-build.ts) — frontend build models
|
||||||
|
|
||||||
|
**Current scope:**
|
||||||
|
- Build artifact generation only (`.aab` and `.ipa`).
|
||||||
|
- No automatic App Store Connect / Google Play upload.
|
||||||
|
|
||||||
## Common tasks
|
## Common tasks
|
||||||
|
|
||||||
- **Re-skin:** Edit colors in `_variables.scss` only.
|
- **Re-skin:** Edit colors in `_variables.scss` only.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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"
|
LOGS_DIR: str = "/logs"
|
||||||
GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb"
|
GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb"
|
||||||
|
|
||||||
|
# Mobile build orchestration
|
||||||
|
MOBILE_BUILD_SSH_USER: str = "buildbot"
|
||||||
|
MOBILE_BUILD_SSH_PASSWORD: str = "buildbot"
|
||||||
|
MOBILE_BUILD_SSH_PORT: int = 22
|
||||||
|
MOBILE_BUILD_ANDROID_HOST: str = ""
|
||||||
|
MOBILE_BUILD_IOS_HOST: str = ""
|
||||||
|
MOBILE_BUILD_REMOTE_APP_DIR: str = "~/mobile_app"
|
||||||
|
MOBILE_BUILD_ANDROID_SCRIPT: str = "do_android_build.sh"
|
||||||
|
MOBILE_BUILD_IOS_SCRIPT: str = "do_ios_build.sh"
|
||||||
|
MOBILE_BUILD_UPLOAD_DIR: str = "/tmp/kmtn_mobile_build_uploads"
|
||||||
|
MOBILE_BUILD_ARTIFACT_DIR: str = "/tmp/kmtn_mobile_build_artifacts"
|
||||||
|
MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "~/mobile_app/build/app/outputs/bundle/release/*.aab"
|
||||||
|
MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "~/mobile_app/build/ios/ipa/*.ipa"
|
||||||
|
|
||||||
model_config = {"env_prefix": "KMTN_"}
|
model_config = {"env_prefix": "KMTN_"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,7 @@ from app.config import settings
|
|||||||
from app.database import async_session, engine
|
from app.database import async_session, engine
|
||||||
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
||||||
from app.user_models import User
|
from app.user_models import User
|
||||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats
|
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_orphaned_sequences(sync_conn):
|
def _cleanup_orphaned_sequences(sync_conn):
|
||||||
@@ -223,6 +223,7 @@ app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
|
|||||||
app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
|
app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
|
||||||
app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
|
app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
|
||||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||||
|
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
|
||||||
|
|
||||||
# Dev-only admin router (disabled in production)
|
# Dev-only admin router (disabled in production)
|
||||||
if settings.ENV != "production":
|
if settings.ENV != "production":
|
||||||
|
|||||||
@@ -235,3 +235,63 @@ class LogParseState(Base):
|
|||||||
key = Column(String(20), unique=True, nullable=False, default="default")
|
key = Column(String(20), unique=True, nullable=False, default="default")
|
||||||
last_parsed_date = Column(Date, nullable=False)
|
last_parsed_date = Column(Date, nullable=False)
|
||||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mobile Build Orchestration ─────────────────────────────────
|
||||||
|
|
||||||
|
class MobileBuildRequest(Base):
|
||||||
|
__tablename__ = "mobile_build_requests"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_key = Column(String(80), nullable=False, default="default")
|
||||||
|
platform_android = Column(Boolean, nullable=False, default=False)
|
||||||
|
platform_ios = Column(Boolean, nullable=False, default=False)
|
||||||
|
android_application_id = Column(String(200), nullable=True)
|
||||||
|
ios_bundle_id = Column(String(200), nullable=True)
|
||||||
|
version_name = Column(String(40), nullable=False)
|
||||||
|
build_number = Column(String(40), nullable=False)
|
||||||
|
release_profile = Column(String(80), nullable=False, default="release")
|
||||||
|
|
||||||
|
status = Column(String(20), nullable=False, default="draft")
|
||||||
|
preflight_passed = Column(Boolean, nullable=False, default=False)
|
||||||
|
preflight_report = Column(Text, nullable=False, default="")
|
||||||
|
build_log = Column(Text, nullable=False, default="")
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
started_at = Column(DateTime, nullable=True)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MobileBuildUploadedFile(Base):
|
||||||
|
__tablename__ = "mobile_build_uploaded_files"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
request_id = Column(Integer, ForeignKey("mobile_build_requests.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
platform = Column(String(20), nullable=False)
|
||||||
|
file_kind = Column(String(40), nullable=False)
|
||||||
|
original_name = Column(String(260), nullable=False)
|
||||||
|
stored_path = Column(String(800), nullable=False)
|
||||||
|
sha256 = Column(String(64), nullable=False)
|
||||||
|
size = Column(Integer, nullable=False)
|
||||||
|
uploaded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("request_id", "platform", "file_kind", name="uq_mobile_build_file_kind"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MobileBuildArtifact(Base):
|
||||||
|
__tablename__ = "mobile_build_artifacts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
request_id = Column(Integer, ForeignKey("mobile_build_requests.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
platform = Column(String(20), nullable=False)
|
||||||
|
artifact_type = Column(String(20), nullable=False)
|
||||||
|
file_name = Column(String(260), nullable=False)
|
||||||
|
file_path = Column(String(800), nullable=False)
|
||||||
|
sha256 = Column(String(64), nullable=False)
|
||||||
|
size = Column(Integer, nullable=False)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|||||||
@@ -348,3 +348,75 @@ class GeoStatResponse(BaseModel):
|
|||||||
class ParseResultResponse(BaseModel):
|
class ParseResultResponse(BaseModel):
|
||||||
files_processed: int
|
files_processed: int
|
||||||
rows_inserted: int
|
rows_inserted: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mobile Build Orchestration ───────────────────────────
|
||||||
|
|
||||||
|
class MobileBuildDefaultsResponse(BaseModel):
|
||||||
|
tenant_key: str
|
||||||
|
app_name: str
|
||||||
|
tagline: str
|
||||||
|
support_email: str
|
||||||
|
website: str
|
||||||
|
stream_url: str
|
||||||
|
stream_metadata_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class MobileBuildCreate(BaseModel):
|
||||||
|
platform_android: bool = False
|
||||||
|
platform_ios: bool = False
|
||||||
|
android_application_id: Optional[str] = None
|
||||||
|
ios_bundle_id: Optional[str] = None
|
||||||
|
version_name: str
|
||||||
|
build_number: str
|
||||||
|
release_profile: str = "release"
|
||||||
|
|
||||||
|
|
||||||
|
class MobileBuildUploadedFileResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
request_id: int
|
||||||
|
platform: str
|
||||||
|
file_kind: str
|
||||||
|
original_name: str
|
||||||
|
size: int
|
||||||
|
uploaded_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class MobileBuildArtifactResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
request_id: int
|
||||||
|
platform: str
|
||||||
|
artifact_type: str
|
||||||
|
file_name: str
|
||||||
|
sha256: str
|
||||||
|
size: int
|
||||||
|
created_at: str
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class MobileBuildRequestResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
tenant_key: str
|
||||||
|
platform_android: bool
|
||||||
|
platform_ios: bool
|
||||||
|
android_application_id: Optional[str]
|
||||||
|
ios_bundle_id: Optional[str]
|
||||||
|
version_name: str
|
||||||
|
build_number: str
|
||||||
|
release_profile: str
|
||||||
|
status: str
|
||||||
|
preflight_passed: bool
|
||||||
|
preflight_report: str
|
||||||
|
build_log: str
|
||||||
|
error_message: Optional[str]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
started_at: Optional[str]
|
||||||
|
completed_at: Optional[str]
|
||||||
|
files: list[MobileBuildUploadedFileResponse]
|
||||||
|
artifacts: list[MobileBuildArtifactResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class MobileBuildPreflightResponse(BaseModel):
|
||||||
|
passed: bool
|
||||||
|
issues: list[str]
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ services:
|
|||||||
KMTN_DATABASE_URL: >-
|
KMTN_DATABASE_URL: >-
|
||||||
postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
|
postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
|
||||||
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
|
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
|
||||||
|
KMTN_ADMIN_USERNAME : "admin"
|
||||||
|
KMTN_ADMIN_PASSWORD : "123"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
[class.active]="activeTab() === 'underwriters'"
|
[class.active]="activeTab() === 'underwriters'"
|
||||||
(click)="activeTab.set('underwriters')"
|
(click)="activeTab.set('underwriters')"
|
||||||
>Underwriters</button>
|
>Underwriters</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeTab() === 'mobile-builds'"
|
||||||
|
(click)="activeTab.set('mobile-builds')"
|
||||||
|
>Mobile Builds</button>
|
||||||
<button
|
<button
|
||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
[class.active]="activeTab() === 'stats'"
|
[class.active]="activeTab() === 'stats'"
|
||||||
@@ -448,6 +453,196 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Mobile Builds Tab -->
|
||||||
|
@if (activeTab() === 'mobile-builds') {
|
||||||
|
<div class="tab-content mobile-builds-panel">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<h2>Mobile Build Orchestration</h2>
|
||||||
|
<button class="btn btn-add" (click)="reloadMobileBuilds()">Refresh Requests</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-build-grid">
|
||||||
|
<section class="build-card">
|
||||||
|
<h3>Derived Defaults</h3>
|
||||||
|
@if (mobileBuildDefaults; as defaults) {
|
||||||
|
<div class="derived-list">
|
||||||
|
<div><strong>Tenant:</strong> {{ defaults.tenant_key }}</div>
|
||||||
|
<div><strong>App Name:</strong> {{ defaults.app_name }}</div>
|
||||||
|
<div><strong>Support Email:</strong> {{ defaults.support_email }}</div>
|
||||||
|
<div><strong>Website:</strong> {{ defaults.website }}</div>
|
||||||
|
<div><strong>Stream URL:</strong> {{ defaults.stream_url || '(not set)' }}</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="empty-row">Defaults unavailable. Check station config.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="build-card">
|
||||||
|
<h3>Create Build Request</h3>
|
||||||
|
|
||||||
|
<div class="form-row platform-row">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [(ngModel)]="buildAndroid">
|
||||||
|
Android
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [(ngModel)]="buildIos">
|
||||||
|
iOS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
Android Application ID
|
||||||
|
<input type="text" [(ngModel)]="androidApplicationId" placeholder="com.example.radio">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
iOS Bundle ID
|
||||||
|
<input type="text" [(ngModel)]="iosBundleId" placeholder="com.example.radio">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
Version Name
|
||||||
|
<input type="text" [(ngModel)]="buildVersionName" placeholder="1.0.0">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Build Number
|
||||||
|
<input type="text" [(ngModel)]="buildNumber" placeholder="1">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Release Profile
|
||||||
|
<input type="text" [(ngModel)]="buildReleaseProfile" placeholder="release">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Required Files</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
Android Keystore (.jks/.keystore)
|
||||||
|
<input type="file" (change)="onFileChosen($event, 'android-keystore')">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Android Descriptor (JSON)
|
||||||
|
<input type="file" accept=".json,application/json" (change)="onFileChosen($event, 'android-descriptor')">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
iOS Certificate (.p12)
|
||||||
|
<input type="file" accept=".p12" (change)="onFileChosen($event, 'ios-certificate')">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
iOS Provisioning Profile (.mobileprovision)
|
||||||
|
<input type="file" accept=".mobileprovision" (change)="onFileChosen($event, 'ios-profile')">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (mobileBuildStatusMessage) {
|
||||||
|
<p class="status success">{{ mobileBuildStatusMessage }}</p>
|
||||||
|
}
|
||||||
|
@if (mobileBuildErrorMessage) {
|
||||||
|
<p class="status error">{{ mobileBuildErrorMessage }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-add"
|
||||||
|
[disabled]="mobileBuildBusy"
|
||||||
|
(click)="createAndUploadMobileBuildRequest()"
|
||||||
|
>
|
||||||
|
{{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="build-card">
|
||||||
|
<h3>Build Requests</h3>
|
||||||
|
@if (mobileBuilds$ | async; as builds) {
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Platforms</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Preflight</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (build of builds; track build.id) {
|
||||||
|
<tr [class.selected-build]="selectedMobileBuildId === build.id">
|
||||||
|
<td>#{{ build.id }}</td>
|
||||||
|
<td>
|
||||||
|
{{ build.platform_android ? 'Android' : '' }}
|
||||||
|
{{ build.platform_android && build.platform_ios ? ' + ' : '' }}
|
||||||
|
{{ build.platform_ios ? 'iOS' : '' }}
|
||||||
|
</td>
|
||||||
|
<td>{{ build.version_name }} ({{ build.build_number }})</td>
|
||||||
|
<td>{{ build.status }}</td>
|
||||||
|
<td>{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn-icon btn-edit" (click)="onMobileBuildSelected(build.id)">Select</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="6" class="empty-row">No build requests yet.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (selectedMobileBuild; as build) {
|
||||||
|
<section class="build-card">
|
||||||
|
<div class="tab-toolbar compact">
|
||||||
|
<h3>Selected Request #{{ build.id }}</h3>
|
||||||
|
<div class="action-group">
|
||||||
|
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="runPreflightForSelectedBuild()">Run Preflight</button>
|
||||||
|
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy || !build.preflight_passed" (click)="triggerSelectedBuild()">Trigger Build</button>
|
||||||
|
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="refreshSelectedBuild()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (mobileBuildPreflightIssues.length > 0) {
|
||||||
|
<div class="status error">
|
||||||
|
<strong>Preflight issues:</strong>
|
||||||
|
<ul>
|
||||||
|
@for (issue of mobileBuildPreflightIssues; track issue) {
|
||||||
|
<li>{{ issue }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (build.error_message) {
|
||||||
|
<p class="status error"><strong>Error:</strong> {{ build.error_message }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h4>Artifacts</h4>
|
||||||
|
<div class="artifact-list">
|
||||||
|
@for (artifact of build.artifacts; track artifact.id) {
|
||||||
|
<div class="artifact-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ artifact.file_name }}</strong>
|
||||||
|
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{ artifact.sha256.slice(0, 12) }}…</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn-icon btn-edit" [href]="getArtifactDownloadHref(artifact.download_url)">Download</a>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="empty-row">No artifacts available yet.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Build Log</h4>
|
||||||
|
<pre class="build-log">{{ build.build_log || 'No logs yet.' }}</pre>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Stats Tab -->
|
<!-- Stats Tab -->
|
||||||
@if (activeTab() === 'stats') {
|
@if (activeTab() === 'stats') {
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
|||||||
@@ -408,3 +408,143 @@
|
|||||||
color: $neutral-white;
|
color: $neutral-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mobile builds tab ───────────────────────────────────
|
||||||
|
|
||||||
|
.mobile-builds-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-build-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-card {
|
||||||
|
background: $neutral-white;
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-md;
|
||||||
|
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
color: $primary-blue;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.derived-list {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $neutral-dark;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
input[type='file'] {
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-row {
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
margin: $spacing-sm 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
background: rgba($success-green, 0.1);
|
||||||
|
color: $success-green;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: rgba($danger-red, 0.1);
|
||||||
|
color: $danger-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: $spacing-xs 0 0 $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-build {
|
||||||
|
background: rgba($primary-blue, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-toolbar.compact {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-list {
|
||||||
|
display: grid;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: $neutral-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-log {
|
||||||
|
background: $neutral-black;
|
||||||
|
color: $neutral-white;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include responsive(md) {
|
||||||
|
.mobile-build-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { Event } from '../interfaces/event';
|
import { Event as StationEvent } from '../interfaces/event';
|
||||||
import { StationConfig } from '../interfaces/station-config';
|
import { StationConfig } from '../interfaces/station-config';
|
||||||
import { HistoryEntry } from '../interfaces/history-entry';
|
import { HistoryEntry } from '../interfaces/history-entry';
|
||||||
import { TeamMember } from '../interfaces/team-member';
|
import { TeamMember } from '../interfaces/team-member';
|
||||||
import { CommunityHighlight } from '../interfaces/community-highlight';
|
import { CommunityHighlight } from '../interfaces/community-highlight';
|
||||||
import { Underwriter } from '../interfaces/underwriter';
|
import { Underwriter } from '../interfaces/underwriter';
|
||||||
|
import {
|
||||||
|
MobileBuildDefaults,
|
||||||
|
MobileBuildRequest,
|
||||||
|
} from '../interfaces/mobile-build';
|
||||||
import { ShowService } from '../services/show.service';
|
import { ShowService } from '../services/show.service';
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
@@ -18,6 +22,7 @@ import { HistoryEntryService } from '../services/history-entry.service';
|
|||||||
import { TeamMemberService } from '../services/team-member.service';
|
import { TeamMemberService } from '../services/team-member.service';
|
||||||
import { CommunityHighlightService } from '../services/community-highlight.service';
|
import { CommunityHighlightService } from '../services/community-highlight.service';
|
||||||
import { UnderwriterService } from '../services/underwriter.service';
|
import { UnderwriterService } from '../services/underwriter.service';
|
||||||
|
import { MobileBuildService } from '../services/mobile-build.service';
|
||||||
import { AdminShowFormComponent } from './admin-show-form.component';
|
import { AdminShowFormComponent } from './admin-show-form.component';
|
||||||
import { AdminEventFormComponent } from './admin-event-form.component';
|
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||||
import { AdminStationFormComponent } from './admin-station-form.component';
|
import { AdminStationFormComponent } from './admin-station-form.component';
|
||||||
@@ -38,8 +43,9 @@ import {
|
|||||||
HarmonyRule,
|
HarmonyRule,
|
||||||
ColorValidation,
|
ColorValidation,
|
||||||
} from '../utils/color-harmony';
|
} from '../utils/color-harmony';
|
||||||
|
import { getAppConfig } from '../services/app-config.service';
|
||||||
|
|
||||||
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'stats';
|
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'mobile-builds' | 'stats';
|
||||||
|
|
||||||
interface ThemeColorState {
|
interface ThemeColorState {
|
||||||
color_primary: string;
|
color_primary: string;
|
||||||
@@ -90,12 +96,13 @@ export class AdminComponent implements OnInit {
|
|||||||
private teamMemberService = inject(TeamMemberService);
|
private teamMemberService = inject(TeamMemberService);
|
||||||
private communityHighlightService = inject(CommunityHighlightService);
|
private communityHighlightService = inject(CommunityHighlightService);
|
||||||
private underwriterService = inject(UnderwriterService);
|
private underwriterService = inject(UnderwriterService);
|
||||||
|
private mobileBuildService = inject(MobileBuildService);
|
||||||
|
|
||||||
activeTab = signal<TabKey>('shows');
|
activeTab = signal<TabKey>('shows');
|
||||||
|
|
||||||
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
||||||
editingShow: Show | null = null;
|
editingShow: Show | null = null;
|
||||||
editingEvent: Event | null = null;
|
editingEvent: StationEvent | null = null;
|
||||||
editingHistory: HistoryEntry | null = null;
|
editingHistory: HistoryEntry | null = null;
|
||||||
editingTeam: TeamMember | null = null;
|
editingTeam: TeamMember | null = null;
|
||||||
editingCommunity: CommunityHighlight | null = null;
|
editingCommunity: CommunityHighlight | null = null;
|
||||||
@@ -103,12 +110,34 @@ export class AdminComponent implements OnInit {
|
|||||||
|
|
||||||
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
||||||
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
||||||
readonly events$ = new BehaviorSubject<Event[]>([]);
|
readonly events$ = new BehaviorSubject<StationEvent[]>([]);
|
||||||
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
||||||
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
||||||
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
||||||
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
||||||
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
||||||
|
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
|
||||||
|
|
||||||
|
mobileBuildDefaults: MobileBuildDefaults | null = null;
|
||||||
|
selectedMobileBuildId: number | null = null;
|
||||||
|
selectedMobileBuild: MobileBuildRequest | null = null;
|
||||||
|
mobileBuildStatusMessage = '';
|
||||||
|
mobileBuildErrorMessage = '';
|
||||||
|
mobileBuildPreflightIssues: string[] = [];
|
||||||
|
mobileBuildBusy = false;
|
||||||
|
|
||||||
|
buildAndroid = true;
|
||||||
|
buildIos = true;
|
||||||
|
androidApplicationId = '';
|
||||||
|
iosBundleId = '';
|
||||||
|
buildVersionName = '1.0.0';
|
||||||
|
buildNumber = '1';
|
||||||
|
buildReleaseProfile = 'release';
|
||||||
|
|
||||||
|
androidKeystoreFile: File | null = null;
|
||||||
|
androidDescriptorFile: File | null = null;
|
||||||
|
iosCertificateFile: File | null = null;
|
||||||
|
iosProfileFile: File | null = null;
|
||||||
|
|
||||||
// ── Theme tab state ──────────────────────────────────────────
|
// ── Theme tab state ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -209,9 +238,184 @@ export class AdminComponent implements OnInit {
|
|||||||
this.reloadTeamMembers(),
|
this.reloadTeamMembers(),
|
||||||
this.reloadCommunityHighlights(),
|
this.reloadCommunityHighlights(),
|
||||||
this.reloadUnderwriters(),
|
this.reloadUnderwriters(),
|
||||||
|
this.reloadMobileBuildDefaults(),
|
||||||
|
this.reloadMobileBuilds(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reloadMobileBuildDefaults(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
|
||||||
|
} catch {
|
||||||
|
this.mobileBuildDefaults = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadMobileBuilds(): Promise<void> {
|
||||||
|
const data = await firstValueFrom(this.mobileBuildService.listRequests());
|
||||||
|
this.mobileBuilds$.next(data);
|
||||||
|
|
||||||
|
if (this.selectedMobileBuildId !== null) {
|
||||||
|
const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null;
|
||||||
|
this.selectedMobileBuild = selected;
|
||||||
|
if (!selected) {
|
||||||
|
this.selectedMobileBuildId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMobileBuildSelected(buildId: number): void {
|
||||||
|
this.selectedMobileBuildId = buildId;
|
||||||
|
const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null;
|
||||||
|
this.selectedMobileBuild = selected;
|
||||||
|
this.mobileBuildPreflightIssues = selected?.preflight_report
|
||||||
|
? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files && input.files.length > 0 ? input.files[0] : null;
|
||||||
|
|
||||||
|
if (target === 'android-keystore') this.androidKeystoreFile = file;
|
||||||
|
if (target === 'android-descriptor') this.androidDescriptorFile = file;
|
||||||
|
if (target === 'ios-certificate') this.iosCertificateFile = file;
|
||||||
|
if (target === 'ios-profile') this.iosProfileFile = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (typeof err === 'object' && err !== null) {
|
||||||
|
const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string };
|
||||||
|
const detail = maybeErr.error?.detail;
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
if (Array.isArray(detail)) return detail.join(' | ');
|
||||||
|
if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message;
|
||||||
|
if (typeof maybeErr.message === 'string') return maybeErr.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetMobileBuildMessages(): void {
|
||||||
|
this.mobileBuildStatusMessage = '';
|
||||||
|
this.mobileBuildErrorMessage = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateBuildInputs(): string | null {
|
||||||
|
if (!this.buildAndroid && !this.buildIos) {
|
||||||
|
return 'Select at least one platform.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.buildAndroid && !this.androidApplicationId.trim()) {
|
||||||
|
return 'Android application ID is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.buildIos && !this.iosBundleId.trim()) {
|
||||||
|
return 'iOS bundle ID is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.buildVersionName.trim() || !this.buildNumber.trim()) {
|
||||||
|
return 'Version name and build number are required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) {
|
||||||
|
return 'Android requires both keystore and descriptor files.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) {
|
||||||
|
return 'iOS requires both certificate and provisioning profile files.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAndUploadMobileBuildRequest(): Promise<void> {
|
||||||
|
this.resetMobileBuildMessages();
|
||||||
|
this.mobileBuildPreflightIssues = [];
|
||||||
|
const inputError = this.validateBuildInputs();
|
||||||
|
if (inputError) {
|
||||||
|
this.mobileBuildErrorMessage = inputError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mobileBuildBusy = true;
|
||||||
|
try {
|
||||||
|
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
||||||
|
platform_android: this.buildAndroid,
|
||||||
|
platform_ios: this.buildIos,
|
||||||
|
android_application_id: this.androidApplicationId.trim() || undefined,
|
||||||
|
ios_bundle_id: this.iosBundleId.trim() || undefined,
|
||||||
|
version_name: this.buildVersionName.trim(),
|
||||||
|
build_number: this.buildNumber.trim(),
|
||||||
|
release_profile: this.buildReleaseProfile.trim() || 'release',
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
||||||
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
||||||
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) {
|
||||||
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile));
|
||||||
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`;
|
||||||
|
await this.reloadMobileBuilds();
|
||||||
|
this.onMobileBuildSelected(request.id);
|
||||||
|
} catch (err) {
|
||||||
|
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
||||||
|
} finally {
|
||||||
|
this.mobileBuildBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runPreflightForSelectedBuild(): Promise<void> {
|
||||||
|
if (!this.selectedMobileBuildId) return;
|
||||||
|
|
||||||
|
this.resetMobileBuildMessages();
|
||||||
|
this.mobileBuildBusy = true;
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId));
|
||||||
|
this.mobileBuildPreflightIssues = result.issues;
|
||||||
|
this.mobileBuildStatusMessage = result.passed
|
||||||
|
? 'Preflight passed. Build can be triggered.'
|
||||||
|
: 'Preflight failed. Resolve the listed issues.';
|
||||||
|
await this.reloadMobileBuilds();
|
||||||
|
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||||
|
} catch (err) {
|
||||||
|
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.');
|
||||||
|
} finally {
|
||||||
|
this.mobileBuildBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerSelectedBuild(): Promise<void> {
|
||||||
|
if (!this.selectedMobileBuildId) return;
|
||||||
|
|
||||||
|
this.resetMobileBuildMessages();
|
||||||
|
this.mobileBuildBusy = true;
|
||||||
|
try {
|
||||||
|
const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId));
|
||||||
|
this.mobileBuildStatusMessage = `Build #${request.id} queued.`;
|
||||||
|
await this.reloadMobileBuilds();
|
||||||
|
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||||
|
} catch (err) {
|
||||||
|
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.');
|
||||||
|
} finally {
|
||||||
|
this.mobileBuildBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSelectedBuild(): Promise<void> {
|
||||||
|
if (!this.selectedMobileBuildId) return;
|
||||||
|
await this.reloadMobileBuilds();
|
||||||
|
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getArtifactDownloadHref(path: string): string {
|
||||||
|
return `${getAppConfig().apiBaseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Show CRUD ───────────────────────────────────────────
|
// ── Show CRUD ───────────────────────────────────────────
|
||||||
|
|
||||||
openShowForm(): void {
|
openShowForm(): void {
|
||||||
@@ -248,7 +452,7 @@ export class AdminComponent implements OnInit {
|
|||||||
this.showEventForm = true;
|
this.showEventForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
editEvent(event: Event): void {
|
editEvent(event: StationEvent): void {
|
||||||
this.editingEvent = event;
|
this.editingEvent = event;
|
||||||
this.showEventForm = true;
|
this.showEventForm = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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