feat: add project overview, analytics and logs pages

- ProjectHeader tabs link all five project pages
- Datagrid forwards rest attributes; failure summary in two columns, skip icon on Deploy step
This commit is contained in:
codecalm committed 2026-10-15 12:32:44 +02:00
1 parent 674b8dbd88
commit da33dcc6cc
7 files changed
+519 -8

No files matched your search

+1 -1
View File
@@ -2,4 +2,4 @@
"@tabler/preview": minor
---
Added the deployment failed and project settings preview pages with a shared `ProjectHeader` component.
Added project overview, deployment failed, analytics, logs and settings preview pages with a shared `ProjectHeader`.
+2 -2
View File
@@ -47,7 +47,7 @@ type 'Promise<number>'.
Found 2 errors in 1 file.`,
},
{ status: 'secondary', name: 'Deploy', time: '-', detail: 'Skipped because Compile failed' },
{ status: 'secondary', icon: 'player-skip-forward', name: 'Deploy', time: '-', detail: 'Skipped because Compile failed' },
]
const nextSteps = [
@@ -151,7 +151,7 @@ const nextSteps = [
<Card>
<CardHeader title="Failure summary" />
<CardBody>
<Datagrid>
<Datagrid style="--tblr-datagrid-item-width: 10rem">
<DatagridItem title="Exit code"><code>2</code></DatagridItem>
<DatagridItem title="Failed step">Compile</DatagridItem>
<DatagridItem title="Errors">2 TypeScript errors</DatagridItem>
+153
View File
@@ -0,0 +1,153 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import ProjectHeader from '@shared/components/parts/ProjectHeader.astro'
import Chart from '@shared/components/demo/Chart.astro'
import Button from '@ui/Button.astro'
import ButtonList from '@ui/ButtonList.astro'
import Card from '@ui/Card.astro'
import CardActions from '@ui/CardActions.astro'
import CardBody from '@ui/CardBody.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardTitle from '@ui/CardTitle.astro'
import ChartSparkline from '@ui/ChartSparkline.astro'
import NavSegmented from '@ui/NavSegmented.astro'
import Progress from '@ui/Progress.astro'
const stats = [
{ id: 'requests', label: 'Requests', value: '8.61M', delta: '+8.2%', color: 'success', data: '21,24,22,27,29,26,31' },
{ id: 'visitors', label: 'Unique visitors', value: '142K', delta: '+3.1%', color: 'success', data: '12,14,13,15,14,16,17' },
{ id: 'errors', label: 'Error rate', value: '0.42%', delta: '+0.09 pp', color: 'danger', data: '3,2,4,3,5,6,7' },
{ id: 'latency', label: 'p95 latency', value: '212 ms', delta: '-14 ms', color: 'success', data: '9,8,8,7,7,6,6' },
]
const routes = [
{ path: '/v1/events', requests: '3.2M', share: 37, errors: '0.12%', p95: '148 ms' },
{ path: '/v1/users/:id', requests: '1.9M', share: 22, errors: '0.31%', p95: '96 ms' },
{ path: '/v1/auth/token', requests: '1.1M', share: 13, errors: '1.84%', p95: '402 ms' },
{ path: '/v1/webhooks', requests: '840K', share: 10, errors: '0.05%', p95: '221 ms' },
{ path: '/v1/search', requests: '610K', share: 7, errors: '0.44%', p95: '618 ms' },
]
const statusCodes = [
{ code: '2xx', share: 97.1, color: 'success' },
{ code: '3xx', share: 1.6, color: 'secondary' },
{ code: '4xx', share: 0.9, color: 'warning' },
{ code: '5xx', share: 0.4, color: 'danger' },
]
---
<DefaultLayout title="Project analytics" pageHeader={false} pageMenu="extra.project-analytics" description="Project analytics with request and error charts, top routes and status code breakdown." pageLibs={['apexcharts']}>
<ProjectHeader name="pulse-api" section="Analytics" badge="Production" badgeColor="success" active="analytics">
<ButtonList>
<NavSegmented items={['24h', '7d', '30d']} default={1} name="analytics-range" size="sm" />
<Button text="Export" icon="download" />
</ButtonList>
</ProjectHeader>
<div class="row row-cards">
{
stats.map((stat) => (
<div class="col-sm-6 col-lg-3">
<Card>
<CardBody>
<div class="d-flex align-items-center justify-content-between">
<div class="text-secondary">{stat.label}</div>
<span class:list={['small', 'fw-medium', `text-${stat.color}`]}>{stat.delta}</span>
</div>
<div class="h1 mb-2">{stat.value}</div>
<ChartSparkline id={`analytics-${stat.id}`} type="line" color={stat.color === 'danger' ? 'danger' : 'primary'} data={stat.data} size="sm" />
</CardBody>
</Card>
</div>
))
}
<div class="col-lg-8">
<Card>
<CardHeader>
<CardTitle>Requests</CardTitle>
<CardActions>
<span class="text-secondary">Last 7 days, per hour</span>
</CardActions>
</CardHeader>
<CardBody>
<Chart chartId="visitors" id="analytics-requests-chart" label="Requests per hour, last 7 days" />
</CardBody>
</Card>
</div>
<div class="col-lg-4">
<Card>
<CardHeader>
<CardTitle>Status codes</CardTitle>
<CardActions>
<span class="text-secondary">Share of responses</span>
</CardActions>
</CardHeader>
<CardBody>
<div class="space-y">
{
statusCodes.map((status) => (
<div>
<div class="d-flex justify-content-between mb-1">
<span class="fw-medium">{status.code}</span>
<span class="text-secondary">{status.share}%</span>
</div>
<Progress value={status.share} color={status.color} size="sm" />
</div>
))
}
</div>
</CardBody>
<CardBody>
<Chart chartId="uptime-incidents" id="analytics-errors-chart" label="Errors per day, last 7 days" size="sm" />
<div class="text-secondary mt-2">5xx responses per day</div>
</CardBody>
</Card>
</div>
<div class="col-12">
<Card>
<CardHeader>
<CardTitle>Top routes</CardTitle>
<CardActions>
<Button text="All routes" size="sm" />
</CardActions>
</CardHeader>
<div class="table-responsive">
<table class="table card-table table-vcenter text-nowrap">
<thead>
<tr>
<th>Route</th>
<th>Requests</th>
<th class="w-25">Share</th>
<th>Error rate</th>
<th>p95 latency</th>
</tr>
</thead>
<tbody>
{
routes.map((route) => (
<tr>
<td>
<code>{route.path}</code>
</td>
<td>{route.requests}</td>
<td>
<div class="d-flex align-items-center gap-2">
<Progress value={route.share} size="sm" class="flex-fill" />
<span class="text-secondary">{route.share}%</span>
</div>
</td>
<td class:list={{ 'text-danger': Number.parseFloat(route.errors) > 1 }}>{route.errors}</td>
<td class:list={{ 'text-warning': Number.parseInt(route.p95, 10) > 400 }}>{route.p95}</td>
</tr>
))
}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</DefaultLayout>
+125
View File
@@ -0,0 +1,125 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import ProjectHeader from '@shared/components/parts/ProjectHeader.astro'
import Badge from '@ui/Badge.astro'
import Button from '@ui/Button.astro'
import ButtonList from '@ui/ButtonList.astro'
import Card from '@ui/Card.astro'
import CardActions from '@ui/CardActions.astro'
import CardBody from '@ui/CardBody.astro'
import CardFooter from '@ui/CardFooter.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardTitle from '@ui/CardTitle.astro'
import InputIcon from '@ui/form/InputIcon.astro'
import NavSegmented from '@ui/NavSegmented.astro'
import StatusDot from '@ui/StatusDot.astro'
type Level = 'info' | 'warn' | 'error'
const levels: Record<Level, { text: string; color: string }> = {
info: { text: 'INFO', color: 'secondary' },
warn: { text: 'WARN', color: 'warning' },
error: { text: 'ERROR', color: 'danger' },
}
const statusColor = (status: number) => (status >= 500 ? 'danger' : status >= 400 ? 'warning' : 'success')
const logs: { time: string; level: Level; status: number; method: string; path: string; duration: string; message: string }[] = [
{ time: '09:41:52.114', level: 'info', status: 200, method: 'POST', path: '/v1/events', duration: '48 ms', message: 'Accepted 240 events for workspace ws_8f2a' },
{ time: '09:41:51.907', level: 'info', status: 200, method: 'GET', path: '/v1/users/u_1042', duration: '12 ms', message: 'Cache hit' },
{ time: '09:41:51.640', level: 'warn', status: 429, method: 'POST', path: '/v1/auth/token', duration: '3 ms', message: 'Rate limit exceeded for client c_77e1, retry after 12 s' },
{ time: '09:41:50.982', level: 'error', status: 502, method: 'GET', path: '/v1/search', duration: '2,004 ms', message: 'Upstream search cluster timed out after 2,000 ms' },
{ time: '09:41:50.311', level: 'info', status: 201, method: 'POST', path: '/v1/webhooks', duration: '96 ms', message: 'Registered webhook wh_5c31 for order.created' },
{ time: '09:41:49.870', level: 'info', status: 304, method: 'GET', path: '/v1/users/u_2210', duration: '9 ms', message: 'Not modified' },
{ time: '09:41:49.204', level: 'warn', status: 401, method: 'GET', path: '/v1/events', duration: '4 ms', message: 'Expired token for client c_09aa' },
{ time: '09:41:48.556', level: 'error', status: 500, method: 'POST', path: '/v1/events', duration: '311 ms', message: 'Redis connection reset by peer, retrying' },
{ time: '09:41:48.020', level: 'info', status: 200, method: 'GET', path: '/v1/health', duration: '1 ms', message: 'OK' },
{ time: '09:41:47.402', level: 'info', status: 200, method: 'DELETE', path: '/v1/webhooks/wh_11ab', duration: '38 ms', message: 'Webhook removed' },
]
---
<DefaultLayout title="Project logs" pageHeader={false} pageMenu="extra.project-logs" description="Runtime logs with a filter toolbar, request status, level badges and live tail.">
<ProjectHeader name="pulse-api" section="Logs" badge="Production" badgeColor="success" active="logs">
<ButtonList>
<Button text="Pause" icon="player-pause" />
<Button text="Download" icon="download" />
</ButtonList>
</ProjectHeader>
<Card>
<CardHeader>
<CardTitle>Runtime logs</CardTitle>
<CardActions>
<span class="d-inline-flex align-items-center gap-2 text-secondary">
<StatusDot color="success" animated />
Live
</span>
</CardActions>
</CardHeader>
<CardBody>
<div class="row g-2 align-items-center">
<div class="col-12 col-md">
<InputIcon icon="search" placeholder="Search messages, paths or request ids" ariaLabel="Search logs" />
</div>
<div class="col-6 col-md-auto">
<select class="form-select" aria-label="Log level">
<option value="all" selected>All levels</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
<div class="col-6 col-md-auto">
<select class="form-select" aria-label="Environment">
<option value="production" selected>Production</option>
<option value="preview">Preview</option>
</select>
</div>
<div class="col-12 col-md-auto">
<NavSegmented items={['Live', '1h', '24h']} default={0} name="logs-range" />
</div>
</div>
</CardBody>
<div class="table-responsive">
<table class="table card-table table-vcenter text-nowrap">
<thead>
<tr>
<th>Time</th>
<th>Level</th>
<th>Status</th>
<th>Request</th>
<th>Duration</th>
<th class="w-100">Message</th>
</tr>
</thead>
<tbody>
{
logs.map((log) => (
<tr class:list={{ 'bg-danger-lt': log.level === 'error' }}>
<td class="text-secondary font-monospace">{log.time}</td>
<td>
<Badge text={levels[log.level].text} color={levels[log.level].color} light />
</td>
<td>
<span class="d-inline-flex align-items-center gap-2">
<StatusDot color={statusColor(log.status)} />
{log.status}
</span>
</td>
<td class="font-monospace">
<span class="text-secondary">{log.method}</span> {log.path}
</td>
<td class:list={['text-secondary', { 'text-danger': log.duration.includes(',') }]}>{log.duration}</td>
<td class="text-wrap">{log.message}</td>
</tr>
))
}
</tbody>
</table>
</div>
<CardFooter class="d-flex align-items-center">
<span class="text-secondary">Showing the latest 10 of 1,284 lines</span>
<Button text="Load older" size="sm" class="ms-auto" />
</CardFooter>
</Card>
</DefaultLayout>
+218
View File
@@ -0,0 +1,218 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import ProjectHeader from '@shared/components/parts/ProjectHeader.astro'
import Badge from '@ui/Badge.astro'
import Button from '@ui/Button.astro'
import ButtonList from '@ui/ButtonList.astro'
import Card from '@ui/Card.astro'
import CardActions from '@ui/CardActions.astro'
import CardBody from '@ui/CardBody.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardTitle from '@ui/CardTitle.astro'
import Datagrid from '@ui/Datagrid.astro'
import DatagridItem from '@ui/DatagridItem.astro'
import Icon from '@ui/Icon.astro'
import ListGroup from '@ui/ListGroup.astro'
import ListGroupItem from '@ui/ListGroupItem.astro'
import StatusDot from '@ui/StatusDot.astro'
const stats = [
{ label: 'Requests, 24h', value: '1.24M', hint: '+8% vs. yesterday', color: 'success' },
{ label: 'Error rate', value: '0.42%', hint: '5,208 errors', color: 'warning' },
{ label: 'p95 latency', value: '212 ms', hint: '-14 ms vs. yesterday', color: 'success' },
{ label: 'Uptime, 30d', value: '99.98%', hint: '1 incident, 9 min', color: 'success' },
]
type Deployment = {
status: 'success' | 'danger' | 'warning'
statusText: string
message: string
branch: string
commit: string
environment: 'Production' | 'Preview'
time: string
href?: string
}
const deployments: Deployment[] = [
{ status: 'danger', statusText: 'Error', message: 'Migrate rate limiter to Redis', branch: 'feat/redis-limiter', commit: '2e77b09', environment: 'Preview', time: '1 day ago', href: './deployment-failed.html' },
{ status: 'success', statusText: 'Ready', message: 'Add request id to access logs', branch: 'main', commit: 'a91c3f2', environment: 'Production', time: '3 hours ago' },
{ status: 'success', statusText: 'Ready', message: 'Bump Node.js to 22.x', branch: 'main', commit: '7d0e4b1', environment: 'Production', time: '2 days ago' },
{ status: 'warning', statusText: 'Building', message: 'Cache webhook signatures', branch: 'feat/webhook-cache', commit: 'c58a2d7', environment: 'Preview', time: 'just now' },
]
const domains = [
{ host: 'api.pulsehq.dev', status: 'success', text: 'Valid configuration' },
{ host: 'pulse-api.relay.app', status: 'success', text: 'Auto-assigned' },
{ host: 'staging-api.pulsehq.dev', status: 'warning', text: 'DNS pending' },
]
const activity = [
{ icon: 'rocket', color: 'success', text: 'Deployed a91c3f2 to production', time: '3 hours ago' },
{ icon: 'alert-triangle', color: 'danger', text: 'Preview build failed on feat/redis-limiter', time: '1 day ago' },
{ icon: 'key', color: 'primary', text: 'k.adamski updated REDIS_URL in Production', time: '1 day ago' },
{ icon: 'world', color: 'secondary', text: 'Added domain staging-api.pulsehq.dev', time: '2 days ago' },
]
---
<DefaultLayout title="Project overview" pageHeader={false} pageMenu="extra.project-overview" description="Project overview with the production deployment, key metrics, latest deployments, domains and activity.">
<ProjectHeader name="pulse-api" section="Overview" badge="Production" badgeColor="success" active="overview">
<ButtonList>
<Button text="Visit" icon="external-link" href="#" />
<Button text="Deploy" color="primary" icon="rocket" />
</ButtonList>
</ProjectHeader>
<div class="row row-cards">
<div class="col-lg-8">
<div class="row row-cards">
<div class="col-12">
<Card>
<CardHeader>
<CardTitle>Production deployment</CardTitle>
<CardActions>
<Button text="Redeploy" size="sm" icon="refresh" />
</CardActions>
</CardHeader>
<CardBody>
<Datagrid>
<DatagridItem title="Status">
<span class="d-inline-flex align-items-center gap-2">
<StatusDot color="success" animated />
Ready
</span>
<span class="text-secondary ms-1">since 3 hours ago</span>
</DatagridItem>
<DatagridItem title="Source">
<a href="#" class="text-reset">
<Icon name="git-branch" class="icon-inline" />
main
</a>
<span class="text-secondary ms-1"><code>a91c3f2</code> Add request id to access logs</span>
</DatagridItem>
<DatagridItem title="Domain">
<a href="#">api.pulsehq.dev</a>
</DatagridItem>
<DatagridItem title="Runtime">Node.js 22.x, fra1</DatagridItem>
</Datagrid>
</CardBody>
<CardBody>
<div class="row g-4">
{
stats.map((stat) => (
<div class="col-6 col-md-3">
<div class="text-secondary">{stat.label}</div>
<div class="h2 mb-1">{stat.value}</div>
<div class:list={['small', `text-${stat.color}`]}>{stat.hint}</div>
</div>
))
}
</div>
</CardBody>
</Card>
</div>
<div class="col-12">
<Card>
<CardHeader>
<CardTitle>Latest deployments</CardTitle>
<CardActions>
<Button text="View all" size="sm" href="./deployment-failed.html" />
</CardActions>
</CardHeader>
<ListGroup class="card-list-group" hoverable>
{
deployments.map((deployment) => (
<ListGroupItem>
<div class="row align-items-center g-3">
<div class="col-auto">
<StatusDot color={deployment.status} animated={deployment.status === 'warning'} ariaLabel={deployment.statusText} />
</div>
<div class="col">
<div class="fw-medium">
{deployment.href ? (
<a href={deployment.href} class="text-reset">
{deployment.message}
</a>
) : (
deployment.message
)}
</div>
<div class="text-secondary">
<Icon name="git-branch" class="icon-inline" />
{deployment.branch} <code>{deployment.commit}</code>
</div>
</div>
<div class="col-auto">
<Badge text={deployment.environment} color={deployment.environment === 'Production' ? 'primary' : 'secondary'} light />
</div>
<div class="col-auto text-secondary">{deployment.time}</div>
</div>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="row row-cards">
<div class="col-12">
<Card>
<CardHeader>
<CardTitle>Domains</CardTitle>
<CardActions>
<Button text="Manage" size="sm" href="./project-settings.html" />
</CardActions>
</CardHeader>
<ListGroup class="card-list-group">
{
domains.map((domain) => (
<ListGroupItem>
<div class="d-flex align-items-center gap-2">
<StatusDot color={domain.status} />
<div>
<div class="fw-medium">{domain.host}</div>
<div class="text-secondary">{domain.text}</div>
</div>
</div>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-12">
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardActions>
<Button text="View logs" size="sm" href="./project-logs.html" />
</CardActions>
</CardHeader>
<ListGroup class="card-list-group">
{
activity.map((item) => (
<ListGroupItem>
<div class="d-flex align-items-center gap-3">
<span class:list={['avatar', 'avatar-sm', `bg-${item.color}-lt`]}>
<Icon name={item.icon} color={item.color} />
</span>
<div>
<div>{item.text}</div>
<div class="text-secondary">{item.time}</div>
</div>
</div>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
</div>
</div>
</div>
</DefaultLayout>
+5 -5
View File
@@ -24,11 +24,11 @@ interface Props {
const { name, section, badge, badgeColor, active } = Astro.props
const tabs: { key: Tab; label: string; href?: string }[] = [
{ key: 'overview', label: 'Overview' },
const tabs: { key: Tab; label: string; href: string }[] = [
{ key: 'overview', label: 'Overview', href: './project-overview.html' },
{ key: 'deployments', label: 'Deployments', href: './deployment-failed.html' },
{ key: 'analytics', label: 'Analytics' },
{ key: 'logs', label: 'Logs' },
{ key: 'analytics', label: 'Analytics', href: './project-analytics.html' },
{ key: 'logs', label: 'Logs', href: './project-logs.html' },
{ key: 'settings', label: 'Settings', href: './project-settings.html' },
]
---
@@ -53,7 +53,7 @@ const tabs: { key: Tab; label: string; href?: string }[] = [
{
tabs.map((tab) => (
<li class="nav-item">
<a class:list={['nav-link', 'text-nowrap', { active: tab.key === active }]} href={tab.href ?? '#'} aria-current={tab.key === active ? 'page' : undefined}>
<a class:list={['nav-link', 'text-nowrap', { active: tab.key === active }]} href={tab.href} aria-current={tab.key === active ? 'page' : undefined}>
{tab.label}
</a>
</li>
+15
View File
@@ -408,6 +408,21 @@
"url": "pricing-table.html",
"title": "Pricing table"
},
"project-overview": {
"title": "Project overview",
"url": "project-overview.html",
"badge": "New"
},
"project-analytics": {
"title": "Project analytics",
"url": "project-analytics.html",
"badge": "New"
},
"project-logs": {
"title": "Project logs",
"url": "project-logs.html",
"badge": "New"
},
"project-settings": {
"title": "Project settings",
"url": "project-settings.html",