feat(angular): add SpaceX API pages, HTTPS logging and landing pad stats
This commit is contained in:
524
angular-app/package-lock.json
generated
524
angular-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
62
angular-app/src/app/pages/capsules/capsules.page.ts
Normal file
62
angular-app/src/app/pages/capsules/capsules.page.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
263
angular-app/src/app/pages/landingpads/landingpads.page.ts
Normal file
263
angular-app/src/app/pages/landingpads/landingpads.page.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
70
angular-app/src/app/pages/launchpads/launchpads.page.ts
Normal file
70
angular-app/src/app/pages/launchpads/launchpads.page.ts
Normal 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(', ');
|
||||
}
|
||||
}
|
||||
68
angular-app/src/app/pages/rockets/rockets.page.ts
Normal file
68
angular-app/src/app/pages/rockets/rockets.page.ts
Normal 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> ${{ 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
60
angular-app/src/app/pages/starlink/starlink.page.ts
Normal file
60
angular-app/src/app/pages/starlink/starlink.page.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
33
angular-app/src/app/services/http-log.service.ts
Normal file
33
angular-app/src/app/services/http-log.service.ts
Normal 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([]);
|
||||
}
|
||||
}
|
||||
58
angular-app/src/app/services/http-logger.interceptor.ts
Normal file
58
angular-app/src/app/services/http-logger.interceptor.ts
Normal 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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user