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"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.0",
|
"@angular/build": "^21.2.2",
|
||||||
"@angular/cli": "^21.0.0",
|
"@angular/cli": "^21.2.2",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.2.2",
|
||||||
"jsdom": "^27.1.0",
|
"jsdom": "^27.1.0",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
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 { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { httpLoggerInterceptor } from './services/http-logger.interceptor';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideHttpClient(),
|
provideHttpClient(withInterceptors([httpLoggerInterceptor])),
|
||||||
provideRouter(routes)
|
provideRouter(routes)
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { StarlinkPage } from './pages/starlink/starlink.page';
|
|||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: LaunchesPage },
|
{ path: '', component: LaunchesPage },
|
||||||
|
{ path: 'launches', redirectTo: '' },
|
||||||
{ path: 'rockets', component: RocketsPage },
|
{ path: 'rockets', component: RocketsPage },
|
||||||
{ path: 'capsules', component: CapsulesPage },
|
{ path: 'capsules', component: CapsulesPage },
|
||||||
{ path: 'starlink', component: StarlinkPage },
|
{ path: 'starlink', component: StarlinkPage },
|
||||||
|
|||||||
@@ -75,6 +75,45 @@
|
|||||||
padding-bottom: 52px;
|
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 {
|
.site-footer {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
padding: 22px 0;
|
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 { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { HttpLogService } from './services/http-log.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -30,6 +31,34 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
|||||||
|
|
||||||
<main class="container page-shell">
|
<main class="container page-shell">
|
||||||
<router-outlet />
|
<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>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
@@ -45,4 +74,11 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
|||||||
`,
|
`,
|
||||||
styleUrl: './app.scss'
|
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