This is an automated email from the ASF dual-hosted git repository.

shamrick pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new a5b2bd9281 TPv2 Date Revival (#7344)
a5b2bd9281 is described below

commit a5b2bd9281e65d4422138b23cd9ce013260751ec
Author: ocket8888 <[email protected]>
AuthorDate: Tue Feb 14 14:18:21 2023 -0700

    TPv2 Date Revival (#7344)
    
    * Add date utilities
    
    * Add date reviver interceptor
    
    * Remove now-unnecessary manual datestring handling
    
    * Add restrictions on what properties will be parsed
    
    Togglable via environment value. May be necessary in production
    environments, where attempting to parse non-dates may take significant
    processing time.
    
    * Add strong environment typing
    
    * Fix cg service unable to get a single Division
    
    * Fix unparsed HTTP errors resulting in no snackbar alerts
---
 .../src/app/api/cache-group.service.ts             |  47 ++---
 .../src/app/api/delivery-service.service.ts        |  14 +-
 .../src/app/api/invalidation-job.service.ts        |   9 +-
 .../src/app/api/physical-location.service.ts       |  19 +-
 .../traffic-portal/src/app/api/profile.service.ts  |  23 +--
 .../traffic-portal/src/app/api/server.service.ts   |  95 +++------
 .../traffic-portal/src/app/api/type.service.ts     |   7 +-
 .../traffic-portal/src/app/api/user.service.ts     |  93 ++-------
 .../server-details/server-details.component.html   |   3 +-
 .../interceptor/date-reviver.interceptor.spec.ts   | 123 ++++++++++++
 .../shared/interceptor/date-reviver.interceptor.ts |  72 +++++++
 .../app/shared/interceptor/error.interceptor.ts    |  26 ++-
 .../traffic-portal/src/app/shared/shared.module.ts |   4 +-
 .../traffic-portal/src/app/utils/date.spec.ts      | 220 +++++++++++++++++++++
 experimental/traffic-portal/src/app/utils/date.ts  | 189 ++++++++++++++++++
 .../src/environments/environment.prod.ts           |   4 +-
 .../traffic-portal/src/environments/environment.ts |   5 +-
 .../src/environments/environment.type.d.ts         |  34 ++++
 18 files changed, 738 insertions(+), 249 deletions(-)

diff --git a/experimental/traffic-portal/src/app/api/cache-group.service.ts 
b/experimental/traffic-portal/src/app/api/cache-group.service.ts
index 6bdb28f48f..3b4982746d 100644
--- a/experimental/traffic-portal/src/app/api/cache-group.service.ts
+++ b/experimental/traffic-portal/src/app/api/cache-group.service.ts
@@ -84,12 +84,9 @@ export class CacheGroupService extends APIService {
                        if (resp.length !== 1) {
                                throw new Error(`Traffic Ops returned wrong 
number of results for Cache Group identifier: ${params}`);
                        }
-                       const cg = resp[0];
-                       //  lastUpdated comes in as a string
-                       return {...cg, lastUpdated: new Date((cg.lastUpdated as 
unknown as string).replace("+00", "Z"))};
+                       return resp[0];
                }
-               const r = await 
this.get<Array<ResponseCacheGroup>>(path).toPromise();
-               return r.map(cg => ({...cg, lastUpdated: new 
Date((cg.lastUpdated as unknown as string).replace("+00", "Z"))}));
+               return this.get<Array<ResponseCacheGroup>>(path).toPromise();
        }
 
        /**
@@ -249,14 +246,11 @@ export class CacheGroupService extends APIService {
                                case "number":
                                        params = {id: String(nameOrID)};
                        }
-                       const r = await this.get<[ResponseDivision]>(path, 
undefined, params).toPromise();
-                       return {...r[0], lastUpdated: new 
Date((r[0].lastUpdated as unknown as string).replace("+00", "Z"))};
+                       const div = await this.get<[ResponseDivision]>(path, 
undefined, params).toPromise();
+                       return div[0];
 
                }
-               const divisions = await 
this.get<Array<ResponseDivision>>(path).toPromise();
-               return divisions.map(
-                       d => ({...d, lastUpdated: new Date((d.lastUpdated as 
unknown as string).replace("+00", "Z"))})
-               );
+               return this.get<Array<ResponseDivision>>(path).toPromise();
        }
 
        /**
@@ -267,11 +261,7 @@ export class CacheGroupService extends APIService {
         */
        public async updateDivision(division: ResponseDivision): 
Promise<ResponseDivision> {
                const path = `divisions/${division.id}`;
-               const response = await this.put<ResponseDivision>(path, 
division).toPromise();
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.put<ResponseDivision>(path, division).toPromise();
        }
 
        /**
@@ -281,11 +271,7 @@ export class CacheGroupService extends APIService {
         * @returns The created division.
         */
        public async createDivision(division: RequestDivision): 
Promise<ResponseDivision> {
-               const response = await this.post<ResponseDivision>("divisions", 
division).toPromise();
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.post<ResponseDivision>("divisions", 
division).toPromise();
        }
 
        /**
@@ -321,13 +307,10 @@ export class CacheGroupService extends APIService {
                                        params = {id: String(nameOrID)};
                        }
                        const r = await this.get<[ResponseRegion]>(path, 
undefined, params).toPromise();
-                       return {...r[0], lastUpdated: new 
Date((r[0].lastUpdated as unknown as string).replace("+00", "Z"))};
+                       return r[0];
 
                }
-               const regions = await 
this.get<Array<ResponseRegion>>(path).toPromise();
-               return regions.map(
-                       d => ({...d, lastUpdated: new Date((d.lastUpdated as 
unknown as string).replace("+00", "Z"))})
-               );
+               return this.get<Array<ResponseRegion>>(path).toPromise();
        }
 
        /**
@@ -338,11 +321,7 @@ export class CacheGroupService extends APIService {
         */
        public async updateRegion(region: ResponseRegion): 
Promise<ResponseRegion> {
                const path = `regions/${region.id}`;
-               const response = await this.put<ResponseRegion>(path, 
region).toPromise();
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.put<ResponseRegion>(path, region).toPromise();
        }
 
        /**
@@ -352,11 +331,7 @@ export class CacheGroupService extends APIService {
         * @returns The created region.
         */
        public async createRegion(region: RequestRegion): 
Promise<ResponseRegion> {
-               const response = await this.post<ResponseRegion>("regions", 
region).toPromise();
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.post<ResponseRegion>("regions", region).toPromise();
        }
 
        /**
diff --git 
a/experimental/traffic-portal/src/app/api/delivery-service.service.ts 
b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
index 7938d00662..74be3c9ced 100644
--- a/experimental/traffic-portal/src/app/api/delivery-service.service.ts
+++ b/experimental/traffic-portal/src/app/api/delivery-service.service.ts
@@ -193,19 +193,9 @@ export class DeliveryServiceService extends APIService {
                                        params = {id: String(id)};
                        }
                        const r = await 
this.get<[ResponseDeliveryService]>(path, undefined, params).toPromise();
-                       const ds = r[0];
-                       return {
-                               ...ds,
-                               lastUpdated: new Date((ds.lastUpdated as 
unknown as string).replace("+00", "Z"))
-                       };
+                       return r[0];
                }
-               const resp = await 
this.get<Array<ResponseDeliveryService>>(path).toPromise();
-               return resp.map(
-                       ds => ({
-                               ...ds,
-                               lastUpdated: new Date((ds.lastUpdated as 
unknown as string).replace("+00", "Z"))
-                       })
-               );
+               return 
this.get<Array<ResponseDeliveryService>>(path).toPromise();
        }
 
        /**
diff --git 
a/experimental/traffic-portal/src/app/api/invalidation-job.service.ts 
b/experimental/traffic-portal/src/app/api/invalidation-job.service.ts
index 1992e1e0a3..87d37ae645 100644
--- a/experimental/traffic-portal/src/app/api/invalidation-job.service.ts
+++ b/experimental/traffic-portal/src/app/api/invalidation-job.service.ts
@@ -70,14 +70,7 @@ export class InvalidationJobService extends APIService {
                                params.userId = String(opts.user.id);
                        }
                }
-               const js = await this.get<Array<ResponseInvalidationJob>>(path, 
undefined, params).toPromise();
-               const jobs = new Array<ResponseInvalidationJob>();
-               for (const j of js) {
-                       const tmp = String(j.startTime).replace(" ", 
"T").replace("+00", "Z");
-                       j.startTime = new Date(tmp);
-                       jobs.push(j);
-               }
-               return jobs;
+               return this.get<Array<ResponseInvalidationJob>>(path, 
undefined, params).toPromise();
        }
 
        /**
diff --git 
a/experimental/traffic-portal/src/app/api/physical-location.service.ts 
b/experimental/traffic-portal/src/app/api/physical-location.service.ts
index c1bfd6baa4..4d9f5eab32 100644
--- a/experimental/traffic-portal/src/app/api/physical-location.service.ts
+++ b/experimental/traffic-portal/src/app/api/physical-location.service.ts
@@ -45,13 +45,10 @@ export class PhysicalLocationService extends APIService {
                                        params = {id: String(nameOrID)};
                        }
                        const r = await 
this.get<[ResponsePhysicalLocation]>(path, undefined, params).toPromise();
-                       return {...r[0], lastUpdated: new 
Date((r[0].lastUpdated as unknown as string).replace("+00", "Z"))};
+                       return r[0];
 
                }
-               const physicalLocations = await 
this.get<Array<ResponsePhysicalLocation>>(path).toPromise();
-               return physicalLocations.map(
-                       d => ({...d, lastUpdated: new Date((d.lastUpdated as 
unknown as string).replace("+00", "Z"))})
-               );
+               return 
this.get<Array<ResponsePhysicalLocation>>(path).toPromise();
        }
 
        /**
@@ -62,11 +59,7 @@ export class PhysicalLocationService extends APIService {
         */
        public async updatePhysicalLocation(physicalLocation: 
ResponsePhysicalLocation): Promise<ResponsePhysicalLocation> {
                const path = `phys_locations/${physicalLocation.id}`;
-               const response = await this.put<ResponsePhysicalLocation>(path, 
physicalLocation).toPromise();
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.put<ResponsePhysicalLocation>(path, 
physicalLocation).toPromise();
        }
 
        /**
@@ -76,11 +69,7 @@ export class PhysicalLocationService extends APIService {
         * @returns The created Physical Location.
         */
        public async createPhysicalLocation(physicalLocation: 
RequestPhysicalLocation): Promise<ResponsePhysicalLocation> {
-               const response = await 
this.post<ResponsePhysicalLocation>("physicalLocations", 
physicalLocation).toPromise();
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.post<ResponsePhysicalLocation>("physicalLocations", 
physicalLocation).toPromise();
        }
 
        /**
diff --git a/experimental/traffic-portal/src/app/api/profile.service.ts 
b/experimental/traffic-portal/src/app/api/profile.service.ts
index 45661d5d82..bf638dbbf1 100644
--- a/experimental/traffic-portal/src/app/api/profile.service.ts
+++ b/experimental/traffic-portal/src/app/api/profile.service.ts
@@ -18,21 +18,6 @@ import { ResponseProfile } from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
-/**
- * Shared mapping function for converting Profile 'lastUpdated' fields to
- * actual dates, as well as the `lastUpdated` property of any and all
- * constituent Parameters thereof.
- *
- * @param p The Profile being converted.
- * @returns the converted Profile.
- */
-function profileMap(p: ResponseProfile): ResponseProfile {
-       return {
-               ...p,
-               lastUpdated: new Date((p.lastUpdated as unknown as 
string).replace("+00", "Z"))
-       };
-}
-
 /**
  * ProfileService exposes API functionality related to Profiles.
  */
@@ -58,7 +43,6 @@ export class ProfileService extends APIService {
         */
        public async getProfiles(idOrName?: number | string): 
Promise<Array<ResponseProfile> | ResponseProfile> {
                const path = "profiles";
-               let prom;
                if (idOrName !== undefined) {
                        let params;
                        switch (typeof idOrName) {
@@ -68,10 +52,9 @@ export class ProfileService extends APIService {
                                case "string":
                                        params = {name: idOrName};
                        }
-                       prom = this.get<[ResponseProfile]>(path, undefined, 
params).toPromise().then(r=>r[0]).then(profileMap);
-               } else {
-                       prom = 
this.get<Array<ResponseProfile>>(path).toPromise().then(r=>r.map(profileMap));
+                       const r = await this.get<[ResponseProfile]>(path, 
undefined, params).toPromise();
+                       return r[0];
                }
-               return prom;
+               return this.get<Array<ResponseProfile>>(path).toPromise();
        }
 }
diff --git a/experimental/traffic-portal/src/app/api/server.service.ts 
b/experimental/traffic-portal/src/app/api/server.service.ts
index 83f315d87a..4411056d03 100644
--- a/experimental/traffic-portal/src/app/api/server.service.ts
+++ b/experimental/traffic-portal/src/app/api/server.service.ts
@@ -18,33 +18,6 @@ import type { RequestServer, ResponseServer, ResponseStatus, 
Servercheck } from
 
 import { APIService } from "./base-api.service";
 
-/**
- * Shared mapping for massaging Status requests.
- *
- * @param s The Status to format.
- * @returns The Status object with a proper Date lastUpdated time.
- */
-function statusMap(s: ResponseStatus): ResponseStatus {
-       return {
-               ...s,
-               lastUpdated: new Date((s.lastUpdated as unknown as 
string).replace("+00", "Z"))
-       };
-}
-
-/**
- * Shared mapping for massaging Server requests.
- *
- * @param s The Server to massage.
- * @returns A Server that is identical to `s` except that its date/time fields 
are now actual Date objects.
- */
-function serverMap(s: ResponseServer): ResponseServer {
-       return {
-               ...s,
-               lastUpdated: !s.lastUpdated ? s.lastUpdated : new 
Date((s.lastUpdated as unknown as string).replace("+00", "Z")),
-               statusLastUpdated: !s.statusLastUpdated ? s.statusLastUpdated : 
new Date(s.statusLastUpdated as unknown as string)
-       };
-}
-
 /**
  * ServerService exposes API functionality related to Servers.
  */
@@ -70,34 +43,30 @@ export class ServerService extends APIService {
         */
        public async getServers(idOrName?: number | string): 
Promise<Array<ResponseServer> | ResponseServer> {
                const path = "servers";
-               let prom;
                if (idOrName !== undefined) {
+                       let servers;
                        switch (typeof idOrName) {
                                case "number":
-                                       prom = this.get<[ResponseServer]>(path, 
undefined, {id: String(idOrName)}).toPromise();
+                                       servers = await 
this.get<[ResponseServer]>(path, undefined, {id: String(idOrName)}).toPromise();
                                        break;
                                case "string":
-                                       prom = 
this.get<Array<ResponseServer>>(path, undefined, {hostName: 
idOrName}).toPromise();
+                                       servers = await 
this.get<Array<ResponseServer>>(path, undefined, {hostName: 
idOrName}).toPromise();
                        }
-                       prom = prom.then(
-                               servers => {
-                                       if (servers.length < 1) {
-                                               throw new Error(`no such server 
'${idOrName}'`);
-                                       }
-                                       if (servers.length > 1) {
-                                               console.warn(
-                                                       "Traffic Ops returned",
-                                                       servers.length,
-                                                       `servers with host name 
'${idOrName}' - selecting the first arbitrarily`
-                                               );
-                                       }
-                                       return servers[0];
-                               }
-                       ).then(serverMap);
-               } else {
-                       prom = 
this.get<Array<ResponseServer>>(path).toPromise().then(ss=>ss.map(serverMap));
+                       if (servers.length < 1) {
+                               throw new Error(`no such server '${idOrName}'`);
+                       }
+                       // This is, unfortunately, possible, despite the many 
assumptions to
+                       // the contrary.
+                       if (servers.length > 1) {
+                               console.warn(
+                                       "Traffic Ops returned",
+                                       servers.length,
+                                       `servers with host name '${idOrName}' - 
selecting the first arbitrarily`
+                               );
+                       }
+                       return servers[0];
                }
-               return prom;;
+               return this.get<Array<ResponseServer>>(path).toPromise();
        }
 
        /**
@@ -107,7 +76,7 @@ export class ServerService extends APIService {
         * @returns The server as created and returned by the API.
         */
        public async createServer(s: RequestServer): Promise<ResponseServer> {
-               return this.post<ResponseServer>("servers", 
s).toPromise().then(serverMap);
+               return this.post<ResponseServer>("servers", s).toPromise();
        }
 
        public async getServerChecks(): Promise<Servercheck[]>;
@@ -149,13 +118,13 @@ export class ServerService extends APIService {
                let ret;
                switch (typeof idOrName) {
                        case "number":
-                               ret = this.get<[ResponseStatus]>(path, {params: 
{id: String(idOrName)}}).toPromise().then(r=>r[0]).then(statusMap);
+                               ret = this.get<[ResponseStatus]>(path, {params: 
{id: String(idOrName)}}).toPromise();
                                break;
                        case "string":
-                               ret = this.get<[ResponseStatus]>(path, {params: 
{name: idOrName}}).toPromise().then(r=>r[0]).then(statusMap);
+                               ret = this.get<[ResponseStatus]>(path, {params: 
{name: idOrName}}).toPromise();
                                break;
                        default:
-                               ret = 
this.get<Array<ResponseStatus>>(path).toPromise().then(ss=>ss.map(statusMap));
+                               ret = 
this.get<Array<ResponseStatus>>(path).toPromise();
                }
                return ret;
        }
@@ -176,12 +145,7 @@ export class ServerService extends APIService {
                        id = server.id;
                }
 
-               return this.post<{serverId: number; action: 
"queue"}>(`servers/${id}/queue_update`, {action: "queue"}).toPromise().catch(
-                       e => {
-                               console.error("Failed to queue updates:", e);
-                               return {action: "queue", serverId: -1};
-                       }
-               );
+               return this.post<{serverId: number; action: 
"queue"}>(`servers/${id}/queue_update`, {action: "queue"}).toPromise();
        }
 
        /**
@@ -200,12 +164,7 @@ export class ServerService extends APIService {
                        id = server.id;
                }
 
-               return this.post<{serverId: number; action: 
"dequeue"}>(`servers/${id}/queue_update`, {action: 
"dequeue"}).toPromise().catch(
-                       e => {
-                               console.error("Failed to clear updates:", e);
-                               return {action: "dequeue", serverId: -1};
-                       }
-               );
+               return this.post<{serverId: number; action: 
"dequeue"}>(`servers/${id}/queue_update`, {action: "dequeue"}).toPromise();
        }
 
        /**
@@ -214,7 +173,6 @@ export class ServerService extends APIService {
         * @param server Either the server that will have its status changed, 
or the integral, unique identifier thereof.
         * @param status The name of the status to which to set the server.
         * @param offlineReason The reason why the server was placed into a 
non-ONLINE or REPORTED status.
-        * @returns Nothing.
         */
        public async updateStatus(server: number | ResponseServer, status: 
string, offlineReason?: string): Promise<undefined> {
                let id: number;
@@ -226,11 +184,6 @@ export class ServerService extends APIService {
                        id = server.id;
                }
 
-               return this.put(`servers/${id}/status`, {offlineReason, 
status}).toPromise().catch(
-                       e=> {
-                               console.error("Failed to update server 
status:", e);
-                               return undefined;
-                       }
-               );
+               return this.put(`servers/${id}/status`, {offlineReason, 
status}).toPromise();
        }
 }
diff --git a/experimental/traffic-portal/src/app/api/type.service.ts 
b/experimental/traffic-portal/src/app/api/type.service.ts
index 91f525280b..2fd0e20093 100644
--- a/experimental/traffic-portal/src/app/api/type.service.ts
+++ b/experimental/traffic-portal/src/app/api/type.service.ts
@@ -67,12 +67,7 @@ export class TypeService extends APIService {
         * @returns The requested Types.
         */
        public async getTypesInTable(useInTable: UseInTable): 
Promise<Array<TypeFromResponse>> {
-               return this.get<Array<TypeFromResponse>>("types", undefined, 
{useInTable}).toPromise().catch(
-                       (e) => {
-                               console.error("Failed to get Types:", e);
-                               return [];
-                       }
-               );
+               return this.get<Array<TypeFromResponse>>("types", undefined, 
{useInTable}).toPromise();
        }
 
        /**
diff --git a/experimental/traffic-portal/src/app/api/user.service.ts 
b/experimental/traffic-portal/src/app/api/user.service.ts
index f53306a4ab..1c68efbab7 100644
--- a/experimental/traffic-portal/src/app/api/user.service.ts
+++ b/experimental/traffic-portal/src/app/api/user.service.ts
@@ -12,18 +12,18 @@
 * limitations under the License.
 */
 
-import { HttpClient, HttpResponse } from "@angular/common/http";
+import { HttpClient, type HttpResponse } from "@angular/common/http";
 import { Injectable } from "@angular/core";
 import {
-       Capability,
-       ResponseUser,
-       PostRequestUser,
-       RequestTenant,
-       ResponseCurrentUser,
-       ResponseRole,
-       ResponseTenant,
-       PutRequestUser,
-       RegistrationRequest,
+       type Capability,
+       type ResponseUser,
+       type PostRequestUser,
+       type RequestTenant,
+       type ResponseCurrentUser,
+       type ResponseRole,
+       type ResponseTenant,
+       type PutRequestUser,
+       type RegistrationRequest,
        userEmailIsValid
 } from "trafficops-types";
 
@@ -50,20 +50,10 @@ export class UserService extends APIService {
        public async login(uOrT: string, p?: string): 
Promise<HttpResponse<object> | null> {
                let path = `/api/${this.apiVersion}/user/login`;
                if (p !== undefined) {
-                       return this.http.post(path, {p, u: uOrT}, 
this.defaultOptions).toPromise().catch(
-                               e => {
-                                       console.error("Failed to login:", e);
-                                       return null;
-                               }
-                       );
+                       return this.http.post(path, {p, u: uOrT}, 
this.defaultOptions).toPromise();
                }
                path += "/token";
-               return this.http.post(path, {t: uOrT}, 
this.defaultOptions).toPromise().catch(
-                       e => {
-                               console.error("Failed to login with token:", e);
-                               return null;
-                       }
-               );
+               return this.http.post(path, {t: uOrT}, 
this.defaultOptions).toPromise();
        }
 
        /**
@@ -74,12 +64,7 @@ export class UserService extends APIService {
         */
        public async logout(): Promise<HttpResponse<object> | null> {
                const path = `/api/${this.apiVersion}/user/logout`;
-               return this.http.post(path, undefined, 
this.defaultOptions).toPromise().catch(
-                       e => {
-                               console.error("Failed to logout:", e);
-                               return null;
-                       }
-               );
+               return this.http.post(path, undefined, 
this.defaultOptions).toPromise();
        }
 
        /**
@@ -89,11 +74,7 @@ export class UserService extends APIService {
         */
        public async getCurrentUser(): Promise<ResponseCurrentUser> {
                const path = "user/current";
-               const r = await this.get<ResponseCurrentUser>(path).toPromise();
-               return {
-                       ...r,
-                       lastUpdated: new Date((r.lastUpdated as unknown as 
string).replace("+00", "Z"))
-               };
+               return this.get<ResponseCurrentUser>(path).toPromise();
        }
 
        /**
@@ -149,22 +130,9 @@ export class UserService extends APIService {
                                        params = {id: String(nameOrID)};
                        }
                        const r = await this.get<[ResponseUser]>(path, 
undefined, params).toPromise();
-                       return {
-                               ...r[0],
-                               lastAuthenticated: r[0].lastAuthenticated ? new 
Date((r[0].lastAuthenticated as unknown as string)) : null,
-                               lastUpdated: new Date((r[0].lastUpdated as 
unknown as string).replace(" ", "T").replace("+00", "Z")),
-                               registrationSent: r[0].registrationSent ? new 
Date((r[0].registrationSent as unknown as string)) : null
-                       };
+                       return r[0];
                }
-               const users = await 
this.get<Array<ResponseUser>>(path).toPromise();
-               return users.map(
-                       u => ({
-                               ...u,
-                               lastAuthenticated: u.lastAuthenticated ? new 
Date((u.lastAuthenticated as unknown as string)) : null,
-                               lastUpdated: new Date((u.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z")),
-                               registrationSent: u.registrationSent ? new 
Date((u.registrationSent as unknown as string)) : null
-                       })
-               );
+               return this.get<Array<ResponseUser>>(path).toPromise();
        }
 
        /**
@@ -204,13 +172,7 @@ export class UserService extends APIService {
                        id = user.id;
                }
                const path = `users/${id}`;
-               const response = await this.put<ResponseUser>(path, 
body).toPromise();
-               return {
-                       ...response,
-                       lastAuthenticated: response.lastAuthenticated ? new 
Date((response.lastAuthenticated as unknown as string)) : null,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z")),
-                       registrationSent: response.registrationSent ? new 
Date((response.registrationSent as unknown as string)) : null
-               };
+               return this.put<ResponseUser>(path, body).toPromise();
        }
 
        /**
@@ -220,13 +182,7 @@ export class UserService extends APIService {
         * @returns The created user.
         */
        public async createUser(user: PostRequestUser): Promise<ResponseUser> {
-               const response = await  this.post<ResponseUser>("users", 
user).toPromise();
-               return {
-                       ...response,
-                       lastAuthenticated: response.lastAuthenticated ? new 
Date((response.lastAuthenticated as unknown as string)) : null,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z")),
-                       registrationSent: response.registrationSent ? new 
Date((response.registrationSent as unknown as string)) : null
-               };
+               return this.post<ResponseUser>("users", user).toPromise();
        }
 
        /**
@@ -354,11 +310,7 @@ export class UserService extends APIService {
         * @returns The created tenant.
         */
        public async createTenant(tenant: RequestTenant): 
Promise<ResponseTenant> {
-               const response = await this.post<ResponseTenant>("tenants", 
tenant).toPromise();
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.post<ResponseTenant>("tenants", tenant).toPromise();
        }
 
        /**
@@ -368,12 +320,7 @@ export class UserService extends APIService {
         * @returns The updated tenant.
         */
        public async updateTenant(tenant: ResponseTenant): 
Promise<ResponseTenant> {
-               const response = await 
this.put<ResponseTenant>(`tenants/${tenant.id}`, tenant).toPromise();
-
-               return {
-                       ...response,
-                       lastUpdated: new Date((response.lastUpdated as unknown 
as string).replace(" ", "T").replace("+00", "Z"))
-               };
+               return this.put<ResponseTenant>(`tenants/${tenant.id}`, 
tenant).toPromise();
        }
 
        /**
diff --git 
a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
 
b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
index a61eafe371..2d09e79d2b 100644
--- 
a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
+++ 
b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
@@ -12,7 +12,8 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 -->
 <mat-card>
-       <mat-card-content>
+       <tp-loading *ngIf="!server"></tp-loading>
+       <mat-card-content *ngIf="server">
                <div class="actions-container" *ngIf="!isNew">
                        <button [disabled]="!isCache()" mat-button 
type="button">Manage Capabilities</button>
                        <button [disabled]="!isCache()" mat-button 
type="button">Manage Delivery Services</button>
diff --git 
a/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.spec.ts
 
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.spec.ts
new file mode 100644
index 0000000000..1b65063fd6
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.spec.ts
@@ -0,0 +1,123 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { HttpClient, HTTP_INTERCEPTORS } from "@angular/common/http";
+import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
+import { TestBed } from "@angular/core/testing";
+
+import { DateReviverInterceptor } from "./date-reviver.interceptor";
+
+/**
+ * Holds some Date instance data fields for testing Date revival.
+ */
+interface TestResponse {
+       lastAuthenticated: Date;
+       lastUpdated: Date;
+       statusLastUpdated: Date;
+}
+
+describe("DateReviverInterceptor", () => {
+       let httpClient: HttpClient;
+       let httpTestingController: HttpTestingController;
+
+       beforeEach(() => {
+               TestBed.configureTestingModule({
+                       imports: [
+                               HttpClientTestingModule
+                       ],
+                       providers: [
+                               DateReviverInterceptor,
+                               { multi: true, provide: HTTP_INTERCEPTORS, 
useClass: DateReviverInterceptor}
+                       ]
+               });
+               // Inject the http service and test controller for each test
+               httpClient = TestBed.inject(HttpClient);
+               httpTestingController = TestBed.inject(HttpTestingController);
+       });
+
+       it("should be created", () => {
+               const interceptor: DateReviverInterceptor = 
TestBed.inject(DateReviverInterceptor);
+               expect(interceptor).toBeTruthy();
+       });
+
+       it("revives dates sent in responses", () => {
+               const testData = {
+                       lastAuthenticated: "2022-03-31T08:01:02.000+00",
+                       lastUpdated: "2022-03-31 08:01:02+00",
+                       statusLastUpdated: "2022-03-31T08:01:02Z"
+               };
+
+               httpClient.get<TestResponse>("/api/data").subscribe(
+                       data => {
+                               expect(typeof data).toBe("object");
+
+                               const dataProperties = Object.keys(data);
+                               expect(dataProperties.length).toBe(3);
+                               
expect(dataProperties).toContain("lastAuthenticated");
+                               
expect(data.lastAuthenticated).toBeInstanceOf(Date);
+                               expect(dataProperties).toContain("lastUpdated");
+                               expect(data.lastUpdated).toBeInstanceOf(Date);
+                               
expect(dataProperties).toContain("statusLastUpdated");
+                               
expect(data.statusLastUpdated).toBeInstanceOf(Date);
+
+                               const expectedDate = new Date(Date.UTC(2022, 2, 
31, 8, 1, 2, 0));
+
+                               
expect(data.lastAuthenticated).toEqual(expectedDate);
+                               expect(data.lastUpdated).toEqual(expectedDate);
+                               
expect(data.statusLastUpdated).toEqual(expectedDate);
+                       }
+               );
+
+               httpTestingController.expectOne("/api/data").flush(testData);
+       });
+
+       it("doesn't modify non-date properties of responses", () => {
+               const testData = {
+                       arr: [],
+                       bool: true,
+                       num: 1,
+                       obj: {},
+                       str: "some string that doesn't look like a date",
+                       strNum: "12345"
+               };
+
+               httpClient.get<typeof testData>("/api/data").subscribe(
+                       data => {
+                               expect(data).toEqual(testData);
+                       }
+               );
+
+               httpTestingController.expectOne("/api/data").flush(testData);
+       });
+
+       it("doesn't modify responses where JSON parsing wasn't requested", ()=>{
+               const testData = JSON.stringify({
+                       lastAuthenticated: "2022-03-31T08:01:02.000+00",
+                       lastUpdated: "2022-03-31 08:01:02+00",
+                       statusLastUpdated: "2022-03-31T08:01:02Z"
+               });
+
+               httpClient.get("/api/data", {responseType: "text"}).subscribe(
+                       data => {
+                               expect(data).toEqual(testData);
+                       }
+               );
+
+               httpTestingController.expectOne("/api/data").flush(testData);
+       });
+
+       afterEach(() => {
+               httpTestingController.verify();
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.ts
 
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.ts
new file mode 100644
index 0000000000..723ecd2ddd
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/shared/interceptor/date-reviver.interceptor.ts
@@ -0,0 +1,72 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import {
+       type HttpRequest,
+       type HttpHandler,
+       type HttpEvent,
+       type HttpInterceptor,
+       HttpResponse
+} from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import type { Observable } from "rxjs";
+import { map } from "rxjs/operators";
+
+import { dateReviver } from "src/app/utils/date";
+
+/**
+ * The DateReviverInterceptor adds custom JSON parsing to all HTTP requests 
that
+ * converts date strings to Date instances.
+ */
+@Injectable()
+export class DateReviverInterceptor implements HttpInterceptor {
+
+       /**
+        * Parses the eventually received response (if indeed one is received) 
as a
+        * JSON payload, reviving string values that look like dates into Date
+        * instances.
+        *
+        * @param request The outgoing request.
+        * @param next The next step in the request process.
+        * @returns The response events. Non-response events or those without
+        * response bodies are untouched.
+        */
+       private parseResponseJSON(request: HttpRequest<unknown>, next: 
HttpHandler): Observable<HttpEvent<unknown>> {
+               request = request.clone({responseType: "text"});
+               return next.handle(request).pipe(map(
+                       event => {
+                               if (event instanceof HttpResponse && typeof 
event.body === "string") {
+                                       return event.clone({body: 
JSON.parse(event.body, dateReviver)});
+                               }
+                               return event;
+                       }));
+       }
+
+       /**
+        * Intercepts requests with the "json" response type to add a custom 
parser
+        * for date strings.
+        *
+        * @param request The outgoing request, before being sent.
+        * @param next The next step in the HTTP handler stack.
+        * @returns The response events. Non-response events or those without
+        * response bodies are untouched.
+        */
+       public intercept(request: HttpRequest<unknown>, next: HttpHandler): 
Observable<HttpEvent<unknown>> {
+               if (request.responseType === "json") {
+                       // If the expected response type is JSON then handle it 
here.
+                       return this.parseResponseJSON(request, next);
+               }
+               return next.handle(request);
+       }
+}
diff --git 
a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts 
b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
index 3d2ce2ac94..55bc33993e 100644
--- 
a/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
+++ 
b/experimental/traffic-portal/src/app/shared/interceptor/error.interceptor.ts
@@ -32,6 +32,17 @@ export class ErrorInterceptor implements HttpInterceptor {
                private readonly router: Router
        ) {}
 
+       /**
+        * Raises all passed alerts to the AlertService.
+        *
+        * @param alerts The alerts to be raised.
+        */
+       private raiseAlerts(alerts: Alert[]): void {
+               for (const alert of alerts) {
+                       this.alerts.newAlert(alert);
+               }
+       }
+
        /**
         * Intercepts HTTP responses and checks for erroneous responses, 
displaying
         * appropriate error Alerts and redirecting unauthenticated users to the
@@ -42,13 +53,20 @@ export class ErrorInterceptor implements HttpInterceptor {
         * @returns An Observable that will emit an event if the request fails.
         */
        public intercept(request: HttpRequest<unknown>, next: HttpHandler): 
Observable<HttpEvent<unknown>> {
-               return next.handle(request).pipe(catchError((err) => {
+               return next.handle(request).pipe(catchError((err: 
HttpErrorResponse) => {
                        console.error("HTTP Error: ", err);
 
-                       if (err.hasOwnProperty("error") && (err as {error: 
object}).error.hasOwnProperty("alerts")) {
-                               for (const a of (err as {error: {alerts: 
Alert[]}}).error.alerts) {
-                                       this.alerts.newAlert(a);
+                       if (typeof(err.error) === "string") {
+                               try {
+                                       const body: {alerts: Alert[] | 
undefined} = JSON.parse(err.error);
+                                       if (Array.isArray(body.alerts)) {
+                                               this.raiseAlerts(body.alerts);
+                                       }
+                               } catch (e) {
+                                       console.error("non-JSON HTTP error 
response:", e);
                                }
+                       } else if (typeof(err.error) === "object" && 
Array.isArray(err.error.alerts)) {
+                               this.raiseAlerts(err.error.alerts);
                        }
 
                        if (err instanceof HttpErrorResponse && err.status === 
401 && this.router.getCurrentNavigation() === null) {
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts 
b/experimental/traffic-portal/src/app/shared/shared.module.ts
index a3c514913e..3e7076a163 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -28,6 +28,7 @@ import { DecisionDialogComponent } from 
"./dialogs/decision-dialog/decision-dial
 import { TextDialogComponent } from 
"./dialogs/text-dialog/text-dialog.component";
 import { GenericTableComponent } from 
"./generic-table/generic-table.component";
 import { AlertInterceptor } from "./interceptor/alerts.interceptor";
+import { DateReviverInterceptor } from 
"./interceptor/date-reviver.interceptor";
 import { ErrorInterceptor } from "./interceptor/error.interceptor";
 import { LoadingComponent } from "./loading/loading.component";
 import { ObscuredTextInputComponent } from 
"./obscured-text-input/obscured-text-input.component";
@@ -83,7 +84,8 @@ import { CustomvalidityDirective } from 
"./validation/customvalidity.directive";
        ],
        providers: [
                { multi: true, provide: HTTP_INTERCEPTORS, useClass: 
ErrorInterceptor },
-               { multi: true, provide: HTTP_INTERCEPTORS, useClass: 
AlertInterceptor }
+               { multi: true, provide: HTTP_INTERCEPTORS, useClass: 
AlertInterceptor },
+               { multi: true, provide: HTTP_INTERCEPTORS, useClass: 
DateReviverInterceptor}
        ]
 })
 export class SharedModule { }
diff --git a/experimental/traffic-portal/src/app/utils/date.spec.ts 
b/experimental/traffic-portal/src/app/utils/date.spec.ts
new file mode 100644
index 0000000000..4e40032840
--- /dev/null
+++ b/experimental/traffic-portal/src/app/utils/date.spec.ts
@@ -0,0 +1,220 @@
+/*
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { dateReviver, parseHTTPDate } from "./date";
+
+describe("date utilities", () => {
+       it("revives legacy, custom TO timestamps into dates", ()=>{
+               const data = '{"notADate": "testquest", "myDate": "2022-01-02 
03:04:05+00"}';
+               const parsed = JSON.parse(data, dateReviver);
+               const keys = Object.keys(parsed);
+               expect(keys.length).toBe(2);
+               expect(keys).toContain("notADate");
+               expect(keys).toContain("myDate");
+               expect(typeof(parsed.notADate)).toBe("string");
+
+               const parsedDate = parsed.myDate;
+               if (!(parsedDate instanceof Date)) {
+                       return fail(`expected "${parsedDate}" to be a Date`);
+               }
+               expect(parsedDate.getUTCFullYear()).toBe(2022);
+               expect(parsedDate.getUTCMonth()).toBe(0);
+               expect(parsedDate.getUTCDate()).toBe(2);
+               expect(parsedDate.getUTCHours()).toBe(3);
+               expect(parsedDate.getUTCMinutes()).toBe(4);
+               expect(parsedDate.getUTCSeconds()).toBe(5);
+               expect(parsedDate.getUTCMilliseconds()).toBe(0);
+       });
+
+       it("revives RFC3339 timestamps into dates", () => {
+               const data = '{"notADate": "testquest", "myDate": 
"2022-01-02T03:04:05Z"}';
+               const parsed = JSON.parse(data, dateReviver);
+               const keys = Object.keys(parsed);
+               expect(keys.length).toBe(2);
+               expect(keys).toContain("notADate");
+               expect(keys).toContain("myDate");
+               expect(typeof(parsed.notADate)).toBe("string");
+
+               const parsedDate = parsed.myDate;
+               if (!(parsedDate instanceof Date)) {
+                       return fail(`expected "${parsedDate}" to be a Date`);
+               }
+               expect(parsedDate.getUTCFullYear()).toBe(2022);
+               expect(parsedDate.getUTCMonth()).toBe(0);
+               expect(parsedDate.getUTCDate()).toBe(2);
+               expect(parsedDate.getUTCHours()).toBe(3);
+               expect(parsedDate.getUTCMinutes()).toBe(4);
+               expect(parsedDate.getUTCSeconds()).toBe(5);
+               expect(parsedDate.getUTCMilliseconds()).toBe(0);
+       });
+
+       it("revives RFC3339 timestamps with sub-second precision into dates", 
() => {
+               const data = '{"notADate": "testquest", "myDate": 
"2022-01-02T03:04:05.6789Z"}';
+               const parsed = JSON.parse(data, dateReviver);
+               const keys = Object.keys(parsed);
+               expect(keys.length).toBe(2);
+               expect(keys).toContain("notADate");
+               expect(keys).toContain("myDate");
+               expect(typeof(parsed.notADate)).toBe("string");
+
+               const parsedDate = parsed.myDate;
+               if (!(parsedDate instanceof Date)) {
+                       return fail(`expected "${parsedDate}" to be a Date`);
+               }
+               expect(parsedDate.getUTCFullYear()).toBe(2022);
+               expect(parsedDate.getUTCMonth()).toBe(0);
+               expect(parsedDate.getUTCDate()).toBe(2);
+               expect(parsedDate.getUTCHours()).toBe(3);
+               expect(parsedDate.getUTCMinutes()).toBe(4);
+               expect(parsedDate.getUTCSeconds()).toBe(5);
+               expect(parsedDate.getUTCMilliseconds()).toBe(678);
+       });
+
+       it("leaves unparsable dates alone", () => {
+               const data = {
+                       lastAuthenticated: "not a valid date",
+                       lastUpdated: "9999-99-99T99:99:99.99Z"
+               };
+               const parsed = JSON.parse(JSON.stringify(data), dateReviver);
+               expect(parsed).toEqual(data);
+       });
+
+       it("parses HTTP header dates", () => {
+               let date = "Sun, 02 Jan 2022 03:04:05 GMT";
+               let parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(0);
+               expect(parsed.getUTCDate()).toBe(2);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Mon, 07 Feb 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(1);
+               expect(parsed.getUTCDate()).toBe(7);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Tue, 01 Mar 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(2);
+               expect(parsed.getUTCDate()).toBe(1);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Wed, 06 Apr 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(3);
+               expect(parsed.getUTCDate()).toBe(6);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Thu, 05 May 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(4);
+               expect(parsed.getUTCDate()).toBe(5);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Fri, 03 Jun 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(5);
+               expect(parsed.getUTCDate()).toBe(3);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Sat, 02 Jul 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(6);
+               expect(parsed.getUTCDate()).toBe(2);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Mon, 01 Aug 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(7);
+               expect(parsed.getUTCDate()).toBe(1);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Thu, 01 Sep 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(8);
+               expect(parsed.getUTCDate()).toBe(1);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Sat, 01 Oct 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(9);
+               expect(parsed.getUTCDate()).toBe(1);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Tue, 01 Nov 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(10);
+               expect(parsed.getUTCDate()).toBe(1);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+
+               date = "Thu, 01 Dec 2022 03:04:05 GMT";
+               parsed = parseHTTPDate(date);
+               expect(parsed.getUTCFullYear()).toBe(2022);
+               expect(parsed.getUTCMonth()).toBe(11);
+               expect(parsed.getUTCDate()).toBe(1);
+               expect(parsed.getUTCHours()).toBe(3);
+               expect(parsed.getUTCMinutes()).toBe(4);
+               expect(parsed.getUTCSeconds()).toBe(5);
+               expect(parsed.getUTCMilliseconds()).toBe(0);
+       });
+
+       it("fails to parse invalid dates", ()=>{
+               expect(()=>parseHTTPDate("not a date")).toThrow();
+               expect(()=>parseHTTPDate("Thu, 01 NaN 2022 03:04:05 
GMT")).toThrow();
+       });
+});
diff --git a/experimental/traffic-portal/src/app/utils/date.ts 
b/experimental/traffic-portal/src/app/utils/date.ts
new file mode 100644
index 0000000000..4c0975b3da
--- /dev/null
+++ b/experimental/traffic-portal/src/app/utils/date.ts
@@ -0,0 +1,189 @@
+/*
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import { environment } from "src/environments/environment";
+
+/**
+ * MalformedDateError is an Error that provides the raw date that caused it as 
a
+ * readable property. Other than that, it's just like an Error.
+ */
+export class MalformedDateError extends Error {
+
+       public readonly date: string;
+
+       constructor(date: string) {
+               super();
+               this.message = `malformed date: ${date}`;
+               this.date = date;
+       }
+}
+
+/**
+ * Matches both the legacy, custom TO timestamp strings and RFC3339 with
+ * optional sub-second precision. This is, unfortunately, necessary, because
+ * Chrome's overly permissive Date parser will attempt to recognize strings
+ * containing numbers as years, and parse them as 01-01 of that year at
+ * 00:00:00.
+ */
+const datePattern = /^(\d{4})-(\d{2})-(\d{2})[T 
](\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:[\+-]00|Z)$/;
+
+const knownDateProps = new Set([
+       "configApplyTime",
+       "configUpdateTime",
+       "createdAt",
+       "effectiveDate",
+       "expiration",
+       "lastAuthenticated",
+       "lastUpdated",
+       "registrationSent",
+       "revalApplyTime",
+       "revalUpdateTime",
+       "startTime",
+       "statusLastUpdated",
+       "summaryTime",
+
+       // UNIX Epoch timestamp in seconds
+       // "date",
+
+       // UNIX Epoch timestamp in seconds - but as a string
+       // "expirationDate",
+       // "inceptionDate",
+]);
+
+const exhaustiveCheck = (_: PropertyKey, v: unknown): v is string => typeof(v) 
=== "string" && datePattern.test(v);
+const restrictiveCheck = (k: PropertyKey, v: unknown): v is string => 
knownDateProps.has(String(k)) && typeof(v) === "string";
+
+// TODO: figure out a way to do this that doesn't involve recalculating on 
every
+// property of every object on every API call, and yet allows testing.
+const check = environment.useExhaustiveDates ? exhaustiveCheck : 
restrictiveCheck;
+
+/**
+ * dateReviver is meant to be passed into a JSON.parse call as a "reviver"
+ * callback. It causes strings that look like dates to be converted to Date
+ * objects.
+ *
+ * This only supports UTC timestamps (which is all Traffic Ops is capable of
+ * producing).
+ *
+ * If a timestamp has sub-milisecond precision, the trailing digits beyond the
+ * thousandths place are truncated before parsing.
+ *
+ * Note that this will do this for **all** strings that look like dates! If, 
for
+ * example, a Delivery Service's LongDescription contains only an RFC3339
+ * datestamp, it will be improperly converted!
+ *
+ * @todo Find a way to specify object keys that should be left alone.
+ *
+ * @example
+ *
+ * const data = `{"notADate": "testquest", "myDate": "2022-01-01T00:00:00Z"}`;
+ * const parsed = JSON.parse(data, dateReviver);
+ * console.log(typeof parsed.notADate); // prints "string"
+ * console.log(parsed.myDate instanceof Date); // prints true
+ *
+ * @param k The name of the property being parsed.
+ * @param v The value of the property being parsed.
+ * @returns Either the parsed date, or just whatever the value is if it's not a
+ * string that looks like a date.
+ */
+export function dateReviver(k: PropertyKey, v: unknown): Date | unknown {
+       if (!check(k, v)) {
+               return v;
+       }
+
+       const date = new Date(v.replace(" ", "T").replace("+00", "Z"));
+       if (!Number.isNaN(date.valueOf())) {
+               return date;
+       }
+       return v;
+}
+
+/** A MonthName is the abbreviated name of a month as it appears in HTTP 
header dates. */
+type MonthName = 
"Jan"|"Feb"|"Mar"|"Apr"|"May"|"Jun"|"Jul"|"Aug"|"Sep"|"Oct"|"Nov"|"Dec";
+
+/**
+ * Checks if a string is a valid abbreviated month name.
+ *
+ * @param s The string to check.
+ * @returns `true` if `s` is a valid abbreviated month name, `false` otherwise.
+ */
+function isMonthName(s: string): s is MonthName {
+       switch(s) {
+               case "Jan":
+               case "Feb":
+               case "Mar":
+               case "Apr":
+               case "May":
+               case "Jun":
+               case "Jul":
+               case "Aug":
+               case "Sep":
+               case "Oct":
+               case "Nov":
+               case "Dec":
+                       return true;
+       }
+       return false;
+}
+
+/** Index with an abbreviated month name to obtain its number. */
+const monthNumbers: Readonly<Record<MonthName, number>> = {
+       // Month names are decided by RFC specs, can't be changed to match our 
conventions.
+       // They should also be in the actual order of months in a year, not 
lexical order.
+       /* eslint-disable @typescript-eslint/naming-convention */
+       /* eslint-disable sort-keys */
+       Jan: 0,
+       Feb: 1,
+       Mar: 2,
+       Apr: 3,
+       May: 4,
+       Jun: 5,
+       Jul: 6,
+       Aug: 7,
+       Sep: 8,
+       Oct: 9,
+       Nov: 10,
+       Dec: 11
+       /* eslint-enable @typescript-eslint/naming-convention */
+       /* eslint-enable sort-keys */
+};
+
+/** Matches dates as formatted in HTTP headers e.g. "Date", "Last-Modified" 
etc. */
+const httpDatePattern = 
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*(\d{2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT$/;
+
+/**
+ * Parses a date as formatted in HTTP headers to a Javascript Date.
+ *
+ * @param raw The raw value of the header (or part of a header) being parsed.
+ * @returns The Date represented by `raw`.
+ * @throws {MalformedDateError} if `raw` fails to parse.
+ */
+export function parseHTTPDate(raw: string): Date {
+       const matches = httpDatePattern.exec(raw.trim().replace(/\s\s+/g, " "));
+       if (!matches || matches.length !== 7 || matches.some(x=>x===undefined)) 
{
+               throw new MalformedDateError(raw);
+       }
+       const [, d, M, y, h, m, s] = matches;
+       const [day, year, hour, minute, second] = [d, y, h, m, s].map(Number);
+       if (!isMonthName(M)) {
+               throw new MalformedDateError(raw);
+       }
+       const month = monthNumbers[M];
+
+       const date = new Date(0);
+       date.setUTCFullYear(year, month, day);
+       date.setUTCHours(hour, minute, second);
+       return date;
+}
diff --git a/experimental/traffic-portal/src/environments/environment.prod.ts 
b/experimental/traffic-portal/src/environments/environment.prod.ts
index 49b581105e..27807dd4fa 100644
--- a/experimental/traffic-portal/src/environments/environment.prod.ts
+++ b/experimental/traffic-portal/src/environments/environment.prod.ts
@@ -13,10 +13,12 @@
 * limitations under the License.
 */
 
+import type { Environment } from "./environment.type";
+
 /**
  * environment contains information about the running environment.
  */
-export const environment = {
+export const environment: Environment = {
        apiVersion: "4.0",
        customModule: false,
        production: true
diff --git a/experimental/traffic-portal/src/environments/environment.ts 
b/experimental/traffic-portal/src/environments/environment.ts
index 959bf92c68..325ba4fcf2 100644
--- a/experimental/traffic-portal/src/environments/environment.ts
+++ b/experimental/traffic-portal/src/environments/environment.ts
@@ -13,6 +13,8 @@
 * limitations under the License.
 */
 
+import { Environment } from "./environment.type";
+
 // This file can be replaced during build by using the `fileReplacements` 
array.
 // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
 // The list of file replacements can be found in `angular.json`.
@@ -20,8 +22,9 @@
 /**
  * environment contains information about the running environment.
  */
-export const environment = {
+export const environment: Environment = {
        apiVersion: "4.0",
        customModule: false,
        production: false,
+       useExhaustiveDates: true
 };
diff --git a/experimental/traffic-portal/src/environments/environment.type.d.ts 
b/experimental/traffic-portal/src/environments/environment.type.d.ts
new file mode 100644
index 0000000000..e82304f2f7
--- /dev/null
+++ b/experimental/traffic-portal/src/environments/environment.type.d.ts
@@ -0,0 +1,34 @@
+/*
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+/**
+ * Environment is the type of an Angular deployment environment.
+ */
+export interface Environment {
+       /** The version of the Traffic Ops API to be used */
+       apiVersion: `${number}.${number}`;
+       /** Whether the "Custom" module should be loaded. */
+       customModule?: boolean;
+       /**
+        * Whether the environment should be treated as a "production" 
environment.
+        */
+       production?: boolean;
+       /**
+        * If defined and `true`, the date-reviving HTTP interceptor will 
attempt to
+        * convert anything that looks like a date into a `Date`. Otherwise, it 
uses
+        * a specific list of property names known to contain Date information.
+        */
+       useExhaustiveDates?: boolean;
+}

Reply via email to