@stacksjs/ts-analytics 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team management handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateId } from '../index'
|
|
6
|
+
import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
|
|
7
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
8
|
+
import { getQueryParams } from '../../deploy/lambda-adapter'
|
|
9
|
+
import { addMembership, removeMembership, addPendingInvite } from '../lib/membership'
|
|
10
|
+
import { getUserByEmail } from './auth'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /api/sites/{siteId}/team
|
|
14
|
+
*/
|
|
15
|
+
export async function handleInviteTeamMember(request: Request, siteId: string): Promise<Response> {
|
|
16
|
+
try {
|
|
17
|
+
const body = await request.json() as Record<string, any>
|
|
18
|
+
|
|
19
|
+
if (!body.email || !body.role) {
|
|
20
|
+
return jsonResponse({ error: 'Missing required fields: email, role' }, 400)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const validRoles = ['admin', 'editor', 'viewer']
|
|
24
|
+
if (!validRoles.includes(body.role)) {
|
|
25
|
+
return jsonResponse({ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, 400)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const memberId = generateId()
|
|
29
|
+
const existingUser = await getUserByEmail(body.email)
|
|
30
|
+
const now = new Date().toISOString()
|
|
31
|
+
const member = {
|
|
32
|
+
pk: `SITE#${siteId}`,
|
|
33
|
+
sk: `TEAM#${memberId}`,
|
|
34
|
+
id: memberId,
|
|
35
|
+
siteId,
|
|
36
|
+
email: body.email,
|
|
37
|
+
role: body.role,
|
|
38
|
+
userId: existingUser?.userId || null,
|
|
39
|
+
status: existingUser ? 'active' : 'invited',
|
|
40
|
+
invitedAt: now,
|
|
41
|
+
acceptedAt: existingUser ? now : null,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await dynamodb.putItem({
|
|
45
|
+
TableName: TABLE_NAME,
|
|
46
|
+
Item: marshall(member),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Existing users get immediate project access via the membership layer
|
|
50
|
+
// (what the authz guard checks). New emails stay 'invited' until accept (#60).
|
|
51
|
+
if (existingUser) await addMembership(existingUser.userId, siteId, body.role)
|
|
52
|
+
else await addPendingInvite(body.email, siteId, body.role)
|
|
53
|
+
|
|
54
|
+
// Tell the invitee (#129) — invites previously created silently. Best
|
|
55
|
+
// effort: mail trouble must not fail the invite itself.
|
|
56
|
+
try {
|
|
57
|
+
const { sendEmail } = await import('../lib/email')
|
|
58
|
+
const appUrl = (process.env.APP_URL || '').replace(/\/$/, '')
|
|
59
|
+
await sendEmail({
|
|
60
|
+
to: body.email,
|
|
61
|
+
subject: `You've been invited to an analytics dashboard (${siteId})`,
|
|
62
|
+
text: [
|
|
63
|
+
`You've been invited as ${body.role} on the analytics dashboard for ${siteId}.`,
|
|
64
|
+
existingUser
|
|
65
|
+
? `Your existing account already has access — sign in to view it.`
|
|
66
|
+
: `Sign up with this email address to accept the invite.`,
|
|
67
|
+
appUrl ? `\n${appUrl}/login` : '',
|
|
68
|
+
].filter(Boolean).join('\n'),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
console.error('Invite email failed:', (e as Error).message)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return jsonResponse({ member }, 201)
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error('Invite team member error:', error)
|
|
79
|
+
return errorResponse('Failed to invite team member')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* GET /api/sites/{siteId}/team
|
|
85
|
+
*/
|
|
86
|
+
export async function handleGetTeamMembers(_request: Request, siteId: string): Promise<Response> {
|
|
87
|
+
try {
|
|
88
|
+
const result = await dynamodb.query({
|
|
89
|
+
TableName: TABLE_NAME,
|
|
90
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
91
|
+
ExpressionAttributeValues: {
|
|
92
|
+
':pk': { S: `SITE#${siteId}` },
|
|
93
|
+
':prefix': { S: 'TEAM#' },
|
|
94
|
+
},
|
|
95
|
+
}) as { Items?: any[] }
|
|
96
|
+
|
|
97
|
+
const members = (result.Items || []).map(unmarshall)
|
|
98
|
+
|
|
99
|
+
return jsonResponse({ members })
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
console.error('Get team members error:', error)
|
|
103
|
+
return errorResponse('Failed to fetch team members')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* DELETE /api/sites/{siteId}/team/{memberId}
|
|
109
|
+
*/
|
|
110
|
+
export async function handleRemoveTeamMember(request: Request, siteId: string, memberId: string): Promise<Response> {
|
|
111
|
+
try {
|
|
112
|
+
// Load the record first so we can revoke the linked project membership.
|
|
113
|
+
const got = await dynamodb.getItem({
|
|
114
|
+
TableName: TABLE_NAME,
|
|
115
|
+
Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `TEAM#${memberId}` } },
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Rank rules (#157): the guard already requires admin+ for team writes,
|
|
119
|
+
// but an admin must not be able to remove the OWNER or peer admins —
|
|
120
|
+
// that's an authorization bypass (team takeover). Owner removal is never
|
|
121
|
+
// allowed here (transfer ownership instead); removing an admin requires
|
|
122
|
+
// the owner. Self-removal (leaving the team) is always allowed except
|
|
123
|
+
// for the owner.
|
|
124
|
+
if (got.Item) {
|
|
125
|
+
const target = unmarshall(got.Item)
|
|
126
|
+
const targetRole = String(target.role || 'viewer')
|
|
127
|
+
const { getSessionFromRequest } = await import('./auth')
|
|
128
|
+
const session = await getSessionFromRequest(request)
|
|
129
|
+
const isSelf = !!session && target.userId === session.userId
|
|
130
|
+
if (targetRole === 'owner') {
|
|
131
|
+
return jsonResponse({ error: 'The owner cannot be removed. Transfer ownership first.' }, 403)
|
|
132
|
+
}
|
|
133
|
+
if (targetRole === 'admin' && !isSelf && session) {
|
|
134
|
+
const { getMembership } = await import('../lib/membership')
|
|
135
|
+
const requesterRole = await getMembership(session.userId, siteId)
|
|
136
|
+
if (requesterRole !== 'owner') {
|
|
137
|
+
return jsonResponse({ error: 'Only the owner can remove an admin.' }, 403)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (got.Item) {
|
|
143
|
+
const m = unmarshall(got.Item)
|
|
144
|
+
if (m.userId) await removeMembership(m.userId, siteId)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await dynamodb.deleteItem({
|
|
148
|
+
TableName: TABLE_NAME,
|
|
149
|
+
Key: marshall({
|
|
150
|
+
pk: `SITE#${siteId}`,
|
|
151
|
+
sk: `TEAM#${memberId}`,
|
|
152
|
+
}),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return jsonResponse({ success: true })
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
console.error('Remove team member error:', error)
|
|
159
|
+
return errorResponse('Failed to remove team member')
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uptime monitoring handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateId } from '../index'
|
|
6
|
+
import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
|
|
7
|
+
import { parseDateRange } from '../utils/date'
|
|
8
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
9
|
+
import { getQueryParams } from '../../deploy/lambda-adapter'
|
|
10
|
+
import { sendEmail } from '../lib/email'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /api/sites/{siteId}/uptime
|
|
14
|
+
*/
|
|
15
|
+
export async function handleCreateUptimeMonitor(request: Request, siteId: string): Promise<Response> {
|
|
16
|
+
try {
|
|
17
|
+
const body = await request.json() as Record<string, any>
|
|
18
|
+
|
|
19
|
+
if (!body.url) {
|
|
20
|
+
return jsonResponse({ error: 'Missing required field: url' }, 400)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const monitorId = generateId()
|
|
24
|
+
const monitor = {
|
|
25
|
+
pk: `SITE#${siteId}`,
|
|
26
|
+
sk: `UPTIME#${monitorId}`,
|
|
27
|
+
id: monitorId,
|
|
28
|
+
siteId,
|
|
29
|
+
url: body.url,
|
|
30
|
+
name: body.name || body.url,
|
|
31
|
+
interval: body.interval || 60, // seconds
|
|
32
|
+
timeout: body.timeout || 30, // seconds
|
|
33
|
+
expectedStatus: body.expectedStatus || 200,
|
|
34
|
+
alertEmail: body.alertEmail,
|
|
35
|
+
alertWebhook: body.alertWebhook,
|
|
36
|
+
isActive: body.isActive ?? true,
|
|
37
|
+
status: 'unknown',
|
|
38
|
+
lastChecked: null,
|
|
39
|
+
lastResponseTime: null,
|
|
40
|
+
uptimePercent: 100,
|
|
41
|
+
createdAt: new Date().toISOString(),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await dynamodb.putItem({
|
|
45
|
+
TableName: TABLE_NAME,
|
|
46
|
+
Item: marshall(monitor),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return jsonResponse({ monitor }, 201)
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error('Create uptime monitor error:', error)
|
|
53
|
+
return errorResponse('Failed to create uptime monitor')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* GET /api/sites/{siteId}/uptime
|
|
59
|
+
*/
|
|
60
|
+
export async function handleGetUptimeMonitors(_request: Request, siteId: string): Promise<Response> {
|
|
61
|
+
try {
|
|
62
|
+
const result = await dynamodb.query({
|
|
63
|
+
TableName: TABLE_NAME,
|
|
64
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
65
|
+
ExpressionAttributeValues: {
|
|
66
|
+
':pk': { S: `SITE#${siteId}` },
|
|
67
|
+
':prefix': { S: 'UPTIME#' },
|
|
68
|
+
},
|
|
69
|
+
}) as { Items?: any[] }
|
|
70
|
+
|
|
71
|
+
const monitors = (result.Items || []).map(unmarshall)
|
|
72
|
+
|
|
73
|
+
return jsonResponse({ monitors })
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
console.error('Get uptime monitors error:', error)
|
|
77
|
+
return errorResponse('Failed to fetch uptime monitors')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* GET /api/sites/{siteId}/uptime/{monitorId}/history
|
|
83
|
+
*/
|
|
84
|
+
export async function handleGetUptimeHistory(request: Request, siteId: string, monitorId: string): Promise<Response> {
|
|
85
|
+
try {
|
|
86
|
+
const query = getQueryParams(request)
|
|
87
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
88
|
+
|
|
89
|
+
const result = await dynamodb.query({
|
|
90
|
+
TableName: TABLE_NAME,
|
|
91
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
92
|
+
ExpressionAttributeValues: {
|
|
93
|
+
':pk': { S: `UPTIME_CHECK#${siteId}#${monitorId}` },
|
|
94
|
+
':start': { S: `CHECK#${startDate.toISOString()}` },
|
|
95
|
+
':end': { S: `CHECK#${endDate.toISOString()}` },
|
|
96
|
+
},
|
|
97
|
+
ScanIndexForward: false,
|
|
98
|
+
Limit: 1000,
|
|
99
|
+
}) as { Items?: any[] }
|
|
100
|
+
|
|
101
|
+
const checks = (result.Items || []).map(unmarshall)
|
|
102
|
+
|
|
103
|
+
// Calculate uptime stats
|
|
104
|
+
const totalChecks = checks.length
|
|
105
|
+
const successfulChecks = checks.filter(c => c.status === 'up').length
|
|
106
|
+
const uptimePercent = totalChecks > 0 ? Math.round((successfulChecks / totalChecks) * 10000) / 100 : 100
|
|
107
|
+
|
|
108
|
+
const avgResponseTime = totalChecks > 0
|
|
109
|
+
? Math.round(checks.reduce((sum, c) => sum + (c.responseTime || 0), 0) / totalChecks)
|
|
110
|
+
: 0
|
|
111
|
+
|
|
112
|
+
// Get incidents (status changes)
|
|
113
|
+
const incidents: Array<{ timestamp: string; status: string; duration?: number }> = []
|
|
114
|
+
let currentIncident: { start: string; status: string } | null = null
|
|
115
|
+
|
|
116
|
+
for (let i = checks.length - 1; i >= 0; i--) {
|
|
117
|
+
const check = checks[i]
|
|
118
|
+
if (check.status === 'down' && !currentIncident) {
|
|
119
|
+
currentIncident = { start: check.timestamp, status: 'down' }
|
|
120
|
+
}
|
|
121
|
+
else if (check.status === 'up' && currentIncident) {
|
|
122
|
+
incidents.push({
|
|
123
|
+
timestamp: currentIncident.start,
|
|
124
|
+
status: currentIncident.status,
|
|
125
|
+
duration: new Date(check.timestamp).getTime() - new Date(currentIncident.start).getTime(),
|
|
126
|
+
})
|
|
127
|
+
currentIncident = null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (currentIncident) {
|
|
132
|
+
incidents.push({
|
|
133
|
+
timestamp: currentIncident.start,
|
|
134
|
+
status: currentIncident.status,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return jsonResponse({
|
|
139
|
+
checks: checks.slice(0, 100).map(c => ({
|
|
140
|
+
timestamp: c.timestamp,
|
|
141
|
+
status: c.status,
|
|
142
|
+
responseTime: c.responseTime,
|
|
143
|
+
statusCode: c.statusCode,
|
|
144
|
+
error: c.error,
|
|
145
|
+
})),
|
|
146
|
+
summary: {
|
|
147
|
+
totalChecks,
|
|
148
|
+
uptimePercent,
|
|
149
|
+
avgResponseTime,
|
|
150
|
+
incidents: incidents.length,
|
|
151
|
+
},
|
|
152
|
+
incidents,
|
|
153
|
+
dateRange: {
|
|
154
|
+
start: startDate.toISOString(),
|
|
155
|
+
end: endDate.toISOString(),
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
console.error('Get uptime history error:', error)
|
|
161
|
+
return errorResponse('Failed to fetch uptime history')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* DELETE /api/sites/{siteId}/uptime/{monitorId}
|
|
167
|
+
*/
|
|
168
|
+
export async function handleDeleteUptimeMonitor(_request: Request, siteId: string, monitorId: string): Promise<Response> {
|
|
169
|
+
try {
|
|
170
|
+
await dynamodb.deleteItem({
|
|
171
|
+
TableName: TABLE_NAME,
|
|
172
|
+
Key: marshall({
|
|
173
|
+
pk: `SITE#${siteId}`,
|
|
174
|
+
sk: `UPTIME#${monitorId}`,
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return jsonResponse({ success: true })
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.error('Delete uptime monitor error:', error)
|
|
182
|
+
return errorResponse('Failed to delete uptime monitor')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Probe a URL with a timeout; up = expected status (or any 2xx/3xx). */
|
|
187
|
+
async function probeUrl(url: string, timeoutMs: number, expectedStatus?: number): Promise<{ up: boolean, httpStatus: number, responseTime: number }> {
|
|
188
|
+
const start = Date.now()
|
|
189
|
+
const ctrl = new AbortController()
|
|
190
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
191
|
+
try {
|
|
192
|
+
const res = await fetch(url, { signal: ctrl.signal, redirect: 'follow' })
|
|
193
|
+
const up = expectedStatus ? res.status === expectedStatus : (res.status >= 200 && res.status < 400)
|
|
194
|
+
return { up, httpStatus: res.status, responseTime: Date.now() - start }
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return { up: false, httpStatus: 0, responseTime: Date.now() - start }
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
clearTimeout(t)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function probeAndRecord(siteId: string, m: any): Promise<void> {
|
|
205
|
+
const r = await probeUrl(m.url, (m.timeout || 30) * 1000, m.expectedStatus)
|
|
206
|
+
const nowIso = new Date().toISOString()
|
|
207
|
+
const status = r.up ? 'up' : 'down'
|
|
208
|
+
const wasDown = m.status === 'down'
|
|
209
|
+
|
|
210
|
+
await dynamodb.putItem({
|
|
211
|
+
TableName: TABLE_NAME,
|
|
212
|
+
Item: marshall({
|
|
213
|
+
pk: `UPTIME_CHECK#${siteId}#${m.id}`,
|
|
214
|
+
sk: `CHECK#${nowIso}`,
|
|
215
|
+
status,
|
|
216
|
+
httpStatus: r.httpStatus,
|
|
217
|
+
responseTime: r.responseTime,
|
|
218
|
+
timestamp: nowIso,
|
|
219
|
+
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
|
220
|
+
}),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
await dynamodb.updateItem({
|
|
224
|
+
TableName: TABLE_NAME,
|
|
225
|
+
Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `UPTIME#${m.id}` } },
|
|
226
|
+
UpdateExpression: 'SET #s = :s, lastChecked = :now, lastResponseTime = :rt',
|
|
227
|
+
ExpressionAttributeNames: { '#s': 'status' },
|
|
228
|
+
ExpressionAttributeValues: { ':s': { S: status }, ':now': { S: nowIso }, ':rt': { N: String(r.responseTime) } },
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Notify once on a down transition (up -> down).
|
|
232
|
+
if (status === 'down' && !wasDown) {
|
|
233
|
+
const msg = `Uptime alert: ${m.name || m.url} is DOWN (HTTP ${r.httpStatus || 'timeout'})`
|
|
234
|
+
if (m.alertEmail)
|
|
235
|
+
await sendEmail({ to: m.alertEmail, subject: `[Uptime] ${m.name || m.url} is down`, text: msg })
|
|
236
|
+
if (m.alertWebhook) {
|
|
237
|
+
try {
|
|
238
|
+
await fetch(m.alertWebhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ monitor: m.name || m.url, url: m.url, status, httpStatus: r.httpStatus, at: nowIso }) })
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
console.error('Uptime webhook failed:', (e as Error).message)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Probe every active monitor whose interval has elapsed (scheduled job, #91/#90). */
|
|
248
|
+
export async function runUptimeChecks(): Promise<number> {
|
|
249
|
+
const sites = await dynamodb.query({
|
|
250
|
+
TableName: TABLE_NAME,
|
|
251
|
+
KeyConditionExpression: 'pk = :pk',
|
|
252
|
+
ExpressionAttributeValues: { ':pk': { S: 'SITES' } },
|
|
253
|
+
}) as { Items?: any[] }
|
|
254
|
+
|
|
255
|
+
let probed = 0
|
|
256
|
+
for (const raw of (sites.Items || [])) {
|
|
257
|
+
const site = unmarshall(raw)
|
|
258
|
+
const siteId = site.siteId || site.id
|
|
259
|
+
if (!siteId)
|
|
260
|
+
continue
|
|
261
|
+
const mons = await dynamodb.query({
|
|
262
|
+
TableName: TABLE_NAME,
|
|
263
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :p)',
|
|
264
|
+
ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}` }, ':p': { S: 'UPTIME#' } },
|
|
265
|
+
}) as { Items?: any[] }
|
|
266
|
+
for (const mraw of (mons.Items || [])) {
|
|
267
|
+
const m = unmarshall(mraw)
|
|
268
|
+
if (m.isActive === false)
|
|
269
|
+
continue
|
|
270
|
+
const intervalMs = (m.interval || 60) * 1000
|
|
271
|
+
if (m.lastChecked && Date.now() - new Date(m.lastChecked).getTime() < intervalMs)
|
|
272
|
+
continue
|
|
273
|
+
try {
|
|
274
|
+
await probeAndRecord(siteId, m)
|
|
275
|
+
probed++
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
console.error(`[jobs] uptime probe ${m.id} failed:`, (e as Error).message)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return probed
|
|
283
|
+
}
|