feat(angular): add SpaceX API pages, HTTPS logging and landing pad stats

This commit is contained in:
Fbase965
2026-03-17 10:20:13 +00:00
parent 1ceffa0dea
commit 4296fb17c7
13 changed files with 1007 additions and 224 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -33,11 +33,11 @@
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.0.0",
"@angular/cli": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@angular/build": "^21.2.2",
"@angular/cli": "^21.2.2",
"@angular/compiler-cli": "^21.2.2",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}
}

View File

@@ -1,13 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { httpLoggerInterceptor } from './services/http-logger.interceptor';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(),
provideHttpClient(withInterceptors([httpLoggerInterceptor])),
provideRouter(routes)
]
};

View File

@@ -8,6 +8,7 @@ import { StarlinkPage } from './pages/starlink/starlink.page';
export const routes: Routes = [
{ path: '', component: LaunchesPage },
{ path: 'launches', redirectTo: '' },
{ path: 'rockets', component: RocketsPage },
{ path: 'capsules', component: CapsulesPage },
{ path: 'starlink', component: StarlinkPage },

View File

@@ -75,6 +75,45 @@
padding-bottom: 52px;
}
.http-proof {
margin-top: 28px;
}
.clear-btn {
border: 1px solid #334155;
border-radius: 10px;
background: #0f172a;
color: #cbd5e1;
padding: 8px 12px;
cursor: pointer;
}
.clear-btn:hover {
background: #1e293b;
color: #f8fafc;
}
.http-log-list {
margin-top: 14px;
display: grid;
gap: 10px;
}
.http-log-item {
border: 1px solid #1e293b;
border-radius: 10px;
padding: 10px;
background: rgba(2, 6, 23, 0.8);
}
.log-url {
margin-top: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all;
color: #dbeafe;
font-size: 0.9rem;
}
.site-footer {
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 22px 0;

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { HttpLogService } from './services/http-log.service';
@Component({
selector: 'app-root',
@@ -30,6 +31,34 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
<main class="container page-shell">
<router-outlet />
<section class="http-proof panel">
<div class="row between start">
<div>
<h3>HTTPS Call Logs</h3>
<p class="muted">
Requests captured by Angular interceptor (method, URL, status and response time).
</p>
</div>
<button class="clear-btn" type="button" (click)="clearLogs()">Clear logs</button>
</div>
@if (latestLogs().length === 0) {
<p class="small">No requests yet. Open another page to trigger SpaceX API calls.</p>
} @else {
<div class="http-log-list">
@for (entry of latestLogs(); track entry.id) {
<article class="http-log-item">
<span class="badge" [class.badge-green]="entry.ok" [class.badge-red]="!entry.ok">
{{ entry.method }} {{ entry.status }}
</span>
<p class="log-url">{{ entry.url }}</p>
<p class="small">{{ entry.timestamp }} • {{ entry.durationMs }}ms</p>
</article>
}
</div>
}
</section>
</main>
<footer class="site-footer">
@@ -45,4 +74,11 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
`,
styleUrl: './app.scss'
})
export class App {}
export class App {
private readonly httpLogService = inject(HttpLogService);
readonly latestLogs = computed(() => this.httpLogService.entries().slice(0, 12));
clearLogs(): void {
this.httpLogService.clear();
}
}

View File

@@ -0,0 +1,62 @@
import { Component, OnInit, inject } from '@angular/core';
import { SpacexApiService } from '../../services/spacex-api.service';
@Component({
selector: 'app-capsules-page',
template: `
<section class="page-head">
<h2>Capsules</h2>
<p>Dragon capsule fleet status and usage.</p>
</section>
@if (loading) {
<p class="muted">Loading capsules from HTTPS endpoint...</p>
}
@if (error) {
<p class="muted">{{ error }}</p>
}
<section class="cards two-cols">
@for (capsule of capsules; track capsule.id) {
<article class="card panel">
<div class="row between start">
<div>
<h3>{{ capsule.serial }}</h3>
<p class="muted">{{ capsule.type }}</p>
</div>
<span class="badge badge-cyan">{{ capsule.status }}</span>
</div>
<p class="muted">{{ capsule.last_update || 'No update available.' }}</p>
<div class="meta-grid cols-2">
<p><strong>Reuse count:</strong> {{ capsule.reuse_count }}</p>
<p><strong>Launches:</strong> {{ capsule.launches?.length || 0 }}</p>
<p><strong>Water landings:</strong> {{ capsule.water_landings }}</p>
<p><strong>Land landings:</strong> {{ capsule.land_landings }}</p>
</div>
</article>
}
</section>
`
})
export class CapsulesPage implements OnInit {
private readonly spacexApi = inject(SpacexApiService);
capsules: any[] = [];
loading = true;
error = '';
ngOnInit() {
this.spacexApi.getCapsules().subscribe({
next: (docs) => {
this.capsules = docs;
this.loading = false;
},
error: () => {
this.error = 'Could not load capsules from SpaceX API.';
this.loading = false;
}
});
}
}

View File

@@ -0,0 +1,263 @@
import { Component, OnInit, inject } from '@angular/core';
import { SpacexApiService } from '../../services/spacex-api.service';
type LandingPad = {
id: string;
name?: string;
full_name?: string;
status?: string;
type?: string;
locality?: string;
region?: string;
wikipedia?: string;
details?: string;
landing_attempts?: number;
landing_successes?: number;
launches?: string[];
};
type LandingPadStats = {
totalPads: number;
activePads: number;
inactivePads: number;
totalAttempts: number;
totalSuccesses: number;
globalSuccessRate: string;
averageAttemptsPerPad: string;
oceanPlatforms: number;
groundPads: number;
mostUsedPadName: string;
mostUsedPadAttempts: number;
bestReliablePadName: string;
bestReliablePadRate: string;
};
@Component({
selector: 'app-landingpads-page',
template: `
<section class="page-head">
<h2>Landing Pads</h2>
<p>Recovery zones used by Falcon boosters.</p>
</section>
@if (loading) {
<p class="muted">Loading landing pads from HTTPS endpoint...</p>
}
@if (error) {
<p class="muted">{{ error }}</p>
}
@if (!loading && !error) {
<section class="cards four-cols stats">
<article class="card panel stat blue">
<p>Total pads</p>
<h3>{{ stats.totalPads }}</h3>
</article>
<article class="card panel stat green">
<p>Active pads</p>
<h3>{{ stats.activePads }}</h3>
</article>
<article class="card panel stat cyan">
<p>Total attempts</p>
<h3>{{ stats.totalAttempts }}</h3>
</article>
<article class="card panel stat purple">
<p>Global success rate</p>
<h3>{{ stats.globalSuccessRate }}</h3>
</article>
</section>
<section class="cards two-cols">
<article class="card panel">
<h3>Operational Snapshot</h3>
<div class="meta-grid cols-2">
<p><strong>Inactive pads:</strong> {{ stats.inactivePads }}</p>
<p><strong>Total successes:</strong> {{ stats.totalSuccesses }}</p>
<p><strong>Ocean platforms:</strong> {{ stats.oceanPlatforms }}</p>
<p><strong>Ground pads:</strong> {{ stats.groundPads }}</p>
<p><strong>Avg attempts/pad:</strong> {{ stats.averageAttemptsPerPad }}</p>
<p><strong>Most used:</strong> {{ stats.mostUsedPadName }} ({{ stats.mostUsedPadAttempts }})</p>
</div>
</article>
<article class="card panel">
<h3>Performance Highlight</h3>
<p class="muted">
Best reliable pad (minimum 5 attempts):
<strong class="strong">{{ stats.bestReliablePadName }}</strong>
</p>
<div class="meta-grid cols-2">
<p><strong>Success rate:</strong> {{ stats.bestReliablePadRate }}</p>
</div>
</article>
</section>
}
<section class="cards two-cols">
@for (pad of landingPads; track pad.id) {
<article class="card panel">
<div class="row between start">
<div>
<h3>{{ pad.full_name || pad.name }}</h3>
<p class="muted">{{ pad.locality }}, {{ pad.region }}</p>
</div>
<span class="badge" [class.badge-green]="pad.status === 'active'" [class.badge-gray]="pad.status !== 'active'">
{{ pad.status }}
</span>
</div>
<div class="meta-grid cols-2">
<p><strong>Type:</strong> {{ pad.type || 'N/A' }}</p>
<p><strong>Landings:</strong> {{ pad.landing_attempts }}</p>
<p><strong>Successes:</strong> {{ pad.landing_successes }}</p>
<p><strong>Success rate:</strong> {{ getPadSuccessRate(pad) }}</p>
<p><strong>Wikipedia:</strong> {{ pad.wikipedia ? 'Available' : 'N/A' }}</p>
<p><strong>Launch records:</strong> {{ pad.launches?.length || 0 }}</p>
</div>
<p class="muted">{{ pad.details || 'No additional details available.' }}</p>
</article>
}
</section>
`
})
export class LandingpadsPage implements OnInit {
private readonly spacexApi = inject(SpacexApiService);
landingPads: LandingPad[] = [];
loading = true;
error = '';
stats: LandingPadStats = {
totalPads: 0,
activePads: 0,
inactivePads: 0,
totalAttempts: 0,
totalSuccesses: 0,
globalSuccessRate: '0.0%',
averageAttemptsPerPad: '0.0',
oceanPlatforms: 0,
groundPads: 0,
mostUsedPadName: 'N/A',
mostUsedPadAttempts: 0,
bestReliablePadName: 'N/A',
bestReliablePadRate: 'N/A'
};
ngOnInit() {
this.spacexApi.getLandpads().subscribe({
next: (docs) => {
this.landingPads = [...docs].sort(
(a: LandingPad, b: LandingPad) =>
this.toSafeNumber(b.landing_attempts) - this.toSafeNumber(a.landing_attempts)
);
this.stats = this.buildStats(this.landingPads);
this.loading = false;
},
error: () => {
this.error = 'Could not load landing pads from SpaceX API.';
this.loading = false;
}
});
}
getPadSuccessRate(pad: LandingPad): string {
const attempts = this.toSafeNumber(pad.landing_attempts);
const successes = this.toSafeNumber(pad.landing_successes);
return this.toRate(successes, attempts);
}
private buildStats(pads: LandingPad[]): LandingPadStats {
const totalPads = pads.length;
const activePads = pads.filter((pad) => (pad.status || '').toLowerCase() === 'active').length;
const inactivePads = totalPads - activePads;
const totalAttempts = pads.reduce(
(sum, pad) => sum + this.toSafeNumber(pad.landing_attempts),
0
);
const totalSuccesses = pads.reduce(
(sum, pad) => sum + this.toSafeNumber(pad.landing_successes),
0
);
const oceanPlatforms = pads.filter((pad) => this.isOceanPlatform(pad.type)).length;
const groundPads = totalPads - oceanPlatforms;
const mostUsedPad =
pads.reduce(
(currentBest, pad) =>
this.toSafeNumber(pad.landing_attempts) > this.toSafeNumber(currentBest?.landing_attempts)
? pad
: currentBest,
pads[0]
) || null;
const reliableCandidates = pads.filter((pad) => this.toSafeNumber(pad.landing_attempts) >= 5);
const bestReliablePad =
reliableCandidates.reduce(
(currentBest, pad) => {
if (!currentBest) {
return pad;
}
const currentRate =
this.toSafeNumber(currentBest.landing_successes) /
this.toSafeNumber(currentBest.landing_attempts);
const candidateRate =
this.toSafeNumber(pad.landing_successes) / this.toSafeNumber(pad.landing_attempts);
if (candidateRate > currentRate) {
return pad;
}
if (
candidateRate === currentRate &&
this.toSafeNumber(pad.landing_attempts) > this.toSafeNumber(currentBest.landing_attempts)
) {
return pad;
}
return currentBest;
},
reliableCandidates[0]
) || null;
return {
totalPads,
activePads,
inactivePads,
totalAttempts,
totalSuccesses,
globalSuccessRate: this.toRate(totalSuccesses, totalAttempts),
averageAttemptsPerPad: totalPads === 0 ? '0.0' : (totalAttempts / totalPads).toFixed(1),
oceanPlatforms,
groundPads,
mostUsedPadName: mostUsedPad?.full_name || mostUsedPad?.name || 'N/A',
mostUsedPadAttempts: this.toSafeNumber(mostUsedPad?.landing_attempts),
bestReliablePadName: bestReliablePad?.full_name || bestReliablePad?.name || 'N/A',
bestReliablePadRate: bestReliablePad
? this.toRate(
this.toSafeNumber(bestReliablePad.landing_successes),
this.toSafeNumber(bestReliablePad.landing_attempts)
)
: 'N/A'
};
}
private toSafeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
private toRate(successes: number, attempts: number): string {
if (attempts <= 0) {
return '0.0%';
}
return `${((successes / attempts) * 100).toFixed(1)}%`;
}
private isOceanPlatform(type: string | undefined): boolean {
const normalized = (type || '').toLowerCase();
return normalized.includes('barge') || normalized.includes('drone') || normalized.includes('ship');
}
}

View File

@@ -0,0 +1,70 @@
import { Component, OnInit, inject } from '@angular/core';
import { SpacexApiService } from '../../services/spacex-api.service';
@Component({
selector: 'app-launchpads-page',
template: `
<section class="page-head">
<h2>Launchpads</h2>
<p>Launch infrastructure and usage history.</p>
</section>
@if (loading) {
<p class="muted">Loading launchpads from HTTPS endpoint...</p>
}
@if (error) {
<p class="muted">{{ error }}</p>
}
<section class="cards two-cols">
@for (pad of launchpads; track pad.id) {
<article class="card panel">
<div class="row between start">
<div>
<h3>{{ pad.full_name || pad.name }}</h3>
<p class="muted">{{ pad.locality }}, {{ pad.region }}</p>
</div>
<span class="badge badge-yellow">{{ pad.status }}</span>
</div>
<div class="meta-grid cols-2">
<p><strong>Attempts:</strong> {{ pad.launch_attempts }}</p>
<p><strong>Successes:</strong> {{ pad.launch_successes }}</p>
<p><strong>Timezone:</strong> {{ pad.timezone }}</p>
<p><strong>Rockets:</strong> {{ formatRocketList(pad.rockets) }}</p>
</div>
</article>
}
</section>
`
})
export class LaunchpadsPage implements OnInit {
private readonly spacexApi = inject(SpacexApiService);
launchpads: any[] = [];
loading = true;
error = '';
ngOnInit() {
this.spacexApi.getLaunchpads().subscribe({
next: (docs) => {
this.launchpads = docs;
this.loading = false;
},
error: () => {
this.error = 'Could not load launchpads from SpaceX API.';
this.loading = false;
}
});
}
formatRocketList(rockets: unknown): string {
if (!Array.isArray(rockets) || rockets.length === 0) {
return 'N/A';
}
return rockets
.map((rocket: any) => (typeof rocket === 'object' ? rocket.name : rocket))
.join(', ');
}
}

View File

@@ -0,0 +1,68 @@
import { DecimalPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { SpacexApiService } from '../../services/spacex-api.service';
@Component({
selector: 'app-rockets-page',
imports: [DecimalPipe],
template: `
<section class="page-head">
<h2>Rockets</h2>
<p>Current and historical launch vehicles from SpaceX API.</p>
</section>
@if (loading) {
<p class="muted">Loading rockets from HTTPS endpoint...</p>
}
@if (error) {
<p class="muted">{{ error }}</p>
}
<section class="cards two-cols">
@for (rocket of rockets; track rocket.id) {
<article class="card panel">
<div class="row between start">
<div>
<h3>{{ rocket.name }}</h3>
<p class="muted">{{ rocket.type }} • {{ rocket.first_flight }}</p>
</div>
<span class="badge" [class.badge-green]="rocket.active" [class.badge-red]="!rocket.active">
{{ rocket.active ? 'Active' : 'Retired' }}
</span>
</div>
<p class="muted">{{ rocket.description }}</p>
<div class="meta-grid cols-2">
<p><strong>Country:</strong> {{ rocket.country }}</p>
<p><strong>Company:</strong> {{ rocket.company }}</p>
<p><strong>Boosters:</strong> {{ rocket.boosters }}</p>
<p><strong>Stages:</strong> {{ rocket.stages }}</p>
<p><strong>Success:</strong> {{ rocket.success_rate_pct }}%</p>
<p><strong>Cost/Launch:</strong> &#36;{{ rocket.cost_per_launch | number }}</p>
</div>
</article>
}
</section>
`
})
export class RocketsPage implements OnInit {
private readonly spacexApi = inject(SpacexApiService);
rockets: any[] = [];
loading = true;
error = '';
ngOnInit() {
this.spacexApi.getRockets().subscribe({
next: (docs) => {
this.rockets = docs;
this.loading = false;
},
error: () => {
this.error = 'Could not load rockets from SpaceX API.';
this.loading = false;
}
});
}
}

View File

@@ -0,0 +1,60 @@
import { Component, OnInit, inject } from '@angular/core';
import { SpacexApiService } from '../../services/spacex-api.service';
@Component({
selector: 'app-starlink-page',
template: `
<section class="page-head">
<h2>Starlink</h2>
<p>Recent Starlink satellites with orbital metadata.</p>
</section>
@if (loading) {
<p class="muted">Loading Starlink data from HTTPS endpoint...</p>
}
@if (error) {
<p class="muted">{{ error }}</p>
}
<section class="cards two-cols">
@for (satellite of starlink; track satellite.id) {
<article class="card panel">
<div class="row between start">
<div>
<h3>{{ satellite.spaceTrack?.OBJECT_NAME || 'Starlink' }}</h3>
<p class="muted">{{ satellite.version || 'Unknown version' }}</p>
</div>
<span class="badge badge-purple">{{ satellite.height_km || '?' }} km</span>
</div>
<div class="meta-grid cols-2">
<p><strong>Latitude:</strong> {{ satellite.latitude }}</p>
<p><strong>Longitude:</strong> {{ satellite.longitude }}</p>
<p><strong>Velocity:</strong> {{ satellite.velocity_kms || '?' }} km/s</p>
<p><strong>NORAD:</strong> {{ satellite.spaceTrack?.NORAD_CAT_ID || 'N/A' }}</p>
</div>
</article>
}
</section>
`
})
export class StarlinkPage implements OnInit {
private readonly spacexApi = inject(SpacexApiService);
starlink: any[] = [];
loading = true;
error = '';
ngOnInit() {
this.spacexApi.getStarlink().subscribe({
next: (docs) => {
this.starlink = docs;
this.loading = false;
},
error: () => {
this.error = 'Could not load Starlink data from SpaceX API.';
this.loading = false;
}
});
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable, signal } from '@angular/core';
export type HttpLogEntry = {
id: number;
timestamp: string;
method: string;
url: string;
status: number;
durationMs: number;
ok: boolean;
};
@Injectable({ providedIn: 'root' })
export class HttpLogService {
private readonly _entries = signal<HttpLogEntry[]>([]);
private sequence = 1;
readonly entries = this._entries.asReadonly();
add(entry: Omit<HttpLogEntry, 'id' | 'timestamp'>): void {
const enriched: HttpLogEntry = {
id: this.sequence++,
timestamp: new Date().toISOString(),
...entry
};
this._entries.update((prev) => [enriched, ...prev].slice(0, 50));
}
clear(): void {
this._entries.set([]);
}
}

View File

@@ -0,0 +1,58 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
HttpResponse
} from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { HttpLogService } from './http-log.service';
export const httpLoggerInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const logger = inject(HttpLogService);
const start = performance.now();
return next(req).pipe(
tap({
next: (event) => {
if (!(event instanceof HttpResponse)) {
return;
}
const durationMs = Math.round(performance.now() - start);
logger.add({
method: req.method,
url: req.url,
status: event.status,
durationMs,
ok: event.ok
});
if (req.url.startsWith('https://')) {
console.log(`[HTTPS] ${req.method} ${req.url} -> ${event.status} (${durationMs}ms)`);
}
},
error: (error: HttpErrorResponse) => {
const durationMs = Math.round(performance.now() - start);
logger.add({
method: req.method,
url: req.url,
status: error.status || 0,
durationMs,
ok: false
});
if (req.url.startsWith('https://')) {
console.log(
`[HTTPS] ${req.method} ${req.url} -> ${error.status || 0} (${durationMs}ms, error)`
);
}
}
})
);
};