/saas/liveLive traffic dashboard
A live operations page: four ticking numbers with sparklines behind them, a scrolling country strip, a feed that fills itself, sessions by country, and p95 by country against the objective.
Live, and honest about it. The floor is db.sessions' most recent hour — real countries, real latencies — and the ticking on top of it is a seeded series generated on the server from seeded("live") against REFERENCE_DATE, never a clock and never Math.random, so the frame the server sends is the frame the browser hydrates and the page reads the same every time it is opened. The client generates nothing: a generator is a function and a function cannot cross into the browser, so the whole series is handed over as plain rows and the islands walk it — which is also what keeps db on the server. One provider owns the clock, so the tiles, the strip and the sparklines can never disagree about what minute it is, where two panels each running their own interval eventually would. Everything stops when nobody is watching: a hidden tab or a reader who has asked for less motion parks the clock and the feed alike. Composes AppShell, PageHeader, StatCardGroup, StatCard, Sparkline, Ticker, LiveFeed, LiveIndicator, DashboardGrid, Widget, RankList, ChartCard and BarChart.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { LatencyByCountry } from "./components/latency-by-country"
import { LiveEvents } from "./components/live-events"
import { LiveStats } from "./components/live-stats"
import { LiveTicker } from "./components/live-ticker"
import { LiveTickProvider } from "./components/live-tick"
import { SessionsByCountry } from "./components/sessions-by-country"
import {
countriesNow,
currentUser,
FEED_ROWS,
lastUpdated,
liveFrames,
liveRows,
shellNotifications,
SHOWN_COUNTRIES,
SLO_MS,
TICK_MS,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"
/**
* Live traffic. A server component: it reads the last hour through `db`, then
* generates the whole seeded series the page will walk and hands it over as
* plain rows. Nothing on the client generates anything — which is what makes
* the frame the server sent the frame the browser hydrates, and keeps the data
* layer out of the bundle.
*/
export default function LivePage() {
const countries = countriesNow().slice(0, SHOWN_COUNTRIES)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.live}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Live"
description="What the platform is doing right now, and where it is doing it."
meta={lastUpdated()}
/>
<LiveTickProvider frames={liveFrames()} every={TICK_MS}>
<div className="flex flex-col gap-4">
<LiveStats />
<LiveTicker countries={countries} />
</div>
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<LiveEvents
rows={liveRows()}
every={TICK_MS}
limit={FEED_ROWS}
now={REFERENCE_DATE}
/>
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<SessionsByCountry countries={countries} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<LatencyByCountry countries={countries} slo={SLO_MS} />
</DashboardGridItem>
</DashboardGrid>
</LiveTickProvider>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/saas-liveNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { LatencyByCountry } from "./components/latency-by-country"
import { LiveEvents } from "./components/live-events"
import { LiveStats } from "./components/live-stats"
import { LiveTicker } from "./components/live-ticker"
import { LiveTickProvider } from "./components/live-tick"
import { SessionsByCountry } from "./components/sessions-by-country"
import {
countriesNow,
currentUser,
FEED_ROWS,
lastUpdated,
liveFrames,
liveRows,
shellNotifications,
SHOWN_COUNTRIES,
SLO_MS,
TICK_MS,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"
/**
* Live traffic. A server component: it reads the last hour through `db`, then
* generates the whole seeded series the page will walk and hands it over as
* plain rows. Nothing on the client generates anything — which is what makes
* the frame the server sent the frame the browser hydrates, and keeps the data
* layer out of the bundle.
*/
export default function LivePage() {
const countries = countriesNow().slice(0, SHOWN_COUNTRIES)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.live}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Live"
description="What the platform is doing right now, and where it is doing it."
meta={lastUpdated()}
/>
<LiveTickProvider frames={liveFrames()} every={TICK_MS}>
<div className="flex flex-col gap-4">
<LiveStats />
<LiveTicker countries={countries} />
</div>
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<LiveEvents
rows={liveRows()}
every={TICK_MS}
limit={FEED_ROWS}
now={REFERENCE_DATE}
/>
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<SessionsByCountry countries={countries} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<LatencyByCountry countries={countries} slo={SLO_MS} />
</DashboardGridItem>
</DashboardGrid>
</LiveTickProvider>
</AppShell>
)
}/**
* What /live reads. The floor is `db.sessions`' most recent hour — real
* countries, real latencies — and the ticking on top of it is a seeded series
* generated here, once, from `seeded("live")` against REFERENCE_DATE. Nothing
* reads a clock and nothing calls Math.random, so the frame the server sends is
* the frame the browser hydrates, and the page reads the same every time it is
* opened.
*
* The islands never call any of this. A generator is a function and a function
* cannot cross into the browser, so the whole series is generated here and
* handed over as plain rows; the client walks it on an interval. That is also
* what keeps `db` on the server, where it belongs.
*/
import { getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type Member, type Session } from "@/lib/sample-data"
const SESSIONS = db.sessions.all()
/** How many readings the page walks before it comes round again. */
export const FRAMES = 90
/** How long a reading stands, in milliseconds. */
export const TICK_MS = 3000
/** What p95 the page treats as its objective; anything above it is a miss. */
export const SLO_MS = 400
const LATEST = Math.max(...SESSIONS.map((row) => row.at.getTime()))
const CURRENT_HOUR = SESSIONS.filter((row) => row.at.getTime() === LATEST)
export type CountryNow = {
country: string
region: Session["region"]
sessions: number
p95Ms: number
errorRate: number
}
const BY_COUNTRY: CountryNow[] = [...CURRENT_HOUR]
.map(({ country, region, sessions, p95Ms, errorRate }) => ({
country,
region,
sessions,
p95Ms,
errorRate,
}))
.sort((a, b) => b.sessions - a.sessions)
/** The most recent hour, country by country, busiest first. */
export function countriesNow(): CountryNow[] {
return BY_COUNTRY
}
/** How many countries the two panels name. */
export const SHOWN_COUNTRIES = 8
export type LiveFrame = {
sessions: number
eventsPerMin: number
p95Ms: number
/** A percentage, not a ratio: this is what the tile prints. */
errorRate: number
/**
* Sessions in each of the countries `countriesNow().slice(0, SHOWN_COUNTRIES)`
* names, in that order. A country drifts on its own walk rather than sharing
* the platform's, or a strip of eight would print the same change eight times.
*/
countries: number[]
}
const BASE = {
sessions: CURRENT_HOUR.reduce((total, row) => total + row.sessions, 0),
p95Ms:
CURRENT_HOUR.length === 0
? 0
: CURRENT_HOUR.reduce((total, row) => total + row.p95Ms * row.sessions, 0) /
Math.max(1, CURRENT_HOUR.reduce((total, row) => total + row.sessions, 0)),
errorRate:
CURRENT_HOUR.length === 0
? 0
: CURRENT_HOUR.reduce((total, row) => total + row.errorRate * row.sessions, 0) /
Math.max(1, CURRENT_HOUR.reduce((total, row) => total + row.sessions, 0)),
}
// Events are not a column anywhere, so they are a rule over one that is: a
// session raises about this many events a minute while it is open.
const EVENTS_PER_SESSION = 2.4
/** The four measures a tile prints; `countries` is the strip's, not a tile's. */
export type LiveMeasure = Exclude<keyof LiveFrame, "countries">
const SHOWN = BY_COUNTRY.slice(0, SHOWN_COUNTRIES)
/** A bounded random walk: each step nudges the last one, and never runs away. */
const step = (rand: () => number, from: number, size: number, bound: number) =>
Math.max(-bound, Math.min(bound, from + (rand() - 0.5) * size))
/**
* The readings the page walks. Each frame drifts from the last rather than
* being drawn fresh, so the numbers wander the way a live gauge does instead of
* jumping; the walk is bounded, so nothing ever runs away.
*/
const FRAME_SERIES: LiveFrame[] = (() => {
const rand = seeded("live")
let drift = 0
const local = SHOWN.map(() => 0)
return Array.from({ length: FRAMES }, () => {
drift = step(rand, drift, 0.06, 0.18)
const load = 1 + drift
for (let i = 0; i < local.length; i++) local[i] = step(rand, local[i], 0.09, 0.22)
return {
sessions: Math.round(BASE.sessions * load),
eventsPerMin: Math.round(BASE.sessions * load * EVENTS_PER_SESSION),
// Latency follows the load, and a little harder than linearly: a busy
// minute is a slow minute.
p95Ms: Math.round(BASE.p95Ms * (1 + drift * 1.6) + rand() * 24),
errorRate: Math.round((BASE.errorRate * (1 + drift * 2) + rand() * 0.004) * 1000) / 10,
// A country carries the platform's drift and a wander of its own, so two
// countries never move by the same percentage twice.
countries: SHOWN.map((country, i) => Math.round(country.sessions * (load + local[i]))),
}
})
})()
/** Every reading, in order. The client walks it; it never generates its own. */
export function liveFrames(): LiveFrame[] {
return FRAME_SERIES
}
export type LiveRow = {
id: string
text: string
tone: "success" | "warning" | "danger" | "info" | "neutral"
at: Date
}
/** How many rows the feed holds. */
export const FEED_ROWS = 7
const FEED_SPACING_MS = 12_000
type Line = { text: (rand: () => number) => string; tone: LiveRow["tone"]; weight: number }
// The vocabulary of a live feed: what a platform actually says about itself.
// Each line is written from the rows the page already shows, so nothing here is
// a number nobody could check.
const LINES: Line[] = [
{ text: (rand) => `Order paid · $${(40 + rand() * 900).toFixed(2)}`, tone: "success", weight: 0.26 },
{ text: () => "Trial started", tone: "success", weight: 0.1 },
{ text: (rand) => `Deploy ${sha(rand)} finished`, tone: "info", weight: 0.12 },
{ text: (rand) => `Signup from ${country(rand)}`, tone: "info", weight: 0.16 },
{ text: (rand) => `p95 crossed ${SLO_MS} ms in ${country(rand)}`, tone: "warning", weight: 0.14 },
{ text: () => "Rate limit hit · api-gateway · 429", tone: "warning", weight: 0.08 },
{ text: (rand) => `Webhook delivery failed · attempt ${1 + Math.floor(rand() * 4)} of 5`, tone: "danger", weight: 0.08 },
{ text: () => "Cache warmed · search index", tone: "neutral", weight: 0.06 },
]
const SHA_CHARS = "0123456789abcdef"
function sha(rand: () => number): string {
return Array.from({ length: 7 }, () => SHA_CHARS[Math.floor(rand() * SHA_CHARS.length)]).join("")
}
function country(rand: () => number): string {
return BY_COUNTRY[Math.floor(rand() * BY_COUNTRY.length)]?.country ?? "the United States"
}
/**
* The rows the feed walks. The first `FEED_ROWS` are stamped back from
* REFERENCE_DATE so the page opens on a feed that has been running; everything
* after them is stamped at REFERENCE_DATE, because a row that has just arrived
* arrived now.
*/
const FEED_SERIES: LiveRow[] = (() => {
const rand = seeded("live-feed")
const total = LINES.reduce((sum, line) => sum + line.weight, 0)
return Array.from({ length: FRAMES }, (_, index) => {
let ticket = rand() * total
let line = LINES[LINES.length - 1]
for (const candidate of LINES) {
ticket -= candidate.weight
if (ticket < 0) {
line = candidate
break
}
}
return {
id: `live_${String(index + 1).padStart(3, "0")}`,
text: line.text(rand),
tone: line.tone,
at: new Date(REFERENCE_DATE.getTime() - Math.max(0, FEED_ROWS - 1 - index) * FEED_SPACING_MS),
}
})
})()
/** Every row the feed will show, in the order it will show them. */
export function liveRows(): LiveRow[] {
return FEED_SERIES
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
const STAMP = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
})
/** What the page is measuring against, as a line under the title. */
export function lastUpdated(): string {
return `The hour to ${STAMP.format(REFERENCE_DATE)} UTC · ${SLO_MS} ms objective`
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes. A server action so the page can stay a
* server component and still hand the shell something to call, and a `Result`
* so the caller reads the same success-or-error shape every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { type CountryNow } from "../data"
const ms = (value: number) => `${Math.round(value)} ms`
export type LatencyByCountryProps = {
countries: CountryNow[]
/** The p95 the page treats as its objective, in milliseconds. */
slo: number
}
/** How slow each country's slowest twentieth is, against the objective. */
export function LatencyByCountry({ countries, slo }: LatencyByCountryProps) {
const missing = countries.filter((country) => country.p95Ms > slo).length
return (
<ChartCard
data-widget="widget-saas-live-latency-by-country"
title="p95 by country"
description="The hour just gone, against the objective"
height={280}
className="h-full"
footer={
missing === 0
? `Every country inside the ${slo} ms objective.`
: `${missing} of ${countries.length} countries over the ${slo} ms objective.`
}
>
<BarChart
data={countries.map((country) => ({ country: country.country, p95: country.p95Ms }))}
index="country"
series={[{ key: "p95", label: "p95", color: "chart-3" }]}
annotations={[{ kind: "line", value: slo, label: "Objective", tone: "warning" }]}
valueFormatter={ms}
height={260}
showLegend={false}
/>
</ChartCard>
)
}"use client"
import * as React from "react"
import { LiveFeed } from "@/components/ui/live-feed"
import { LiveIndicator } from "@/components/ui/live-indicator"
import { Widget } from "@/components/ui/widget"
import { type LiveRow } from "../data"
export type LiveEventsProps = {
/** Every row the feed will show, generated on the server, in order. */
rows: LiveRow[]
/** Milliseconds between rows. */
every: number
limit: number
/** What the stamps are measured against — the page's reference instant. */
now: Date
}
/**
* The feed. A function cannot cross into the browser, so the server hands over
* the rows and the source is built here: it walks them and comes round again,
* giving each pass its own ids so React still has a key per row.
*/
export function LiveEvents({ rows, every, limit, now }: LiveEventsProps) {
const [source] = React.useState(() => {
let step = 0
return () => {
const row = rows[step % rows.length]
const pass = Math.floor(step / rows.length)
step += 1
return pass === 0 ? row : { ...row, id: `${row.id}_${pass}`, at: now }
}
})
return (
<Widget
data-widget="widget-saas-live-live-events"
title="Live events"
description="Everything the platform did in the last few minutes"
actions={<LiveIndicator />}
className="h-full"
>
<LiveFeed source={source} every={every} limit={limit} now={now} />
</Widget>
)
}"use client"
import { ActivityIcon, GaugeIcon, TriangleAlertIcon, UsersIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { Sparkline } from "@/components/ui/sparkline"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type LiveMeasure } from "../data"
import { useLiveTick, useLiveWindow } from "./live-tick"
/**
* How many readings sit behind the current one in a tile's sparkline. UI
* vocabulary, so it lives with the chart that draws it rather than in data.ts —
* which is also what keeps this island's only import from there a type.
*/
const SPARK_WINDOW = 24
const whole = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
type Tile = {
key: LiveMeasure
label: string
icon: React.ReactNode
format: (value: number) => string
/** False for the measures where down is the win — latency, errors. */
positiveIsGood: boolean
}
const TILES: Tile[] = [
{ key: "sessions", label: "Active sessions", icon: <UsersIcon />, format: whole, positiveIsGood: true },
{ key: "eventsPerMin", label: "Events / min", icon: <ActivityIcon />, format: whole, positiveIsGood: true },
{ key: "p95Ms", label: "p95 response", icon: <GaugeIcon />, format: (value) => `${Math.round(value)} ms`, positiveIsGood: false },
{ key: "errorRate", label: "Error rate", icon: <TriangleAlertIcon />, format: (value) => `${value.toFixed(1)}%`, positiveIsGood: false },
]
/** How far a reading has moved since the one before, as a ratio. */
function change(now: number, before: number): number {
if (!Number.isFinite(before) || before === 0) return 0
return (now - before) / before
}
/** The four numbers the page is about, rolling to each new reading. */
export function LiveStats() {
const { frame, previous } = useLiveTick()
const window = useLiveWindow(SPARK_WINDOW)
if (!frame) return null
return (
<StatCardGroup data-widget="widget-saas-live-live-stats" columns={4}>
{TILES.map((tile) => (
<StatCard
key={tile.key}
label={tile.label}
roll={{ value: frame[tile.key], format: tile.format }}
delta={change(frame[tile.key], previous?.[tile.key] ?? frame[tile.key])}
positiveIsGood={tile.positiveIsGood}
description="since the last reading"
icon={tile.icon}
footer={
<Sparkline
data={window.map((point) => point[tile.key])}
height={32}
showLast
aria-label={`${tile.label}, last ${window.length} readings`}
/>
}
/>
))}
</StatCardGroup>
)
}"use client"
import * as React from "react"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { type LiveFrame } from "../data"
type Tick = { frames: LiveFrame[]; index: number; running: boolean }
const TickContext = React.createContext<Tick>({ frames: [], index: 0, running: false })
/** Whether the document is currently on screen. False on the server, where it is not. */
function useHidden(): boolean {
const [hidden, setHidden] = React.useState(false)
React.useEffect(() => {
const read = () => setHidden(document.hidden)
read()
document.addEventListener("visibilitychange", read)
return () => document.removeEventListener("visibilitychange", read)
}, [])
return hidden
}
export type LiveTickProviderProps = {
/** Every reading, generated once on the server. The client only walks it. */
frames: LiveFrame[]
/** Milliseconds a reading stands for. */
every: number
/**
* Hold the clock without a box of its own. On the page the box is the column
* the panels stack in, and the node a `[data-motion="reduced"]` above it is
* heard at; a panel drawn alone — one widget — is the whole tree, so the
* clock wraps nothing and listens to the document instead.
*/
bare?: boolean
children: React.ReactNode
}
/**
* The one clock on the page.
*
* Every panel that ticks reads the same index from here, so the tiles, the
* strip and the sparklines can never disagree about what minute it is — which
* two panels each running their own interval eventually would. It walks a
* series the server generated; nothing is drawn here, so the page is the same
* page every time it is opened. It stops when nobody is watching: a hidden tab
* or a reader who has asked for less motion parks it on the frame it is on.
*/
export function LiveTickProvider({ frames, every, bare = false, children }: LiveTickProviderProps) {
const root = React.useRef<HTMLDivElement | null>(null)
const [index, setIndex] = React.useState(0)
const hidden = useHidden()
const reduced = useReducedMotion(root)
const running = !hidden && !reduced && frames.length > 1
useInterval(() => setIndex((current) => (current + 1) % frames.length), running ? every : null)
const value = React.useMemo(() => ({ frames, index, running }), [frames, index, running])
const clock = <TickContext.Provider value={value}>{children}</TickContext.Provider>
if (bare) return clock
return (
<div
ref={root}
data-slot="live-tick"
className="flex flex-col gap-6"
data-running={running || undefined}
>
{clock}
</div>
)
}
/** The reading being shown, and whether it is still moving. */
export function useLiveTick(): { frame: LiveFrame | undefined; previous: LiveFrame | undefined; running: boolean } {
const { frames, index, running } = React.useContext(TickContext)
return {
frame: frames[index],
// The frame before this one, wrapping, so a delta always has something to
// be measured against — including on the first one.
previous: frames[(index - 1 + frames.length) % Math.max(1, frames.length)],
running,
}
}
/** The readings up to and including the current one, for a sparkline. */
export function useLiveWindow(size: number): LiveFrame[] {
const { frames, index } = React.useContext(TickContext)
return React.useMemo(() => {
if (frames.length === 0) return []
return Array.from({ length: size }, (_, step) => {
const at = index - (size - 1 - step)
return frames[((at % frames.length) + frames.length) % frames.length]
})
}, [frames, index, size])
}"use client"
import { formatNumber } from "@/lib/format"
import { Card } from "@/components/ui/card"
import { LiveIndicator } from "@/components/ui/live-indicator"
import { Ticker, type TickerItem } from "@/components/ui/ticker"
import { type CountryNow } from "../data"
import { useLiveTick } from "./live-tick"
/** The strip along the top: the busiest countries, each with how it is doing. */
export function LiveTicker({ countries }: { countries: CountryNow[] }) {
const { frame, previous, running } = useLiveTick()
if (!frame || !previous) return null
// Each country's own reading, and its own change since the frame before —
// `frame.countries` is in the order this list is in.
const items: TickerItem[] = countries.map((country, i) => {
const now = frame.countries[i] ?? country.sessions
const before = previous.countries[i] ?? now
return {
key: country.country,
label: country.country,
value: now,
delta: before === 0 ? 0 : (now - before) / before,
format: (value) => formatNumber(value, { maximumFractionDigits: 0 }),
}
})
return (
<Card data-widget="widget-saas-live-live-ticker" className="flex-row items-center gap-4 overflow-hidden py-3">
<LiveIndicator active={running} className="ms-4 shrink-0" />
<Ticker items={items} scrolling className="min-w-0 pe-4" />
</Card>
)
}"use client"
import { formatCompact } from "@/lib/format"
import { RankList } from "@/components/ui/rank-list"
import { Widget } from "@/components/ui/widget"
import { type CountryNow } from "../data"
/** Who is on the platform right now, busiest country first. */
export function SessionsByCountry({ countries }: { countries: CountryNow[] }) {
return (
<Widget
data-widget="widget-saas-live-sessions-by-country"
title="Sessions by country"
description="The hour just gone"
className="h-full"
>
<RankList
items={countries.map((country) => ({ label: country.country, value: country.sessions }))}
showRank
color="chart-1"
format={(value) => formatCompact(value)}
/>
</Widget>
)
}