diff --git a/farm.config.ts b/farm.config.ts index dc26d53..d83740e 100644 --- a/farm.config.ts +++ b/farm.config.ts @@ -33,7 +33,8 @@ export default defineConfig((cfg) => { server: { proxy: { "/auth": { - target: "http://localhost:8000", + // target: "http://localhost:8000", + target: "https://api.test.status.otc-service.com", changeOrigin: true }, "/v2": { diff --git a/package.json b/package.json index 0f79b92..9f90810 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "statusdashboard", - "version": "1.4.0", + "version": "1.5.0", "type": "module", "scripts": { "dev": "farm start", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 747127e..4295962 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1106,9 +1106,6 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@25.6.2': - resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} - '@types/node@25.7.0': resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} @@ -2252,8 +2249,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -2679,9 +2676,6 @@ packages: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici-types@7.21.0: resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} @@ -4467,7 +4461,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 25.6.2 + '@types/node': 25.7.0 '@types/js-cookie@3.0.6': {} @@ -4477,10 +4471,6 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@25.6.2': - dependencies: - undici-types: 7.19.2 - '@types/node@25.7.0': dependencies: undici-types: 7.21.0 @@ -4699,7 +4689,7 @@ snapshots: baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 electron-to-chromium: 1.5.353 - node-releases: 2.0.38 + node-releases: 2.0.44 update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer@5.7.1: @@ -5612,7 +5602,7 @@ snapshots: negotiator@1.0.0: {} - node-releases@2.0.38: {} + node-releases@2.0.44: {} normalize-path@3.0.0: {} @@ -5980,8 +5970,6 @@ snapshots: ua-parser-js@1.0.41: {} - undici-types@7.19.2: {} - undici-types@7.21.0: {} unist-util-stringify-position@4.0.0: diff --git a/src/App.tsx b/src/App.tsx index b3e7bd9..ab2a82d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,5 @@ +import { ScaleLoadingSpinner } from "@telekom/scale-components-react"; +import { Suspense } from "react"; import { OIDCProvider } from "./Components/Auth"; import { BrowserRouter } from "./Components/Router"; import { Layout } from "./Pages"; @@ -16,12 +18,14 @@ import { StatusContext } from "./Services/Status"; */ export function App() { return ( - - - - - - - + }> + + + + + + + + ) } diff --git a/src/Components/Auth/With.tsx b/src/Components/Auth/With.tsx index 1a94183..2334261 100644 --- a/src/Components/Auth/With.tsx +++ b/src/Components/Auth/With.tsx @@ -4,12 +4,15 @@ import { useAuth } from "react-oidc-context"; /** * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.2.0 */ -export function Authorized({ children }: { children: ReactNode }): ReactNode { +export function Authorized({ children, rules }: { children: ReactNode, rules?: (groups: string[]) => boolean }): ReactNode { const auth = useAuth(); - if (auth.isAuthenticated) { + if (auth.isAuthenticated || process.env.NODE_ENV === "development") { + if (rules && !rules((auth.user?.profile as any)?.groups as string[] || [])) { + return null; + } return children; } diff --git a/src/Components/Auth/index.tsx b/src/Components/Auth/index.tsx index a18e676..270dd43 100644 --- a/src/Components/Auth/index.tsx +++ b/src/Components/Auth/index.tsx @@ -27,7 +27,7 @@ const log = new Logger("Auth"); /** * @author Aloento * @since 1.0.0 - * @version 1.0.0 + * @version 1.1.0 */ function AuthHandler() { const auth = useAuth(); diff --git a/src/Components/Availability/CategoryGroup.tsx b/src/Components/Availability/CategoryGroup.tsx index d157444..7803dd4 100644 --- a/src/Components/Availability/CategoryGroup.tsx +++ b/src/Components/Availability/CategoryGroup.tsx @@ -5,7 +5,7 @@ import { Models } from "~/Services/Status.Models"; /** * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.2.0 */ export function CategoryGroup({ Category }: { Category: Models.ICategory }) { const { Availa, Region } = useAvailability(); @@ -26,6 +26,10 @@ export function CategoryGroup({ Category }: { Category: Models.ICategory }) { return `bg-${color}-100 hover:bg-${color}-200`; } + if (!avas.length) { + return null; + } + return <> diff --git a/src/Components/Event/Enums.ts b/src/Components/Event/Enums.ts index ea59bff..210bcd7 100644 --- a/src/Components/Event/Enums.ts +++ b/src/Components/Event/Enums.ts @@ -64,7 +64,7 @@ export function IsIncident(type: EventType): boolean { /** * @author Aloento * @since 1.0.0 - * @version 0.2.1 + * @version 0.3.1 */ export enum EventStatus { Detected = "Detected", @@ -82,14 +82,21 @@ export enum EventStatus { Active = "Active", Reopened = "Reopened", Changed = "Changed", + + PendingReview = "Pending Review", + Reviewed = "Reviewed", } /** * @author Aloento * @since 1.1.0 - * @version 0.1.1 + * @version 0.2.0 */ -export function GetStatusList(type: EventType): EventStatus[] { +export function GetStatusList(type: EventType, status?: EventStatus, groups?: string[]): EventStatus[] { + if (groups && status === EventStatus.PendingReview && groups.some(g => g === "/sd_creators")) { + return [EventStatus.PendingReview, EventStatus.Cancelled]; + } + switch (type) { case EventType.Maintenance: return Object.values(EventStatus).slice(5, 10); @@ -112,7 +119,7 @@ export function IsOpenStatus(status: EventStatus): boolean { /** * @author Aloento * @since 1.0.0 - * @version 0.2.0 + * @version 0.3.0 */ export function GetStatusString(status: EventStatus): string { switch (status) { @@ -142,5 +149,9 @@ export function GetStatusString(status: EventStatus): string { return StatusEnum.Cancelled; case EventStatus.Active: return StatusEnum.Active; + case EventStatus.PendingReview: + return StatusEnum.PendingReview; + case EventStatus.Reviewed: + return StatusEnum.Reviewed; } } diff --git a/src/Components/Event/EventApprove.tsx b/src/Components/Event/EventApprove.tsx new file mode 100644 index 0000000..d961932 --- /dev/null +++ b/src/Components/Event/EventApprove.tsx @@ -0,0 +1,75 @@ +import { Toast, ToastBody, ToastTitle, useToastController } from "@fluentui/react-components"; +import { ScaleButton, ScaleIconActionCheckmark } from "@telekom/scale-components-react"; +import { useRequest } from "ahooks"; +import { useAuth } from "react-oidc-context"; +import { useStatus } from "~/Services/Status"; +import { StatusEnum } from "~/Services/Status.Entities"; +import { Models } from "~/Services/Status.Models"; +import { useAccessToken } from "../Auth/useAccessToken"; +import { EventStatus } from "./Enums"; + +/** + * @author Aloento + * @since 1.5.0 + * @version 0.2.2 + */ +export function EventApprove({ Event }: { Event: Models.IEvent }) { + const { Update } = useStatus(); + + const getToken = useAccessToken(); + const { user } = useAuth(); + const { dispatchToast } = useToastController(); + + const { runAsync, loading } = useRequest(async () => { + const url = process.env.SD_BACKEND_URL!; + const raw = await fetch(`${url}/v2/events/${Event.Id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${getToken()}`, + }, + body: JSON.stringify({ + status: StatusEnum.Reviewed, + version: Event.Version ?? Event.Histories.size + 1, + message: `Approved by ${user?.profile.name}`, + update_date: new Date().toISOString(), + }), + }); + + if (!raw.ok) { + const message = await raw.text(); + dispatchToast( + + Failed to approve event + {message} + , + { intent: "warning" } + ); + throw new Error("Failed to approve event: " + message); + } + + Event.Status = EventStatus.Reviewed; + Event.Histories.add({ + Id: Event.Histories.size + 1, + Created: new Date(), + Event, + Message: `Approved by ${user?.profile.name}`, + Status: EventStatus.Reviewed, + }); + Update(); + }, { + manual: true, + }); + + return ( + runAsync()} + > + +  Approve + + ); +} diff --git a/src/Components/Event/EventCard.tsx b/src/Components/Event/EventCard.tsx index 58aac46..b8ee33f 100644 --- a/src/Components/Event/EventCard.tsx +++ b/src/Components/Event/EventCard.tsx @@ -5,6 +5,7 @@ import { Authorized } from "../Auth/With"; import { Indicator } from "../Home/Indicator"; import { EventStatus, EventType, IsIncident } from "./Enums"; import { EventAffected } from "./EventAffected"; +import { EventApprove } from "./EventApprove"; import { EventEditor } from "./EventEditor"; import { EventExtract } from "./EventExtract"; @@ -23,7 +24,7 @@ import { EventExtract } from "./EventExtract"; * * @author Aloento * @since 1.0.0 - * @version 0.2.0 + * @version 0.3.0 */ export function EventCard({ Event }: { Event: Models.IEvent }) { return ( @@ -39,10 +40,18 @@ export function EventCard({ Event }: { Event: Models.IEvent }) {
+ { + return Event.Status === EventStatus.PendingReview && + groups.some(g => g === "/sd_operators" || g === "/sd_admins"); + }}> + + + { Event.RegionServices.size > 1 && } +
@@ -72,6 +81,13 @@ export function EventCard({ Event }: { Event: Models.IEvent }) { } + + + {Event.ContactEmail && + } +
@@ -95,6 +111,15 @@ export function EventCard({ Event }: { Event: Models.IEvent }) { } + + + {Event.ContactEmail && + } +
diff --git a/src/Components/Event/EventEditor.tsx b/src/Components/Event/EventEditor.tsx index 0bea011..72ffd8f 100644 --- a/src/Components/Event/EventEditor.tsx +++ b/src/Components/Event/EventEditor.tsx @@ -1,6 +1,7 @@ import { ScaleButton, ScaleDropdownSelect, ScaleDropdownSelectItem, ScaleIconActionEdit, ScaleModal, ScaleTextarea, ScaleTextField } from "@telekom/scale-components-react"; import { useBoolean } from "ahooks"; import dayjs from "dayjs"; +import { useAuth } from "react-oidc-context"; import { Dic } from "~/Helpers/Entities"; import { Models } from "~/Services/Status.Models"; import { EventStatus, EventType, GetStatusList, IsIncident, IsOpenStatus } from "./Enums"; @@ -20,16 +21,17 @@ import { useEditForm } from "./useEditForm"; * * @author Aloento * @since 1.0.0 - * @version 0.3.0 + * @version 0.4.0 */ export function EventEditor({ Event }: { Event: Models.IEvent }) { const { State, Actions, Validation, OnSubmit, Loading } = useEditForm(Event); const [open, { setTrue, setFalse }] = useBoolean(); + const auth = useAuth(); return <> - Edit +  Edit - {GetStatusList(State.type) + {GetStatusList(State.type, Event.Status, (auth.user?.profile as any)?.groups) .map((status, i) => {status} @@ -125,6 +127,18 @@ export function EventEditor({ Event }: { Event: Models.IEvent }) { helperText={Validation.description} /> + {State.type === EventType.Maintenance && ( + Actions.setContactEmail(e.target.value as string)} + invalid={!!Validation.contactEmail} + helperText={Validation.contactEmail} + /> + )} + - Extract +  Extract (); + function setContactEmail(value = contactEmail) { + let err: boolean = false; + + if (type === EventType.Maintenance && !value) { + setValContactEmail("Contact Email is required for maintenance."); + err = true; + } + + if (value && !value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + setValContactEmail("Please enter a valid email address."); + err = true; + } + + if (value && value.length > 100) { + setValContactEmail("Email must be less than 100 characters."); + err = true; + } + + _setContactEmail(value); + !err && setValContactEmail(undefined); + + return !err; + } + const [status, _setStatus] = useState(); const [valStatus, setValStatus] = useState(); function setStatus(value = status) { @@ -185,10 +212,11 @@ export function useEditForm(event: Models.IEvent) { }, [start, end]); const getToken = useAccessToken(); + const { dispatchToast } = useToastController(); const { DB, Update } = useStatus(); const { runAsync, loading } = useRequest(async () => { - if (![setTitle(), setType(), setUpdate(), setDescription(), setStatus(), setStart(), setEnd(), setUpdateAt()].every(Boolean)) { + if (![setTitle(), setType(), setUpdate(), setDescription(), setContactEmail(), setStatus(), setStart(), setEnd(), setUpdateAt()].every(Boolean)) { throw new Error("Validation failed."); } const url = process.env.SD_BACKEND_URL!; @@ -202,6 +230,11 @@ export function useEditForm(event: Models.IEvent) { description, }; + if (type === EventType.Maintenance && contactEmail) { + body.contact_email = contactEmail; + body.version = event.Version ?? event.Histories.size + 1; + }; + if (event.Type !== type) { body.status = StatusEnum.ImpactChanged; } @@ -234,7 +267,15 @@ export function useEditForm(event: Models.IEvent) { }); if (!raw.ok) { - throw new Error("Failed to update event: " + await raw.text()); + const message = await raw.text(); + dispatchToast( + + Failed to update event + {message} + , + { intent: "warning" } + ); + throw new Error("Failed to update event: " + message); } const eventIndex = DB.Events.findIndex(e => e.Id === event.Id); @@ -246,6 +287,7 @@ export function useEditForm(event: Models.IEvent) { updatedEvent.Start = start; updatedEvent.End = end; updatedEvent.Description = description; + updatedEvent.ContactEmail = contactEmail; const newHistory: Models.IHistory = { Id: Math.max(...Array.from(updatedEvent.Histories).map(h => h.Id), 0) + 1, @@ -273,6 +315,7 @@ export function useEditForm(event: Models.IEvent) { type, update, description, + contactEmail, status, start, end, @@ -283,6 +326,7 @@ export function useEditForm(event: Models.IEvent) { setType, setUpdate, setDescription, + setContactEmail, setStatus, setStart, setEnd, @@ -293,6 +337,7 @@ export function useEditForm(event: Models.IEvent) { type: valType, update: valUpdate, description: valDescription, + contactEmail: valContactEmail, status: valStatus, start: valStart, end: valEnd, diff --git a/src/Components/Event/useEventExtract.ts b/src/Components/Event/useEventExtract.tsx similarity index 78% rename from src/Components/Event/useEventExtract.ts rename to src/Components/Event/useEventExtract.tsx index 3c682c4..e4196f1 100644 --- a/src/Components/Event/useEventExtract.ts +++ b/src/Components/Event/useEventExtract.tsx @@ -1,3 +1,4 @@ +import { Toast, ToastBody, ToastTitle, useToastController } from "@fluentui/react-components"; import { useRequest } from "ahooks"; import { useState } from "react"; import { useStatus } from "~/Services/Status"; @@ -8,7 +9,7 @@ import { useRouter } from "../Router"; /** * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.1.1 */ export function useEventExtract(event: Models.IEvent) { const [services, _setServices] = useState([]); @@ -29,6 +30,7 @@ export function useEventExtract(event: Models.IEvent) { } const getToken = useAccessToken(); + const { dispatchToast } = useToastController(); const { Nav } = useRouter(); const { Refresh } = useStatus(); @@ -52,6 +54,18 @@ export function useEventExtract(event: Models.IEvent) { body: JSON.stringify(body) }); + if (!raw.ok) { + const message = await raw.text(); + dispatchToast( + + Failed to extract event + {message} + , + { intent: "warning" } + ); + throw new Error("Failed to extract event: " + message); + } + const res = await raw.json(); const id = res.id; diff --git a/src/Components/Home/ServiceItem.css b/src/Components/Home/ServiceItem.css deleted file mode 100644 index 2c3a695..0000000 --- a/src/Components/Home/ServiceItem.css +++ /dev/null @@ -1,9 +0,0 @@ -.blue-dot { - position: absolute !important; - top: -10%; - right: -10%; -} - -.with-dot { - position: relative; -} diff --git a/src/Components/Home/ServiceItem.tsx b/src/Components/Home/ServiceItem.tsx index 7e17fd0..9904cf3 100644 --- a/src/Components/Home/ServiceItem.tsx +++ b/src/Components/Home/ServiceItem.tsx @@ -7,7 +7,7 @@ import { useStatus } from "~/Services/Status"; import { Models } from "~/Services/Status.Models"; import { EventStatus, EventType, IsIncident, IsOpenStatus } from "../Event/Enums"; import { Indicator } from "./Indicator"; -import "./ServiceItem.css"; + import serviceSlugMap from "./serviceSlugMap.json"; interface IServiceItem { diff --git a/src/Components/Layout/MobileMenu.tsx b/src/Components/Layout/MobileMenu.tsx index 8dde1fa..2a42228 100644 --- a/src/Components/Layout/MobileMenu.tsx +++ b/src/Components/Layout/MobileMenu.tsx @@ -1,14 +1,23 @@ import { ScaleIconActionMenu, ScaleTelekomMobileFlyoutCanvas, ScaleTelekomMobileMenu, ScaleTelekomMobileMenuItem, ScaleTelekomNavFlyout, ScaleTelekomNavItem } from "@telekom/scale-components-react"; +import { chain } from "lodash"; +import { useMemo } from "react"; import { useAuth } from "react-oidc-context"; +import { EventStatus } from "~/Components/Event/Enums"; +import { useStatus } from "~/Services/Status"; import { Authorized } from "../Auth/With"; /** * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.3.0 */ export function MobileMenu() { const auth = useAuth(); + const { DB } = useStatus(); + + const pendingCount = useMemo(() => chain(DB.Events) + .filter(e => e.Status === EventStatus.PendingReview) + .value().length, [DB]); return ( @@ -39,6 +48,16 @@ export function MobileMenu() { New Event + {pendingCount > 0 && ( + + Reviews: {pendingCount} + + )} + + + You're {((auth.user?.profile as any)?.groups as string[])?.filter(x => x.includes("sd"))} + + auth.signoutSilent()}> Logout {auth.user?.profile.name} diff --git a/src/Components/Layout/NavItem.tsx b/src/Components/Layout/NavItem.tsx index 3dcb457..5075501 100644 --- a/src/Components/Layout/NavItem.tsx +++ b/src/Components/Layout/NavItem.tsx @@ -16,7 +16,7 @@ export function NavItem({ Href, Label }: INavItem) { const path = Paths.at(0); return ( - + {Label} ); diff --git a/src/Components/Layout/ProfileMenu.tsx b/src/Components/Layout/ProfileMenu.tsx index 2d5bbae..42ca07a 100644 --- a/src/Components/Layout/ProfileMenu.tsx +++ b/src/Components/Layout/ProfileMenu.tsx @@ -4,7 +4,7 @@ import { useAuth } from "react-oidc-context"; /** * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.2.0 */ export function ProfileMenu() { const auth = useAuth(); @@ -13,9 +13,11 @@ export function ProfileMenu() { - - - +
+ + + +
@@ -23,9 +25,7 @@ export function ProfileMenu() { - - New Event - + You're {((auth.user?.profile as any)?.groups as string[])?.filter(x => x.includes("sd"))} auth.signoutSilent()}> diff --git a/src/Components/Layout/TopNavBar.tsx b/src/Components/Layout/TopNavBar.tsx index 2e4225d..0103b51 100644 --- a/src/Components/Layout/TopNavBar.tsx +++ b/src/Components/Layout/TopNavBar.tsx @@ -1,5 +1,9 @@ import { ScaleTelekomHeader, ScaleTelekomNavItem, ScaleTelekomNavList } from "@telekom/scale-components-react"; +import { chain } from "lodash"; +import { useMemo } from "react"; +import { EventStatus } from "~/Components/Event/Enums"; import { Dic } from "~/Helpers/Entities"; +import { useStatus } from "~/Services/Status"; import { Authorized } from "../Auth/With"; import { MobileMenu } from "./MobileMenu"; import { NavItem } from "./NavItem"; @@ -8,9 +12,15 @@ import { ProfileMenu } from "./ProfileMenu"; /** * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.3.0 */ export function TopNavBar() { + const { DB } = useStatus(); + + const pendingCount = useMemo(() => chain(DB.Events) + .filter(e => e.Status === EventStatus.PendingReview) + .value().length, [DB]); + return (
+ + + + + {pendingCount > 0 && } + diff --git a/src/Components/New/NewForm.tsx b/src/Components/New/NewForm.tsx index 0767e5d..3f156cc 100644 --- a/src/Components/New/NewForm.tsx +++ b/src/Components/New/NewForm.tsx @@ -1,4 +1,4 @@ -import { ScaleButton, ScaleDropdownSelect, ScaleDropdownSelectItem, ScaleHelperText, ScaleTable, ScaleTextarea, ScaleTextField } from "@telekom/scale-components-react"; +import { ScaleButton, ScaleDropdownSelect, ScaleDropdownSelectItem, ScaleHelperText, ScaleModal, ScaleTable, ScaleTextarea, ScaleTextField } from "@telekom/scale-components-react"; import dayjs from "dayjs"; import { orderBy } from "lodash"; import { Dic } from "~/Helpers/Entities"; @@ -21,140 +21,194 @@ import { useNewForm } from "./useNewForm"; * * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.3.0 */ export function NewForm() { const { DB } = useStatus(); const { State, Actions, Validation, OnSubmit, Loading } = useNewForm(); return ( -
{ - e.preventDefault(); - OnSubmit(); - }} - > - - Actions.setType(e.target.value as EventType)} - invalid={!!Validation.type} - helperText={Validation.type} + <> + { + e.preventDefault(); + OnSubmit(); + }} > - {Object.values(EventType).slice(1).map((type, i) => - - {type} - )} - - - Actions.setTitle(e.target.value as string)} - invalid={!!Validation.title} - helperText={Validation.title} - /> - - Actions.setDescription(e.target.value as string)} - invalid={!!Validation.description} - helperText={Validation.description} - /> - - -
- - - - - - - {DB.Regions.map((region, i) => ( - - ))} - - - - - {orderBy(DB.Services, x => x.Name).map((service, i) => ( - - - {DB.Regions.map((region, j) => { - const rs = DB.RegionService.find(rs => rs.Service === service && rs.Region === region); - - return ( - - ); - })} - - ))} - -
- Affected Services -
Name{region.Name}
{service.Name} - { - const checked = e.target.checked; - - Actions.setServices((curr) => { - if (checked) { - return [...curr, rs!]; - } - - return curr.filter(s => s !== rs); - }) - }} - /> -
-
- Actions.setType(e.target.value as EventType)} + invalid={!!Validation.type} + helperText={Validation.type} + > + {Object.values(EventType).slice(1).map((type, i) => + + {type} + )} + + + Actions.setTitle(e.target.value as string)} + invalid={!!Validation.title} + helperText={Validation.title} + /> + + Actions.setDescription(e.target.value as string)} + invalid={!!Validation.description} + helperText={Validation.description} /> -
- - Actions.setStart(new Date(e.target.value as string))} - invalid={!!Validation.start} - helperText={Validation.start} - /> - - {!IsIncident(State.type) && ( + + +
+ + + + + + + {DB.Regions.map((region, i) => ( + + ))} + + + + + {orderBy(DB.Services, x => x.Name).map((service, i) => ( + + + {DB.Regions.map((region, j) => { + const rs = DB.RegionService.find(rs => rs.Service === service && rs.Region === region); + + return ( + + ); + })} + + ))} + +
+ Affected Services +
Name{region.Name}
{service.Name} + { + const checked = e.target.checked; + + Actions.setServices((curr) => { + if (checked) { + return [...curr, rs!]; + } + + return curr.filter(s => s !== rs); + }) + }} + /> +
+
+ + +
+ Actions.setEnd(new Date(e.target.value as string))} - invalid={!!Validation.end} - helperText={Validation.end} + label="Start CET" + required + value={dayjs(State.start).format(Dic.Picker)} + onScale-input={(e) => Actions.setStart(new Date(e.target.value as string))} + invalid={!!Validation.start} + helperText={Validation.start} /> - )} - Actions.setEnd(new Date(e.target.value as string))} + invalid={!!Validation.end} + helperText={Validation.end} + /> + )} + + {State.type === EventType.Maintenance && ( + Actions.setContactEmail(e.target.value as string)} + invalid={!!Validation.contactEmail} + helperText={Validation.contactEmail} + /> + )} + + + Submit + + + + e.preventDefault()} > - Submit - - +
+

+ Maintenance start time is earlier than the recommended 36 hours. +

+

+ Selected: {dayjs(State.start).format("YYYY-MM-DD HH:mm")} +

+

+ Recommended after: {dayjs().add(36, "hour").format("YYYY-MM-DD HH:mm")} +

+ +
+ + Cancel + + + + Confirm Submit + +
+
+
+ ) } diff --git a/src/Components/New/useNewForm.ts b/src/Components/New/useNewForm.ts index 87c20e1..caf3c2c 100644 --- a/src/Components/New/useNewForm.ts +++ b/src/Components/New/useNewForm.ts @@ -15,7 +15,7 @@ import { useRouter } from "../Router"; * * @author Aloento * @since 1.0.0 - * @version 0.2.0 + * @version 0.3.0 */ export function useNewForm() { const { DB, Update } = useStatus(); @@ -57,6 +57,8 @@ export function useNewForm() { _setType(value); setValType(undefined); + setIsShortConfirmed(false); + setStartNeedsConfirm(false); if (IsIncident(value)) { _setEnd(undefined); @@ -84,7 +86,16 @@ export function useNewForm() { const [start, _setStart] = useState(new Date()); const [valStart, setValStart] = useState(); - function setStart(value = start) { + const [isShortConfirmed, setIsShortConfirmed] = useState(false); + const [startNeedsConfirm, setStartNeedsConfirm] = useState(false); + + function dismissStartConfirm() { + setStartNeedsConfirm(false); + setIsShortConfirmed(false); + } + + function setStart(value = start, options: { resetConfirm?: boolean } = {}) { + const { resetConfirm = true } = options; let err: boolean = false; const now = new Date(); @@ -101,6 +112,10 @@ export function useNewForm() { setValStart(undefined); } _setStart(value); + if (resetConfirm) { + setIsShortConfirmed(false); + setStartNeedsConfirm(false); + } return !err; } @@ -126,7 +141,34 @@ export function useNewForm() { useEffect(() => { setStart(); setEnd(); - }, [start, end]); + setContactEmail(); + }, [start, end, type]); + + const [contactEmail, _setContactEmail] = useState(""); + const [valContactEmail, setValContactEmail] = useState(); + function setContactEmail(value = contactEmail) { + let err: boolean = false; + + if (type === EventType.Maintenance && !value) { + setValContactEmail("Contact Email is required for maintenance."); + err = true; + } + + if (value && !value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + setValContactEmail("Please enter a valid email address."); + err = true; + } + + if (value && value.length > 100) { + setValContactEmail("Email must be less than 100 characters."); + err = true; + } + + _setContactEmail(value); + !err && setValContactEmail(undefined); + + return !err; + } const [services, _setServices] = useState([]); const [valServices, setValServices] = useState(); @@ -150,12 +192,25 @@ export function useNewForm() { const getToken = useAccessToken(); const { runAsync, loading } = useRequest(async () => { - if (![setTitle(), setType(), setDescription(), setStart(), setEnd(), setServices()].every(Boolean)) { + if (![setTitle(), setType(), setDescription(), setStart(start, { resetConfirm: false }), setEnd(), setServices(), setContactEmail()].every(Boolean)) { return; } + const now = new Date(); + const minMaintenanceStart = new Date(now.getTime() + 36 * 60 * 60 * 1000); + const isShortMaintenanceStart = type === EventType.Maintenance && start < minMaintenanceStart; + + if (isShortMaintenanceStart && !isShortConfirmed) { + setStartNeedsConfirm(true); + setIsShortConfirmed(true); + return; + } + + setIsShortConfirmed(false); + setStartNeedsConfirm(false); + const status = IsIncident(type) - ? EventStatus.Detected : EventStatus.Planned + ? EventStatus.Detected : EventStatus.PendingReview const event: Models.IEvent = { Id: Math.max(...DB.Events.map(event => event.Id), 0) + 1, @@ -180,6 +235,10 @@ export function useNewForm() { start_date: start.toISOString() } + if (type === EventType.Maintenance && contactEmail) { + body.contact_email = contactEmail; + } + if (!IsIncident(type) && end) { body.end_date = end } @@ -215,7 +274,8 @@ export function useNewForm() { description, start, end, - services + services, + contactEmail }, Actions: { setTitle, @@ -223,15 +283,19 @@ export function useNewForm() { setDescription, setStart, setEnd, - setServices + setServices, + setContactEmail, + dismissStartConfirm }, Validation: { title: valTitle, type: valType, description: valDescription, start: valStart, + startNeedsConfirm, end: valEnd, - services: valServices + services: valServices, + contactEmail: valContactEmail }, OnSubmit: runAsync, Loading: loading diff --git a/src/Helpers/Entities.ts b/src/Helpers/Entities.ts index 8ef881e..6aeed7a 100644 --- a/src/Helpers/Entities.ts +++ b/src/Helpers/Entities.ts @@ -12,7 +12,7 @@ export const Dic = { TZ: "Europe/Berlin", Time: "DD MMM YY, HH:mm", TimeTZ: "DD MMM YYYY, HH:mm [CET]", - Picker: "YYYY-MM-DDTHH:mm:ss" + Picker: "YYYY-MM-DDTHH:mm" }; /** diff --git a/src/Pages/History.tsx b/src/Pages/History.tsx index 3be48d4..cbc2e0d 100644 --- a/src/Pages/History.tsx +++ b/src/Pages/History.tsx @@ -3,6 +3,7 @@ import dayjs from "dayjs"; import { chain } from "lodash"; import { useEffect, useRef, useState } from "react"; import { Helmet } from "react-helmet"; +import { EventStatus } from "~/Components/Event/Enums"; import { EventFilters } from "~/Components/History/EventFilters"; import { getEventTag } from "~/Components/History/EventTag"; import { useEventFilters } from "~/Components/History/useEventFilters"; @@ -15,7 +16,7 @@ const PAGE_SIZE_OPTIONS = [10, 20, 50]; /** * @author Aloento * @since 1.2.0 - * @version 1.2.2 + * @version 1.3.0 */ export function History() { const { DB } = useStatus(); @@ -26,6 +27,10 @@ export function History() { return stored ? parseInt(stored, 10) : 20; }); + const events = DB.Events.filter( + (x) => x.Status !== EventStatus.PendingReview + ); + const { filters, validation, @@ -33,7 +38,7 @@ export function History() { setFilters, setValidation, clearFilters, - } = useEventFilters(DB.Events); + } = useEventFilters(events); useEffect(() => { if (!gridRef.current) { @@ -102,7 +107,7 @@ export function History() { filters={filters} validation={validation} regions={DB.Regions} - totalEvents={DB.Events.length} + totalEvents={events.length} filteredCount={filteredEvents.length} onFiltersChange={setFilters} onValidationChange={setValidation} @@ -117,9 +122,9 @@ export function History() { hideBorder ref={gridRef} > - + Page Size - + {PAGE_SIZE_OPTIONS.map((size) => ( @@ -134,8 +139,8 @@ export function History() { diff --git a/src/Pages/Home.tsx b/src/Pages/Home.tsx index be8455f..6b66f7c 100644 --- a/src/Pages/Home.tsx +++ b/src/Pages/Home.tsx @@ -6,7 +6,7 @@ import { chain } from "lodash"; import { useEffect, useMemo, useState } from "react"; import { Helmet } from "react-helmet"; import { BehaviorSubject, Subject } from "rxjs"; -import { EventType, IsIncident, IsOpenStatus } from "~/Components/Event/Enums"; +import { EventStatus, EventType, IsIncident, IsOpenStatus } from "~/Components/Event/Enums"; import { EventGrid } from "~/Components/Home/EventGrid"; import "~/Components/Home/Home.css"; import { Indicator } from "~/Components/Home/Indicator"; @@ -34,7 +34,7 @@ const log = new Logger("Home"); * @component * @author Aloento * @since 1.0.0 - * @version 0.2.0 + * @version 0.3.0 */ export function Home() { const { DB } = useStatus(); @@ -90,12 +90,29 @@ export function Home() { : `${abnormalCount} components have issues, but don't worry, we are working on it.` : "All Systems Operational"; + const pendingCount = useMemo(() => { + const events = chain(DB.Events) + .filter(e => e.Status === EventStatus.PendingReview) + .value(); + + log.debug("Pending Maintenance", events); + return events.length; + }, [DB]); + return ( <> {Dic.Name} {Dic.Prod} + {pendingCount > 0 && ( + + )} +
- +
- + {Object.values(EventType).map((state, i) => (
diff --git a/src/Pages/Reviews.tsx b/src/Pages/Reviews.tsx new file mode 100644 index 0000000..f8fd0cf --- /dev/null +++ b/src/Pages/Reviews.tsx @@ -0,0 +1,123 @@ +import { ScaleDataGrid, ScaleIconActionCheckmark, ScaleIconActionMenu, ScaleMenuFlyoutItem, ScaleMenuFlyoutList } from "@telekom/scale-components-react"; +import dayjs from "dayjs"; +import { chain } from "lodash"; +import { useEffect, useRef, useState } from "react"; +import { Helmet } from "react-helmet"; +import { EventStatus } from "~/Components/Event/Enums"; +import { Dic } from "~/Helpers/Entities"; +import { useStatus } from "~/Services/Status"; + +const PAGE_SIZE_KEY = "reviewsPageSize"; +const PAGE_SIZE_OPTIONS = [10, 20, 50]; + +/** + * @author Aloento + * @since 1.3.0 + * @version 0.3.0 + */ +export function Reviews() { + const { DB } = useStatus(); + const gridRef = useRef(null); + + const [pageSize, setPageSize] = useState(() => { + const stored = localStorage.getItem(PAGE_SIZE_KEY); + return stored ? parseInt(stored, 10) : 10; + }); + + const pendingEvents = DB.Events.filter((x) => x.Status === EventStatus.PendingReview); + + useEffect(() => { + if (!gridRef.current) { + return; + } + + const grid = gridRef.current; + + grid.fields = [ + { type: "number", label: "ID", sortable: true }, + { type: "text", label: "Plan Start CET", sortable: true }, + { type: "text", label: "Plan End CET", sortable: true }, + { type: "text", label: "Region", sortable: true }, + { type: "text", label: "Service", sortable: true, stretchWeight: 0.8 }, + { type: "actions", label: "Detail" }, + ]; + + const events = chain(pendingEvents) + .map((x) => { + const rs = Array.from(x.RegionServices); + + const services = chain(rs) + .map(s => s.Service.Name) + .uniq() + .value(); + + const regions = chain(rs) + .map(r => r.Region.Name) + .uniq() + .value(); + + return [ + x.Id, + dayjs(x.Start).tz(Dic.TZ).format(Dic.Time), + x.End ? dayjs(x.End).tz(Dic.TZ).format(Dic.Time) : "-", + regions.join(", "), + services.length > 2 + ? `${services.slice(0, 2).join(", ")} +${services.length - 2}` + : services.join(", "), + [ + { + label: "↗", + variant: "secondary", + href: `/Event/${x.Id}` + } + ] + ]; + }) + .value(); + + grid.rows = events; + }, [gridRef.current, pendingEvents]); + + return ( + <> + + Reviews - {Dic.Name} {Dic.Prod} + + + + + Page Size + + + + {PAGE_SIZE_OPTIONS.map((size) => ( + { + setPageSize(size); + localStorage.setItem(PAGE_SIZE_KEY, size.toString()); + }} + > + {size} + + + ))} + + + + + ); +} diff --git a/src/Pages/index.tsx b/src/Pages/index.tsx index 5045839..d47baa9 100644 --- a/src/Pages/index.tsx +++ b/src/Pages/index.tsx @@ -11,6 +11,7 @@ import { Event } from "./Event"; import { History } from "./History"; import { Home } from "./Home"; import { NewEvent } from "./NewEvent"; +import { Reviews } from "./Reviews"; /** * @author Aloento @@ -46,6 +47,9 @@ export function Layout() { case "NewEvent": return ; + case "Reviews": + return ; + case "": case undefined: return ; diff --git a/src/Services/Status.Entities.ts b/src/Services/Status.Entities.ts index bc4c903..a2d7c7d 100644 --- a/src/Services/Status.Entities.ts +++ b/src/Services/Status.Entities.ts @@ -23,7 +23,7 @@ export const enum NameEnum { /** * @author Aloento * @since 1.0.0 - * @version 0.2.0 + * @version 0.3.2 */ export interface EventEntityV2 { title: string; @@ -36,6 +36,10 @@ export interface EventEntityV2 { updates?: UpdateEntityV2[]; type: string; description?: string; + creator?: string; + contact_email?: string; + version?: number; + status?: StatusEnum; } interface UpdateEntityV2 { @@ -48,7 +52,7 @@ interface UpdateEntityV2 { /** * @author Aloento * @since 1.0.0 - * @version 0.2.1 + * @version 0.3.1 */ export const enum StatusEnum { Analyzing = "analyzing", @@ -60,6 +64,7 @@ export const enum StatusEnum { Description = "description", Fixing = "fixing", InProgress = "in progress", + InProgress2 = "in_progress", Modified = "modified", Observing = "observing", Reopened = "reopened", @@ -69,4 +74,6 @@ export const enum StatusEnum { Planned = "planned", Cancelled = "cancelled", Active = "active", + PendingReview = "pending_review", + Reviewed = "reviewed", } diff --git a/src/Services/Status.Models.ts b/src/Services/Status.Models.ts index 87c052d..c456658 100644 --- a/src/Services/Status.Models.ts +++ b/src/Services/Status.Models.ts @@ -16,7 +16,7 @@ export interface IStatusContext { /** * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.2.0 */ export namespace Models { export interface IService { @@ -54,8 +54,11 @@ export namespace Models { End?: Date; Status: EventStatus; Description?: string; + Creator?: string; + ContactEmail?: string; RegionServices: Set; Histories: Set; + Version?: number; } export interface IHistory { diff --git a/src/Services/Status.Trans.V2.ts b/src/Services/Status.Trans.V2.ts index fa032b8..930e4ef 100644 --- a/src/Services/Status.Trans.V2.ts +++ b/src/Services/Status.Trans.V2.ts @@ -9,10 +9,74 @@ import regionIdMap from "./regionIdMap.json"; const log = new Logger("Service", "Status", "TransformerV2"); +function ResolveEventStatus({ + source, + prev, + type, + endDate, +}: { + source?: StatusEnum; + prev: EventStatus; + type: EventType; + endDate: null | string; +}): EventStatus | undefined { + if (!source) { + return undefined; + } + + switch (source) { + case StatusEnum.System: + return endDate + ? IsIncident(type) ? EventStatus.Resolved : EventStatus.Completed + : prev; + + case StatusEnum.Analyzing: + case StatusEnum.Analysing: + return EventStatus.Analysing; + case StatusEnum.Detected: + return EventStatus.Detected; + case StatusEnum.Reopened: + return EventStatus.Reopened; + case StatusEnum.Fixing: + return EventStatus.Fixing; + case StatusEnum.Observing: + return EventStatus.Observing; + case StatusEnum.Resolved: + return EventStatus.Resolved; + + case StatusEnum.Scheduled: + case StatusEnum.Planned: + return EventStatus.Planned; + case StatusEnum.Active: + return EventStatus.Active; + case StatusEnum.Modified: + return EventStatus.Modified; + case StatusEnum.InProgress: + case StatusEnum.InProgress2: + return EventStatus.InProgress; + case StatusEnum.Completed: + return EventStatus.Completed; + case StatusEnum.Cancelled: + return EventStatus.Cancelled; + + case StatusEnum.PendingReview: + return EventStatus.PendingReview; + case StatusEnum.Reviewed: + return EventStatus.Reviewed; + + case StatusEnum.Changed: + case StatusEnum.ImpactChanged: + return prev; + + default: + return undefined; + } +} + /** * @author Aloento * @since 1.0.0 - * @version 0.2.1 + * @version 0.3.1 */ export function TransformerV2({ Components, Events }: { Components: StatusEntityV2[], Events: EventEntityV2[] }): IStatusContext { let id = 0; @@ -122,13 +186,27 @@ export function TransformerV2({ Components, Events }: { Components: StatusEntity Status: IsIncident(type) ? EventStatus.Detected : EventStatus.Planned, Histories: new Set(), RegionServices: new Set(), - Description: event.description + Description: event.description, + ContactEmail: event.contact_email, + Creator: event.creator, + Version: event.version }; if (event.end_date) { dbEvent.End = dayjs(event.end_date).toDate(); } + const statusFromEvent = ResolveEventStatus({ + source: event.status, + prev: dbEvent.Status, + type, + endDate: event.end_date, + }); + const shouldInferStatusFromUpdates = !statusFromEvent; + if (statusFromEvent) { + dbEvent.Status = statusFromEvent; + } + for (const rsId of event.components) { const rs = db.RegionService.find((x) => x.Id === rsId); if (!rs) { @@ -149,57 +227,20 @@ export function TransformerV2({ Components, Events }: { Components: StatusEntity let prev = dbEvent.Status; for (const update of event.updates) { - const status = (() => { - switch (update.status) { - case StatusEnum.System: - return event.end_date - ? IsIncident(type) ? EventStatus.Resolved : EventStatus.Completed - : prev; - - case StatusEnum.Analyzing: - case StatusEnum.Analysing: - return EventStatus.Analysing; - case StatusEnum.Detected: - return EventStatus.Detected; - case StatusEnum.Reopened: - return EventStatus.Reopened; - case StatusEnum.Fixing: - return EventStatus.Fixing; - case StatusEnum.Observing: - return EventStatus.Observing; - case StatusEnum.Resolved: - return EventStatus.Resolved; - - case StatusEnum.Description: - dbEvent.Description = update.text; - break; - - case StatusEnum.Scheduled: - case StatusEnum.Planned: - return EventStatus.Planned; - case StatusEnum.Active: - return EventStatus.Active; - case StatusEnum.Modified: - return EventStatus.Modified; - case StatusEnum.InProgress: - return EventStatus.InProgress; - case StatusEnum.Completed: - return EventStatus.Completed; - case StatusEnum.Cancelled: - return EventStatus.Cancelled; - - case StatusEnum.Changed: - case StatusEnum.ImpactChanged: - return prev; - - default: - break; - } - })(); + if (update.status === StatusEnum.Description) { + dbEvent.Description = update.text; + continue; + } + + const status = ResolveEventStatus({ + source: update.status, + prev, + type, + endDate: event.end_date, + }); if (!status) { - if (update.status !== StatusEnum.Description) - log.debug("Skipped Unknown Status.", update, event); + log.debug("Skipped Unknown Status.", update, event); continue; } @@ -215,11 +256,13 @@ export function TransformerV2({ Components, Events }: { Components: StatusEntity prev = status; } - const status = orderBy( - Array.from(dbEvent.Histories), x => x.Created, "desc" - ).at(0)?.Status; - if (status) { - dbEvent.Status = status; + if (shouldInferStatusFromUpdates) { + const status = orderBy( + Array.from(dbEvent.Histories), x => x.Id + ).at(0)?.Status; + if (status) { + dbEvent.Status = status; + } } } diff --git a/src/Services/Status.tsx b/src/Services/Status.tsx index 8e86594..23e9c95 100644 --- a/src/Services/Status.tsx +++ b/src/Services/Status.tsx @@ -1,5 +1,6 @@ import { useMount, useRequest } from "ahooks"; -import { createContext, JSX, useContext, useState } from "react"; +import { createContext, JSX, useContext, useEffect, useState } from "react"; +import { useAuth } from "react-oidc-context"; import { Subject } from "rxjs"; import { Station } from "~/Helpers/Entities"; import { Logger } from "~/Helpers/Logger"; @@ -46,6 +47,8 @@ await db.load(key); const log = new Logger("Service", key); +let loading; + /** * Custom hook to access the status context. * @@ -58,20 +61,14 @@ const log = new Logger("Service", key); * * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.2.0 */ export function useStatus() { const ctx = useContext(CTX); if (db.Ins.Regions.length < 1) { - throw new Promise((res) => { - const i = setInterval(() => { - if (db.Ins.Regions.length > 0) { - clearInterval(i); - res(ctx); - } - }, 100); - }); + loading ??= ctx.Refresh(); + throw loading; } return ctx; @@ -91,24 +88,26 @@ export function useStatus() { * * @author Aloento * @since 1.0.0 - * @version 0.1.0 + * @version 0.2.0 */ export function StatusContext({ children }: { children: JSX.Element }) { const [ins, setDB] = useState(db.Ins); + const { isLoading, user } = useAuth(); const url = process.env.SD_BACKEND_URL; const { runAsync } = useRequest( async () => { log.info(`Loading status data from v2...`); + const token = user?.access_token ? { headers: { Authorization: `Bearer ${user.access_token}` } } : {}; const compLink = `${url}/v2/components`; - const compRes = await fetch(compLink); + const compRes = await fetch(compLink, token); const compData = await compRes.json(); log.debug("Components Status loaded.", compData); - const first = await fetch(`${url}/v2/events?page=1&limit=50`); + const first = await fetch(`${url}/v2/events?page=1&limit=50`, token); const firstData = await first.json(); const allEvents: EventEntityV2[] = []; @@ -127,7 +126,7 @@ export function StatusContext({ children }: { children: JSX.Element }) { const eventLink = `${url}/v2/events?page=${page}&limit=50`; pagePromises.push( - fetch(eventLink) + fetch(eventLink, token) .then(res => res.json()) .then(data => { log.debug(`Loaded page ${page}/${totalPages}, events: ${data.data?.length || 0}`); @@ -157,17 +156,21 @@ export function StatusContext({ children }: { children: JSX.Element }) { } ); + useEffect(() => { + if (!isLoading && user?.access_token) { + runAsync(); + } + }, [user?.access_token, isLoading]); + useMount(() => { const sub = Station.get>("Update", () => new Subject()); - const scheduleNext = () => { - runAsync().then(() => { - sub.next(new Date()); - setTimeout(scheduleNext, 60000); - }); - }; + const interval = setInterval(() => { + runAsync(); + sub.next(new Date()); + }, 60000); - scheduleNext(); + return () => clearInterval(interval); }); function update(data: IStatusContext = ins) { diff --git a/src/index.css b/src/index.css index df4b8bc..f941de8 100644 --- a/src/index.css +++ b/src/index.css @@ -14,3 +14,13 @@ img { image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; } + +.blue-dot { + position: absolute !important; + top: -10%; + right: -10%; +} + +.with-dot { + position: relative; +}