Menu

Change language

GitHub repo

Status Monitoring

blockdevops

Uptime page. Banner derives All systems operational / Degraded performance / Partial outage from the newest day of each service. Per-service rows render a 90-segment day bar (success/warning/destructive/info/muted per status) with hover tooltips and a right-aligned uptime percentage, followed by an incident timeline with severity badges. Pass `services` (name + days array of up/degraded/down/maintenance/none) and `incidents` to replace the stubs.

Installation

$npx uipkge-ng@latest add status-monitoring
Manifest:https://uipkge.dev/r/angular/status-monitoring.jsonInstalls to:src/app/components/blocks/status-monitoring/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

ServiceStatus
interface ServiceStatus {
  name: string
  /** One entry per day, oldest first. Expected length: 90. */
  days: DayStatus[]
}
MonitoringIncident
interface MonitoringIncident {
  id: string
  date: Date
  title: string
  description: string
  duration: string
  severity: IncidentSeverity
  resolved?: boolean
}

npm dependencies

Files installed (2)

  • src/app/components/blocks/status-monitoring/status-monitoring.component.ts10.7 kB
    // Uptime page. Banner derives All systems operational / Degraded performance /
    // Partial outage from the newest day of each service. Per-service rows render a
    // 90-segment day bar with hover tooltips and a right-aligned uptime percentage,
    // followed by an incident timeline with severity badges.
    // Port of the Vue block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    
    export type DayStatus = 'up' | 'degraded' | 'down' | 'maintenance' | 'none'
    
    export type IncidentSeverity = 'minor' | 'major' | 'critical'
    
    export interface ServiceStatus {
      name: string
      /** One entry per day, oldest first. Expected length: 90. */
      days: DayStatus[]
    }
    
    export interface MonitoringIncident {
      id: string
      date: Date
      title: string
      description: string
      duration: string
      severity: IncidentSeverity
      resolved?: boolean
    }
    
    export type OverallState = 'operational' | 'degraded' | 'outage'
    
    const DAY_COUNT = 90
    
    // Deterministic stubs (no Math.random) so SSR and client markup match exactly.
    function buildDays(blips: Array<{ ago: number; length: number; status: DayStatus }> = []): DayStatus[] {
      const days: DayStatus[] = Array.from({ length: DAY_COUNT }, () => 'up')
      for (const blip of blips) {
        for (let k = 0; k < blip.length; k++) {
          const idx = DAY_COUNT - 1 - blip.ago - k
          if (idx >= 0 && idx < DAY_COUNT) days[idx] = blip.status
        }
      }
      return days
    }
    
    const now = new Date()
    const daysAgoDate = (d: number) => new Date(now.getTime() - d * 86_400_000)
    
    const stubServices: ServiceStatus[] = [
      {
        name: 'API Gateway',
        days: buildDays([
          { ago: 34, length: 1, status: 'down' },
          { ago: 33, length: 1, status: 'degraded' },
        ]),
      },
      { name: 'Web application', days: buildDays() },
      { name: 'Webhooks', days: buildDays([{ ago: 61, length: 1, status: 'degraded' }]) },
    ]
    
    const stubIncidents: MonitoringIncident[] = [
      {
        id: 'i1',
        date: daysAgoDate(33),
        title: 'Elevated error rates on API Gateway',
        description: 'A bad deploy caused intermittent 502s for roughly forty minutes. Rolled back; error rates recovered.',
        duration: 'Lasted 42 minutes',
        severity: 'major',
        resolved: true,
      },
      {
        id: 'i2',
        date: daysAgoDate(34),
        title: 'API Gateway unavailable',
        description: 'Provider network maintenance dropped traffic between 02:14 and 02:51 UTC.',
        duration: 'Lasted 37 minutes',
        severity: 'critical',
        resolved: true,
      },
      {
        id: 'i3',
        date: daysAgoDate(61),
        title: 'Delayed webhook deliveries',
        description: 'A queue backlog delayed deliveries by up to six minutes. The backlog drained on its own.',
        duration: 'Lasted 1 hour, 12 minutes',
        severity: 'minor',
        resolved: true,
      },
    ]
    
    const bannerMeta: Record<OverallState, { label: string; box: string; text: string; dot: string }> = {
      operational: {
        label: 'All systems operational',
        box: 'border-success/25 bg-success/10',
        text: 'text-success',
        dot: 'bg-success',
      },
      degraded: {
        label: 'Degraded performance',
        box: 'border-warning/25 bg-warning/10',
        text: 'text-warning',
        dot: 'bg-warning',
      },
      outage: {
        label: 'Partial outage',
        box: 'border-destructive/25 bg-destructive/10',
        text: 'text-destructive',
        dot: 'bg-destructive',
      },
    }
    
    const dayClass: Record<DayStatus, string> = {
      up: 'bg-success',
      degraded: 'bg-warning',
      down: 'bg-destructive',
      maintenance: 'bg-info',
      none: 'bg-muted',
    }
    
    const dayLabel: Record<DayStatus, string> = {
      up: 'Operational',
      degraded: 'Degraded',
      down: 'Outage',
      maintenance: 'Maintenance',
      none: 'No data',
    }
    
    const severityVariant: Record<IncidentSeverity, 'info' | 'warning' | 'destructive'> = {
      minor: 'info',
      major: 'warning',
      critical: 'destructive',
    }
    
    // Built once per module load so 90 segments × N services never rebuild Date objects during render.
    const dayStamps = Array.from({ length: DAY_COUNT }, (_, i) =>
      daysAgoDate(DAY_COUNT - 1 - i).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
    )
    
    const updatedStamp = now.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-status-monitoring, [ui-status-monitoring]',
      standalone: true,
      // Vue renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [UiBadgeComponent, UiSectionCardComponent],
      template: `
        <div data-slot="status-monitoring" [class]="rootClass">
          <div role="status" [class]="bannerClass">
            <span class="relative flex size-2.5 shrink-0">
              <span [class]="bannerDotPingClass"></span>
              <span [class]="bannerDotClass"></span>
            </span>
            <p [class]="bannerTextClass">{{ banner().label }}</p>
            <span class="text-muted-foreground ml-auto text-xs whitespace-nowrap">Updated {{ updatedStamp }}</span>
          </div>
    
          <ui-section-card title="Service status" description="Daily availability over the last 90 days.">
            <ul slot="header-action" class="text-muted-foreground hidden flex-wrap items-center gap-3 text-xs md:flex">
              @for (item of legend; track item.status) {
                <li class="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
                  <span [class]="legendDotClass(item.status)"></span>
                  {{ item.label }}
                </li>
              }
            </ul>
            <ul class="divide-y">
              @for (s of servicesList(); track s.name; let si = $index) {
                <li [class]="serviceRowClass(si)">
                  <div class="flex flex-wrap items-baseline justify-between gap-4">
                    <p class="text-sm font-medium">{{ s.name }}</p>
                    <p class="text-muted-foreground text-sm tabular-nums">{{ uptimePct(s.days) }}</p>
                  </div>
                  <div
                    class="flex w-full gap-px overflow-hidden sm:gap-[2px]"
                    role="img"
                    [attr.aria-label]="s.name + ' uptime ' + uptimePct(s.days) + ' over the last 90 days'"
                  >
                    @for (d of s.days; track $index; let di = $index) {
                      <span [class]="daySegmentClass(d)" [attr.title]="tooltipFor(s.days, di)"></span>
                    }
                  </div>
                </li>
              }
            </ul>
            <p class="text-muted-foreground mt-4 flex justify-between text-xs">
              <span>90 days ago</span>
              <span>Today</span>
            </p>
          </ui-section-card>
    
          <ui-section-card title="Incident history" description="Reported incidents over the same period.">
            @if (incidentList().length) {
              <ul class="divide-y">
                @for (inc of incidentList(); track inc.id; let ii = $index) {
                  <li [class]="ii === 0 ? 'py-4 pt-0' : 'py-4'">
                    <p class="text-muted-foreground text-xs font-medium">{{ formatDay(inc.date) }}</p>
                    <div class="mt-1.5 flex flex-wrap items-center gap-2">
                      <span ui-badge [variant]="incidentBadgeVariant(inc)" class="capitalize">
                        {{ inc.resolved ? 'Resolved' : inc.severity }}
                      </span>
                      <p class="text-sm font-medium">{{ inc.title }}</p>
                      <span class="text-muted-foreground ml-auto text-xs whitespace-nowrap">{{ inc.duration }}</span>
                    </div>
                    <p class="text-muted-foreground mt-1 text-sm leading-relaxed">{{ inc.description }}</p>
                  </li>
                }
              </ul>
            } @else {
              <div class="flex flex-col items-center justify-center px-6 py-10 text-center">
                <p class="text-sm font-medium">No incidents reported</p>
                <p class="text-muted-foreground mt-0.5 text-xs">Every day in this window has been quiet.</p>
              </div>
            }
          </ui-section-card>
        </div>
      `,
    })
    export class UiStatusMonitoringComponent {
      protected readonly legend = (Object.keys(dayLabel) as DayStatus[]).map((status) => ({
        status,
        label: dayLabel[status],
      }))
      protected readonly updatedStamp = updatedStamp
    
      @Input() set services(v: ServiceStatus[] | undefined) {
        this.servicesInput.set(v)
      }
      @Input() set incidents(v: MonitoringIncident[] | undefined) {
        this.incidentsInput.set(v)
      }
      @Input('class') className?: string
    
      private readonly servicesInput = signal<ServiceStatus[] | undefined>(undefined)
      private readonly incidentsInput = signal<MonitoringIncident[] | undefined>(undefined)
    
      readonly servicesList = computed(() => this.servicesInput() ?? stubServices)
    
      readonly incidentList = computed(() =>
        [...(this.incidentsInput() ?? stubIncidents)].sort((a, b) => b.date.getTime() - a.date.getTime()),
      )
    
      readonly overall = computed<OverallState>(() => {
        const latest = this.servicesList().map((s) => [...s.days].reverse().find((d) => d !== 'none'))
        if (latest.includes('down')) return 'outage'
        if (latest.includes('degraded')) return 'degraded'
        return 'operational'
      })
    
      readonly banner = computed(() => bannerMeta[this.overall()])
    
      get rootClass(): string {
        return cn('space-y-4', this.className)
      }
    
      get bannerClass(): string {
        return cn('flex items-center gap-3 rounded-xl border px-4 py-3', this.banner().box)
      }
    
      get bannerTextClass(): string {
        return cn('text-sm font-medium', this.banner().text)
      }
    
      get bannerDotClass(): string {
        return cn('relative inline-flex size-2.5 rounded-full', this.banner().dot)
      }
    
      get bannerDotPingClass(): string {
        return cn('absolute inline-flex h-full w-full rounded-full opacity-60', this.banner().dot)
      }
    
      legendDotClass(status: DayStatus): string {
        return cn('size-2 rounded-full', dayClass[status])
      }
    
      daySegmentClass(status: DayStatus): string {
        return cn('h-6 w-full min-w-px flex-1 rounded-sm transition-transform hover:scale-y-125', dayClass[status])
      }
    
      serviceRowClass(index: number): string {
        return cn('space-y-2 py-4', index === 0 && 'pt-0')
      }
    
      tooltipFor(days: DayStatus[], i: number): string {
        return `${dayStamps[i]} · ${dayLabel[days[i]]}`
      }
    
      uptimePct(days: DayStatus[]): string {
        const counted = days.filter((d) => d !== 'none')
        if (counted.length === 0) return '—'
        const up = counted.filter((d) => d === 'up').length
        return `${((up / counted.length) * 100).toFixed(2)}%`
      }
    
      formatDay(d: Date): string {
        return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
      }
    
      incidentBadgeVariant(inc: MonitoringIncident): 'success' | 'info' | 'warning' | 'destructive' {
        if (inc.resolved) return 'success'
        return severityVariant[inc.severity]
      }
    }
    
  • src/app/components/blocks/status-monitoring/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/status-monitoring.json