@gravito/zenith 0.1.0-beta.1 → 1.0.0-beta.1
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/ALERTING_GUIDE.md +71 -0
- package/QUASAR_MASTER_PLAN.md +137 -0
- package/dist/bin.js +38061 -26911
- package/dist/client/assets/index-BSTyMCFd.css +1 -0
- package/dist/client/assets/index-oXEse8ih.js +436 -0
- package/dist/client/index.html +2 -2
- package/dist/server/index.js +38061 -26911
- package/package.json +52 -48
- package/specs/PULSE_SPEC.md +86 -0
- package/src/client/App.tsx +2 -0
- package/src/client/Layout.tsx +30 -11
- package/src/client/Sidebar.tsx +2 -1
- package/src/client/WorkerStatus.tsx +25 -21
- package/src/client/components/BrandIcons.tsx +63 -0
- package/src/client/components/PageHeader.tsx +34 -0
- package/src/client/pages/OverviewPage.tsx +18 -20
- package/src/client/pages/PulsePage.tsx +396 -0
- package/src/client/pages/QueuesPage.tsx +1 -3
- package/src/client/pages/SettingsPage.tsx +586 -78
- package/src/client/pages/WorkersPage.tsx +1 -1
- package/src/client/pages/index.ts +1 -0
- package/src/server/index.ts +148 -8
- package/src/server/services/AlertService.ts +189 -41
- package/src/server/services/CommandService.ts +137 -0
- package/src/server/services/PulseService.ts +80 -0
- package/src/server/services/QueueService.ts +58 -4
- package/src/shared/types.ts +97 -0
- package/tsconfig.json +2 -2
- package/PULSE_IMPLEMENTATION_PLAN.md +0 -111
- package/dist/client/assets/index-DGYEwTDL.css +0 -1
- package/dist/client/assets/index-oyTdySX0.js +0 -421
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { CommandType, QuasarCommand } from '@gravito/quasar'
|
|
2
|
+
import { Redis } from 'ioredis'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CommandService handles sending commands from Zenith to Quasar agents.
|
|
6
|
+
*
|
|
7
|
+
* This is the "control center" that publishes commands to Redis Pub/Sub.
|
|
8
|
+
* Agents subscribe and execute commands locally.
|
|
9
|
+
*/
|
|
10
|
+
export class CommandService {
|
|
11
|
+
private redis: Redis
|
|
12
|
+
|
|
13
|
+
constructor(redisUrl: string) {
|
|
14
|
+
this.redis = new Redis(redisUrl, {
|
|
15
|
+
lazyConnect: true,
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async connect(): Promise<void> {
|
|
20
|
+
if (this.redis.status !== 'ready' && this.redis.status !== 'connecting') {
|
|
21
|
+
await this.redis.connect()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Send a command to a specific Quasar agent.
|
|
27
|
+
*
|
|
28
|
+
* @param service - Target service name
|
|
29
|
+
* @param nodeId - Target node ID (or '*' for broadcast)
|
|
30
|
+
* @param type - Command type
|
|
31
|
+
* @param payload - Command payload
|
|
32
|
+
* @returns Command ID
|
|
33
|
+
*/
|
|
34
|
+
async sendCommand(
|
|
35
|
+
service: string,
|
|
36
|
+
nodeId: string,
|
|
37
|
+
type: CommandType,
|
|
38
|
+
payload: QuasarCommand['payload']
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
const commandId = crypto.randomUUID()
|
|
41
|
+
|
|
42
|
+
const command: QuasarCommand = {
|
|
43
|
+
id: commandId,
|
|
44
|
+
type,
|
|
45
|
+
targetNodeId: nodeId,
|
|
46
|
+
payload,
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
issuer: 'zenith',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const channel = `gravito:quasar:cmd:${service}:${nodeId}`
|
|
52
|
+
|
|
53
|
+
await this.redis.publish(channel, JSON.stringify(command))
|
|
54
|
+
|
|
55
|
+
console.log(`[CommandService] 📤 Sent ${type} to ${channel}`)
|
|
56
|
+
return commandId
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Retry a job on a specific node.
|
|
61
|
+
*/
|
|
62
|
+
async retryJob(
|
|
63
|
+
service: string,
|
|
64
|
+
nodeId: string,
|
|
65
|
+
queue: string,
|
|
66
|
+
jobKey: string,
|
|
67
|
+
driver: 'redis' | 'laravel' = 'redis'
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
return this.sendCommand(service, nodeId, 'RETRY_JOB', {
|
|
70
|
+
queue,
|
|
71
|
+
jobKey,
|
|
72
|
+
driver,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Delete a job on a specific node.
|
|
78
|
+
*/
|
|
79
|
+
async deleteJob(
|
|
80
|
+
service: string,
|
|
81
|
+
nodeId: string,
|
|
82
|
+
queue: string,
|
|
83
|
+
jobKey: string,
|
|
84
|
+
driver: 'redis' | 'laravel' = 'redis'
|
|
85
|
+
): Promise<string> {
|
|
86
|
+
return this.sendCommand(service, nodeId, 'DELETE_JOB', {
|
|
87
|
+
queue,
|
|
88
|
+
jobKey,
|
|
89
|
+
driver,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Broadcast a retry command to all nodes of a service.
|
|
95
|
+
*/
|
|
96
|
+
async broadcastRetryJob(
|
|
97
|
+
service: string,
|
|
98
|
+
queue: string,
|
|
99
|
+
jobKey: string,
|
|
100
|
+
driver: 'redis' | 'laravel' = 'redis'
|
|
101
|
+
): Promise<string> {
|
|
102
|
+
return this.retryJob(service, '*', queue, jobKey, driver)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Broadcast a delete command to all nodes of a service.
|
|
107
|
+
*/
|
|
108
|
+
async broadcastDeleteJob(
|
|
109
|
+
service: string,
|
|
110
|
+
queue: string,
|
|
111
|
+
jobKey: string,
|
|
112
|
+
driver: 'redis' | 'laravel' = 'redis'
|
|
113
|
+
): Promise<string> {
|
|
114
|
+
return this.deleteJob(service, '*', queue, jobKey, driver)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Send a Laravel-specific action (retry-all, restart) to a node.
|
|
119
|
+
*/
|
|
120
|
+
async laravelAction(
|
|
121
|
+
service: string,
|
|
122
|
+
nodeId: string,
|
|
123
|
+
action: 'retry-all' | 'restart' | 'retry',
|
|
124
|
+
jobId?: string
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
return this.sendCommand(service, nodeId, 'LARAVEL_ACTION', {
|
|
127
|
+
queue: 'default',
|
|
128
|
+
jobKey: '*',
|
|
129
|
+
action,
|
|
130
|
+
jobId,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async disconnect(): Promise<void> {
|
|
135
|
+
await this.redis.quit()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Redis } from 'ioredis'
|
|
2
|
+
import type { PulseNode } from '../../shared/types'
|
|
3
|
+
|
|
4
|
+
export class PulseService {
|
|
5
|
+
private redis: Redis
|
|
6
|
+
private prefix = 'gravito:quasar:node:'
|
|
7
|
+
|
|
8
|
+
constructor(redisUrl: string) {
|
|
9
|
+
this.redis = new Redis(redisUrl, {
|
|
10
|
+
lazyConnect: true,
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async connect() {
|
|
15
|
+
await this.redis.connect()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discovers active Pulse nodes using SCAN.
|
|
20
|
+
*/
|
|
21
|
+
async getNodes(): Promise<Record<string, PulseNode[]>> {
|
|
22
|
+
const nodes: PulseNode[] = []
|
|
23
|
+
let cursor = '0'
|
|
24
|
+
const now = Date.now()
|
|
25
|
+
|
|
26
|
+
do {
|
|
27
|
+
// Scan for pulse keys
|
|
28
|
+
const result = await this.redis.scan(cursor, 'MATCH', `${this.prefix}*`, 'COUNT', 100)
|
|
29
|
+
cursor = result[0]
|
|
30
|
+
const keys = result[1]
|
|
31
|
+
|
|
32
|
+
if (keys.length > 0) {
|
|
33
|
+
// Fetch values
|
|
34
|
+
const values = await this.redis.mget(...keys)
|
|
35
|
+
|
|
36
|
+
values.forEach((v) => {
|
|
37
|
+
if (v) {
|
|
38
|
+
try {
|
|
39
|
+
const node = JSON.parse(v) as PulseNode
|
|
40
|
+
// Filter out stale nodes if TTL didn't catch them yet (grace period 60s)
|
|
41
|
+
if (now - node.timestamp < 60000) {
|
|
42
|
+
nodes.push(node)
|
|
43
|
+
}
|
|
44
|
+
} catch (_e) {
|
|
45
|
+
// Ignore malformed
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
} while (cursor !== '0')
|
|
51
|
+
|
|
52
|
+
// Group by service
|
|
53
|
+
const grouped: Record<string, PulseNode[]> = {}
|
|
54
|
+
|
|
55
|
+
// Sort nodes by service name, then by node id for stable UI positions
|
|
56
|
+
nodes.sort((a, b) => {
|
|
57
|
+
const sComp = a.service.localeCompare(b.service)
|
|
58
|
+
if (sComp !== 0) return sComp
|
|
59
|
+
return a.id.localeCompare(b.id)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
for (const node of nodes) {
|
|
63
|
+
if (!grouped[node.service]) {
|
|
64
|
+
grouped[node.service] = []
|
|
65
|
+
}
|
|
66
|
+
grouped[node.service].push(node)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return grouped
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Manually record a heartbeat (for this Zenith server itself).
|
|
74
|
+
*/
|
|
75
|
+
async recordHeartbeat(node: PulseNode): Promise<void> {
|
|
76
|
+
const key = `${this.prefix}${node.service}:${node.id}`
|
|
77
|
+
// TTL 30 seconds
|
|
78
|
+
await this.redis.set(key, JSON.stringify(node), 'EX', 30)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -51,7 +51,7 @@ export class QueueService {
|
|
|
51
51
|
private logThrottleReset = Date.now()
|
|
52
52
|
private readonly MAX_LOGS_PER_SEC = 50
|
|
53
53
|
private manager: QueueManager
|
|
54
|
-
public alerts
|
|
54
|
+
public alerts: AlertService
|
|
55
55
|
|
|
56
56
|
constructor(
|
|
57
57
|
redisUrl: string,
|
|
@@ -84,10 +84,11 @@ export class QueueService {
|
|
|
84
84
|
},
|
|
85
85
|
persistence,
|
|
86
86
|
})
|
|
87
|
+
this.alerts = new AlertService(redisUrl)
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
async connect() {
|
|
90
|
-
await Promise.all([this.redis.connect(), this.subRedis.connect()])
|
|
91
|
+
await Promise.all([this.redis.connect(), this.subRedis.connect(), this.alerts.connect()])
|
|
91
92
|
|
|
92
93
|
// Setup single Redis subscription
|
|
93
94
|
await this.subRedis.subscribe('flux_console:logs')
|
|
@@ -123,6 +124,58 @@ export class QueueService {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
})
|
|
127
|
+
|
|
128
|
+
// Start Maintenance Loop
|
|
129
|
+
this.runMaintenanceLoop()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async runMaintenanceLoop() {
|
|
133
|
+
// Initial delay to avoid startup congestion
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
const loop = async () => {
|
|
136
|
+
try {
|
|
137
|
+
await this.checkMaintenance()
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error('[Maintenance] Task Error:', err)
|
|
140
|
+
}
|
|
141
|
+
// Check every hour (3600000 ms)
|
|
142
|
+
setTimeout(loop, 3600000)
|
|
143
|
+
}
|
|
144
|
+
loop()
|
|
145
|
+
}, 1000 * 30) // 30 seconds after boot
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async checkMaintenance() {
|
|
149
|
+
const config = await this.getMaintenanceConfig()
|
|
150
|
+
if (!config.autoCleanup) return
|
|
151
|
+
|
|
152
|
+
const now = Date.now()
|
|
153
|
+
const lastRun = config.lastRun || 0
|
|
154
|
+
const ONE_DAY = 24 * 60 * 60 * 1000
|
|
155
|
+
|
|
156
|
+
if (now - lastRun >= ONE_DAY) {
|
|
157
|
+
console.log(
|
|
158
|
+
`[Maintenance] Starting Auto-Cleanup (Retention: ${config.retentionDays} days)...`
|
|
159
|
+
)
|
|
160
|
+
const deleted = await this.cleanupArchive(config.retentionDays)
|
|
161
|
+
console.log(`[Maintenance] Cleanup Complete. Removed ${deleted} records.`)
|
|
162
|
+
|
|
163
|
+
// Update Last Run
|
|
164
|
+
await this.saveMaintenanceConfig({
|
|
165
|
+
...config,
|
|
166
|
+
lastRun: now,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async getMaintenanceConfig(): Promise<any> {
|
|
172
|
+
const data = await this.redis.get('gravito:zenith:maintenance:config')
|
|
173
|
+
if (data) return JSON.parse(data)
|
|
174
|
+
return { autoCleanup: false, retentionDays: 30 }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async saveMaintenanceConfig(config: any): Promise<void> {
|
|
178
|
+
await this.redis.set('gravito:zenith:maintenance:config', JSON.stringify(config))
|
|
126
179
|
}
|
|
127
180
|
|
|
128
181
|
/**
|
|
@@ -291,7 +344,7 @@ export class QueueService {
|
|
|
291
344
|
/**
|
|
292
345
|
* Records a snapshot of current global statistics for sparklines.
|
|
293
346
|
*/
|
|
294
|
-
async recordStatusMetrics(): Promise<void> {
|
|
347
|
+
async recordStatusMetrics(nodes: Record<string, any> = {}): Promise<void> {
|
|
295
348
|
const stats = await this.listQueues()
|
|
296
349
|
const totals = stats.reduce(
|
|
297
350
|
(acc, q) => {
|
|
@@ -328,6 +381,7 @@ export class QueueService {
|
|
|
328
381
|
this.alerts
|
|
329
382
|
.check({
|
|
330
383
|
queues: stats,
|
|
384
|
+
nodes: nodes as any,
|
|
331
385
|
workers,
|
|
332
386
|
totals,
|
|
333
387
|
})
|
|
@@ -403,7 +457,7 @@ export class QueueService {
|
|
|
403
457
|
}
|
|
404
458
|
} while (cursor !== '0')
|
|
405
459
|
|
|
406
|
-
return workers
|
|
460
|
+
return workers.sort((a, b) => a.id.localeCompare(b.id))
|
|
407
461
|
}
|
|
408
462
|
|
|
409
463
|
/**
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export interface PulseCpu {
|
|
2
|
+
system: number // Percentage 0-100
|
|
3
|
+
process: number // Percentage 0-100
|
|
4
|
+
cores: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PulseMemory {
|
|
8
|
+
system: {
|
|
9
|
+
total: number // bytes
|
|
10
|
+
free: number // bytes
|
|
11
|
+
used: number // bytes
|
|
12
|
+
}
|
|
13
|
+
process: {
|
|
14
|
+
rss: number // bytes
|
|
15
|
+
heapTotal: number // bytes
|
|
16
|
+
heapUsed: number // bytes
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PulseRuntime {
|
|
21
|
+
uptime: number // seconds
|
|
22
|
+
framework: string // e.g. "Node 20.1", "Laravel 10.0"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QueueSnapshot {
|
|
26
|
+
name: string
|
|
27
|
+
driver: 'redis' | 'sqs' | 'rabbitmq'
|
|
28
|
+
size: {
|
|
29
|
+
waiting: number
|
|
30
|
+
active: number
|
|
31
|
+
failed: number
|
|
32
|
+
delayed: number
|
|
33
|
+
}
|
|
34
|
+
throughput?: {
|
|
35
|
+
in: number // jobs/min
|
|
36
|
+
out: number // jobs/min
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PulseNode {
|
|
41
|
+
id: string // Unique Instance ID
|
|
42
|
+
service: string // Group name
|
|
43
|
+
language: 'node' | 'bun' | 'deno' | 'php' | 'go' | 'python' | 'other'
|
|
44
|
+
version: string // App Version
|
|
45
|
+
pid: number
|
|
46
|
+
hostname: string
|
|
47
|
+
platform: string
|
|
48
|
+
cpu: PulseCpu
|
|
49
|
+
memory: PulseMemory
|
|
50
|
+
queues?: QueueSnapshot[]
|
|
51
|
+
runtime: PulseRuntime
|
|
52
|
+
meta?: any // Extra metadata like Laravel workers
|
|
53
|
+
timestamp: number // Last heartbeat
|
|
54
|
+
}
|
|
55
|
+
export interface AlertRule {
|
|
56
|
+
id: string
|
|
57
|
+
name: string
|
|
58
|
+
type: 'backlog' | 'failure' | 'worker_lost' | 'node_cpu' | 'node_ram'
|
|
59
|
+
threshold: number
|
|
60
|
+
queue?: string // Optional: specific queue or all
|
|
61
|
+
cooldownMinutes: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AlertEvent {
|
|
65
|
+
ruleId: string
|
|
66
|
+
timestamp: number
|
|
67
|
+
message: string
|
|
68
|
+
severity: 'warning' | 'critical'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AlertConfig {
|
|
72
|
+
channels: {
|
|
73
|
+
slack?: {
|
|
74
|
+
enabled: boolean
|
|
75
|
+
webhookUrl: string
|
|
76
|
+
}
|
|
77
|
+
discord?: {
|
|
78
|
+
enabled: boolean
|
|
79
|
+
webhookUrl: string
|
|
80
|
+
}
|
|
81
|
+
email?: {
|
|
82
|
+
enabled: boolean
|
|
83
|
+
smtpHost: string
|
|
84
|
+
smtpPort: number
|
|
85
|
+
smtpUser: string
|
|
86
|
+
smtpPass: string
|
|
87
|
+
from: string
|
|
88
|
+
to: string // Comma separated list
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface MaintenanceConfig {
|
|
94
|
+
autoCleanup: boolean
|
|
95
|
+
retentionDays: number
|
|
96
|
+
lastRun?: number // Timestamp
|
|
97
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# Gravito Pulse Implementation Plan
|
|
2
|
-
**Version**: 1.1.0 (Beta Targeted)
|
|
3
|
-
**Status**: Active
|
|
4
|
-
**Target**: Zenith v1.0 Beta
|
|
5
|
-
|
|
6
|
-
This document outlines the implementation plan for the **System Pulse** and **Universal Queue Connector**, enabling Zenith to monitor not just Gravito Stream, but also Laravel, BullMQ, and other queue systems directly in the current Beta phase.
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## 🏗 Architecture Specifications
|
|
11
|
-
|
|
12
|
-
### 1. Redis Schema (Updated)
|
|
13
|
-
|
|
14
|
-
| Key Pattern | Type | TTL | Description |
|
|
15
|
-
| :--- | :--- | :--- | :--- |
|
|
16
|
-
| `pulse:server:{app}:{id}` | `String` (JSON) | 30s | **Heartbeat**. System resources (CPU/RAM). |
|
|
17
|
-
| `pulse:queues:{app}` | `String` (JSON) | 30s | **Queue Snapshot**. Metrics from external queues. |
|
|
18
|
-
| `pulse:slow:{app}` | `Stream` | MaxLen 1000 | **Slow Logs**. Validated heavy requests. |
|
|
19
|
-
|
|
20
|
-
### 2. Gravito Pulse Protocol (GPP) - Shared Types
|
|
21
|
-
|
|
22
|
-
```typescript
|
|
23
|
-
// System Heartbeat
|
|
24
|
-
interface PulseHeartbeat {
|
|
25
|
-
// ... (Existing fields)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Universal Queue Snapshot
|
|
29
|
-
interface QueueSnapshot {
|
|
30
|
-
timestamp: number;
|
|
31
|
-
app: string;
|
|
32
|
-
queues: Array<{
|
|
33
|
-
name: string;
|
|
34
|
-
driver: 'gravito-stream' | 'laravel-horizon' | 'bullmq' | 'sqs' | 'other';
|
|
35
|
-
metrics: {
|
|
36
|
-
waiting: number;
|
|
37
|
-
active?: number | null; // Optional (some drivers can't count active)
|
|
38
|
-
delayed?: number;
|
|
39
|
-
failed?: number;
|
|
40
|
-
};
|
|
41
|
-
meta?: Record<string, any>;
|
|
42
|
-
}>;
|
|
43
|
-
}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
## 📅 Implementation Phases (Beta Priority)
|
|
49
|
-
|
|
50
|
-
### Phase 1: Foundation (Protocol & Node SDK)
|
|
51
|
-
**Goal**: Define the standard so other languages can conform.
|
|
52
|
-
|
|
53
|
-
- [ ] **Task 1.1: Create `packages/pulse-protocol`**
|
|
54
|
-
- Define TypeScript interfaces for Heartbeat AND QueueSnapshot.
|
|
55
|
-
- Export Redis key constants.
|
|
56
|
-
|
|
57
|
-
- [ ] **Task 1.2: Create `packages/pulse-node`**
|
|
58
|
-
- Implement System Recorder (CPU/RAM).
|
|
59
|
-
|
|
60
|
-
### Phase 2: Universal Queue Adapters (v1.0 Beta Target)
|
|
61
|
-
**Goal**: Enable "One Dashboard, Any Queue".
|
|
62
|
-
|
|
63
|
-
- [ ] **Task 2.1: Laravel Adapter Specification (Concept)**
|
|
64
|
-
- Target: `gravito/zenith-laravel` (Composer package).
|
|
65
|
-
- Logic:
|
|
66
|
-
- Hook into Laravel Schedule.
|
|
67
|
-
- Run `Redis::llen` on queue lists or query `failed_jobs` table.
|
|
68
|
-
- Push JSON to `pulse:queues:{laravel_app}`.
|
|
69
|
-
- *Action*: Create a POC documentation/spec for PHP developers.
|
|
70
|
-
|
|
71
|
-
- [ ] **Task 2.2: BullMQ Adapter (Node.js)**
|
|
72
|
-
- Create `packages/adapter-bullmq`.
|
|
73
|
-
- Wrapper that accepts a BullMQ queue instance and auto-reports metrics to Zenith.
|
|
74
|
-
|
|
75
|
-
### Phase 3: Zenith Integration (Aggregated UI)
|
|
76
|
-
**Goal**: Visualize mixed queue sources.
|
|
77
|
-
|
|
78
|
-
- [ ] **Task 3.1: Backend Aggregation**
|
|
79
|
-
- `MonitorService` must now SCAN both `pulse:server:*` and `pulse:queues:*`.
|
|
80
|
-
- Stream consolidated data via SSE.
|
|
81
|
-
|
|
82
|
-
- [ ] **Task 3.2: Unified Queue Dashboard**
|
|
83
|
-
- Update `QueuesPage` to support "External Queues".
|
|
84
|
-
- External queues might be "Read-Only" initially (Metrics only, no Retry controls yet).
|
|
85
|
-
- Add visuals to distinguish Gravito Queues vs. External Queues.
|
|
86
|
-
|
|
87
|
-
---
|
|
88
|
-
|
|
89
|
-
## 🔮 Future Roadmap (v2.0)
|
|
90
|
-
|
|
91
|
-
### Phase 4: Full Application Monitoring (Pulse)
|
|
92
|
-
**Goal**: Catch the "bad" requests and visualize deep application health.
|
|
93
|
-
|
|
94
|
-
- [ ] **Task 4.1: Slow Request Interceptor**
|
|
95
|
-
- Add `httpMiddleware` to `pulse-node`.
|
|
96
|
-
- Tracks request start/end time.
|
|
97
|
-
- If > threshold (default 1s), XADD to `pulse:slow:{app}`.
|
|
98
|
-
|
|
99
|
-
- [ ] **Task 4.2: Exception Tracking**
|
|
100
|
-
- Aggregated 5xx error tracking via ZSET.
|
|
101
|
-
|
|
102
|
-
- [ ] **Task 4.3: Cross-Language SDKs**
|
|
103
|
-
- Python (Django/FastAPI) and Go Recorders.
|
|
104
|
-
- GPU Monitoring for AI workloads.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
## ⚡ Development Guidelines for External Adapters
|
|
110
|
-
1. **Passive Reporting**: Adapters should strictly REPORT data. They should not rely on Zenith for commands in V1.
|
|
111
|
-
2. **Fault Tolerance**: If Redis is down, the adapter must not crash the main application.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 224 71.4% 4.1%;--card: 0 0% 100%;--card-foreground: 224 71.4% 4.1%;--popover: 0 0% 100%;--popover-foreground: 224 71.4% 4.1%;--primary: 238.7 83.5% 66.7%;--primary-foreground: 210 20% 98%;--secondary: 220 14.3% 95.9%;--secondary-foreground: 238.7 83.5% 66.7%;--muted: 220 14.3% 95.9%;--muted-foreground: 220 8.9% 46.1%;--accent: 220 14.3% 95.9%;--accent-foreground: 238.7 83.5% 66.7%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 210 20% 98%;--border: 220 13% 91%;--input: 220 13% 91%;--ring: 238.7 83.5% 66.7%;--radius: 1rem}.dark{--background: 222 47% 2%;--foreground: 213 31% 91%;--card: 222 47% 6%;--card-foreground: 213 31% 91%;--popover: 222 47% 4%;--popover-foreground: 213 31% 91%;--primary: 238.7 83.5% 66.7%;--primary-foreground: 222 47% 4%;--secondary: 222 47% 10%;--secondary-foreground: 213 31% 91%;--muted: 222 47% 8%;--muted-foreground: 215.4 16.3% 56.9%;--accent: 222 47% 12%;--accent-foreground: 213 31% 91%;--destructive: 0 62.8% 30.6%;--destructive-foreground: 210 20% 98%;--border: 222 47% 14%;--input: 222 47% 12%;--ring: 238.7 83.5% 66.7%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-feature-settings:"ss01","ss02","cv01","cv02","cv03"}.dark body{background-image:radial-gradient(at 50% 0%,hsla(238,83%,66%,.05) 0%,transparent 50%),radial-gradient(at 100% 100%,hsla(238,83%,66%,.02) 0%,transparent 50%);background-attachment:fixed}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.-bottom-1{bottom:-.25rem}.-left-1\/4{left:-25%}.-right-1{right:-.25rem}.-right-1\/4{right:-25%}.bottom-0{bottom:0}.bottom-1\/4{bottom:25%}.bottom-8{bottom:2rem}.bottom-full{bottom:100%}.left-0{left:0}.left-1{left:.25rem}.left-1\/2{left:50%}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-4{right:1rem}.right-8{right:2rem}.top-0{top:0}.top-1\.5{top:.375rem}.top-1\/2{top:50%}.top-1\/4{top:25%}.top-4{top:1rem}.top-full{top:100%}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[1001\]{z-index:1001}.z-\[1002\]{z-index:1002}.z-\[100\]{z-index:100}.z-\[2000\]{z-index:2000}.z-\[4000\]{z-index:4000}.z-\[5000\]{z-index:5000}.col-span-full{grid-column:1 / -1}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-square{aspect-ratio:1 / 1}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[300px\]{height:300px}.h-\[350px\]{height:350px}.h-\[600px\]{height:600px}.h-\[85vh\]{height:85vh}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[400px\]{max-height:400px}.max-h-\[850px\]{max-height:850px}.min-h-0{min-height:0px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-72{width:18rem}.w-8{width:2rem}.w-96{width:24rem}.w-\[1px\]{width:1px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0px}.min-w-\[140px\]{min-width:140px}.min-w-\[16px\]{min-width:16px}.min-w-\[200px\]{min-width:200px}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-6xl{max-width:72rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-left{transform-origin:left}.-translate-x-1{--tw-translate-x: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1{--tw-translate-y: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-1\/2{--tw-translate-x: 50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-110{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-\[1\.02\]{--tw-scale-x: 1.02;--tw-scale-y: 1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-12>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(3rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(3rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-border\/10>:not([hidden])~:not([hidden]){border-color:hsl(var(--border) / .1)}.divide-border\/30>:not([hidden])~:not([hidden]){border-color:hsl(var(--border) / .3)}.divide-border\/50>:not([hidden])~:not([hidden]){border-color:hsl(var(--border) / .5)}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:.75rem}.rounded-r-full{border-top-right-radius:9999px;border-bottom-right-radius:9999px}.rounded-t-lg{border-top-left-radius:var(--radius);border-top-right-radius:var(--radius)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-amber-500{--tw-border-opacity: 1;border-color:rgb(245 158 11 / var(--tw-border-opacity, 1))}.border-amber-500\/30{border-color:#f59e0b4d}.border-background{border-color:hsl(var(--background))}.border-blue-500{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.border-border{border-color:hsl(var(--border))}.border-border\/10{border-color:hsl(var(--border) / .1)}.border-border\/30{border-color:hsl(var(--border) / .3)}.border-border\/40{border-color:hsl(var(--border) / .4)}.border-border\/5{border-color:hsl(var(--border) / .05)}.border-border\/50{border-color:hsl(var(--border) / .5)}.border-card{border-color:hsl(var(--card))}.border-green-500{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.border-green-500\/20{border-color:#22c55e33}.border-indigo-500{--tw-border-opacity: 1;border-color:rgb(99 102 241 / var(--tw-border-opacity, 1))}.border-primary{border-color:hsl(var(--primary))}.border-primary-foreground\/30{border-color:hsl(var(--primary-foreground) / .3)}.border-primary\/10{border-color:hsl(var(--primary) / .1)}.border-primary\/20{border-color:hsl(var(--primary) / .2)}.border-primary\/50{border-color:hsl(var(--primary) / .5)}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-500\/10{border-color:#ef44441a}.border-red-500\/20{border-color:#ef444433}.border-red-600{--tw-border-opacity: 1;border-color:rgb(220 38 38 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.border-yellow-500\/20{border-color:#eab30833}.border-t-primary-foreground{border-top-color:hsl(var(--primary-foreground))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/20{background-color:#f59e0b33}.bg-amber-500\/5{background-color:#f59e0b0d}.bg-background{background-color:hsl(var(--background))}.bg-black\/20{background-color:#0003}.bg-black\/60{background-color:#0009}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/20{background-color:#3b82f633}.bg-border\/30{background-color:hsl(var(--border) / .3)}.bg-border\/50{background-color:hsl(var(--border) / .5)}.bg-card{background-color:hsl(var(--card))}.bg-card\/50{background-color:hsl(var(--card) / .5)}.bg-card\/80{background-color:hsl(var(--card) / .8)}.bg-current{background-color:currentColor}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-500\/20{background-color:#22c55e33}.bg-green-500\/40{background-color:#22c55e66}.bg-indigo-500{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.bg-indigo-500\/10{background-color:#6366f11a}.bg-muted{background-color:hsl(var(--muted))}.bg-muted-foreground{background-color:hsl(var(--muted-foreground))}.bg-muted-foreground\/30{background-color:hsl(var(--muted-foreground) / .3)}.bg-muted-foreground\/40{background-color:hsl(var(--muted-foreground) / .4)}.bg-muted\/10{background-color:hsl(var(--muted) / .1)}.bg-muted\/20{background-color:hsl(var(--muted) / .2)}.bg-muted\/30{background-color:hsl(var(--muted) / .3)}.bg-muted\/40{background-color:hsl(var(--muted) / .4)}.bg-muted\/5{background-color:hsl(var(--muted) / .05)}.bg-muted\/50{background-color:hsl(var(--muted) / .5)}.bg-orange-500\/10{background-color:#f973161a}.bg-popover{background-color:hsl(var(--popover))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary-foreground{background-color:hsl(var(--primary-foreground))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/40{background-color:hsl(var(--primary) / .4)}.bg-primary\/5{background-color:hsl(var(--primary) / .05)}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/20{background-color:#ef444433}.bg-red-500\/5{background-color:#ef44440d}.bg-transparent{background-color:transparent}.bg-white\/10{background-color:#ffffff1a}.bg-white\/20{background-color:#fff3}.bg-yellow-500\/10{background-color:#eab3081a}.bg-\[linear-gradient\(rgba\(var\(--primary-rgb\)\,0\.03\)_1px\,transparent_1px\)\,linear-gradient\(90deg\,rgba\(var\(--primary-rgb\)\,0\.03\)_1px\,transparent_1px\)\]{background-image:linear-gradient(rgba(var(--primary-rgb),.03) 1px,transparent 1px),linear-gradient(90deg,rgba(var(--primary-rgb),.03) 1px,transparent 1px)}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-card{--tw-gradient-from: hsl(var(--card)) var(--tw-gradient-from-position);--tw-gradient-to: hsl(var(--card) / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-primary{--tw-gradient-from: hsl(var(--primary)) var(--tw-gradient-from-position);--tw-gradient-to: hsl(var(--primary) / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-primary\/10{--tw-gradient-from: hsl(var(--primary) / .1) var(--tw-gradient-from-position);--tw-gradient-to: hsl(var(--primary) / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-primary\/5{--tw-gradient-from: hsl(var(--primary) / .05) var(--tw-gradient-from-position);--tw-gradient-to: hsl(var(--primary) / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-card{--tw-gradient-to: hsl(var(--card) / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), hsl(var(--card)) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-transparent{--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), transparent var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-indigo-500\/10{--tw-gradient-to: rgb(99 102 241 / .1) var(--tw-gradient-to-position)}.to-indigo-500\/5{--tw-gradient-to: rgb(99 102 241 / .05) var(--tw-gradient-to-position)}.to-indigo-600{--tw-gradient-to: #4f46e5 var(--tw-gradient-to-position)}.to-primary\/60{--tw-gradient-to: hsl(var(--primary) / .6) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.bg-\[size\:64px_64px\]{background-size:64px 64px}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.fill-current{fill:currentColor}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-20{padding:5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-0\.5{padding-left:.125rem;padding-right:.125rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pl-10{padding-left:2.5rem}.pl-12{padding-left:3rem}.pl-4{padding-left:1rem}.pl-9{padding-left:2.25rem}.pr-12{padding-right:3rem}.pr-4{padding-right:1rem}.pt-1{padding-top:.25rem}.pt-1\.5{padding-top:.375rem}.pt-24{padding-top:6rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.tracking-\[0\.1em\]{letter-spacing:.1em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-\[0\.4em\]{letter-spacing:.4em}.tracking-tight{letter-spacing:-.025em}.tracking-tighter{letter-spacing:-.05em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-foreground\/80{color:hsl(var(--foreground) / .8)}.text-foreground\/90{color:hsl(var(--foreground) / .9)}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-indigo-400{--tw-text-opacity: 1;color:rgb(129 140 248 / var(--tw-text-opacity, 1))}.text-indigo-400\/80{color:#818cf8cc}.text-indigo-500{--tw-text-opacity: 1;color:rgb(99 102 241 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/20{color:hsl(var(--muted-foreground) / .2)}.text-muted-foreground\/30{color:hsl(var(--muted-foreground) / .3)}.text-muted-foreground\/40{color:hsl(var(--muted-foreground) / .4)}.text-muted-foreground\/50{color:hsl(var(--muted-foreground) / .5)}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-orange-500{--tw-text-opacity: 1;color:rgb(249 115 22 / var(--tw-text-opacity, 1))}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-primary\/40{color:hsl(var(--primary) / .4)}.text-primary\/60{color:hsl(var(--primary) / .6)}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-500\/60{color:#ef444499}.text-red-900\/60{color:#7f1d1d99}.text-transparent{color:transparent}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/80{color:#fffc}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.opacity-0{opacity:0}.opacity-10{opacity:.1}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-5{opacity:.05}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(34\,197\,94\,0\.6\)\]{--tw-shadow: 0 0 12px rgba(34,197,94,.6);--tw-shadow-colored: 0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_20px_rgba\(var\(--primary-rgb\)\,0\.1\)\]{--tw-shadow: 0 0 20px rgba(var(--primary-rgb),.1);--tw-shadow-colored: 0 0 20px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(34\,197\,94\,0\.4\)\]{--tw-shadow: 0 0 8px rgba(34,197,94,.4);--tw-shadow-colored: 0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(34\,197\,94\,0\.6\)\]{--tw-shadow: 0 0 8px rgba(34,197,94,.6);--tw-shadow-colored: 0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-inner{--tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / .05);--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-amber-500\/20{--tw-shadow-color: rgb(245 158 11 / .2);--tw-shadow: var(--tw-shadow-colored)}.shadow-indigo-500\/20{--tw-shadow-color: rgb(99 102 241 / .2);--tw-shadow: var(--tw-shadow-colored)}.shadow-primary\/20{--tw-shadow-color: hsl(var(--primary) / .2);--tw-shadow: var(--tw-shadow-colored)}.shadow-primary\/30{--tw-shadow-color: hsl(var(--primary) / .3);--tw-shadow: var(--tw-shadow-colored)}.shadow-red-500\/20{--tw-shadow-color: rgb(239 68 68 / .2);--tw-shadow: var(--tw-shadow-colored)}.shadow-transparent{--tw-shadow-color: transparent;--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-primary{--tw-ring-color: hsl(var(--primary))}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur: blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur: blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.duration-700{transition-duration:.7s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.scrollbar-thin::-webkit-scrollbar{width:6px;height:6px}.scrollbar-thin::-webkit-scrollbar-track{background:transparent}.scrollbar-thin::-webkit-scrollbar-thumb{border-radius:9999px;background-color:hsl(var(--muted-foreground) / .2);-webkit-transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.scrollbar-thin::-webkit-scrollbar-thumb:hover{background-color:hsl(var(--muted-foreground) / .4)}.scanline{position:relative;overflow:hidden}.scanline:after{content:" ";display:block;position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(to bottom,transparent 50%,rgba(var(--primary),.02) 50%);background-size:100% 4px;z-index:2;pointer-events:none;animation:scanline-move 10s linear infinite}@keyframes scanline-move{0%{background-position:0 0}to{background-position:0 100%}}.glow-pulse{animation:glow-pulse 2.5s ease-in-out infinite}@keyframes glow-pulse{0%,to{opacity:.3;transform:scale(1);filter:blur(2px)}50%{opacity:.7;transform:scale(1.5);filter:blur(4px)}}.card-premium{border-width:1px;border-color:hsl(var(--border) / .5);background-color:hsl(var(--card));--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s}.dark .card-premium{background-color:#080c1699;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#ffffff0d;box-shadow:0 4px 20px #00000080}.dark .card-premium:hover{border-color:#6065f033;box-shadow:0 8px 30px #6366f10d}.animate-in{animation-duration:.4s;animation-timing-function:cubic-bezier(.16,1,.3,1);fill-mode:forwards}@keyframes toast-progress{0%{transform:scaleX(1)}to{transform:scaleX(0)}}.animate-toast-progress{animation:toast-progress 5s linear forwards}.placeholder\:text-muted-foreground\/30::-moz-placeholder{color:hsl(var(--muted-foreground) / .3)}.placeholder\:text-muted-foreground\/30::placeholder{color:hsl(var(--muted-foreground) / .3)}.placeholder\:text-muted-foreground\/40::-moz-placeholder{color:hsl(var(--muted-foreground) / .4)}.placeholder\:text-muted-foreground\/40::placeholder{color:hsl(var(--muted-foreground) / .4)}.last\:hidden:last-child{display:none}.hover\:-translate-y-2:hover{--tw-translate-y: -.5rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-125:hover{--tw-scale-x: 1.25;--tw-scale-y: 1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-\[1\.02\]:hover{--tw-scale-x: 1.02;--tw-scale-y: 1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-primary\/20:hover{border-color:hsl(var(--primary) / .2)}.hover\:border-primary\/30:hover{border-color:hsl(var(--primary) / .3)}.hover\:border-primary\/50:hover{border-color:hsl(var(--primary) / .5)}.hover\:bg-amber-500:hover{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.hover\:bg-amber-500\/10:hover{background-color:#f59e0b1a}.hover\:bg-amber-600:hover{--tw-bg-opacity: 1;background-color:rgb(217 119 6 / var(--tw-bg-opacity, 1))}.hover\:bg-background:hover{background-color:hsl(var(--background))}.hover\:bg-blue-500\/10:hover{background-color:#3b82f61a}.hover\:bg-blue-600:hover{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.hover\:bg-green-500\/10:hover{background-color:#22c55e1a}.hover\:bg-muted:hover{background-color:hsl(var(--muted))}.hover\:bg-muted\/20:hover{background-color:hsl(var(--muted) / .2)}.hover\:bg-muted\/30:hover{background-color:hsl(var(--muted) / .3)}.hover\:bg-muted\/5:hover{background-color:hsl(var(--muted) / .05)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-muted\/60:hover{background-color:hsl(var(--muted) / .6)}.hover\:bg-muted\/80:hover{background-color:hsl(var(--muted) / .8)}.hover\:bg-primary:hover{background-color:hsl(var(--primary))}.hover\:bg-primary\/10:hover{background-color:hsl(var(--primary) / .1)}.hover\:bg-primary\/20:hover{background-color:hsl(var(--primary) / .2)}.hover\:bg-primary\/5:hover{background-color:hsl(var(--primary) / .05)}.hover\:bg-primary\/\[0\.02\]:hover{background-color:hsl(var(--primary) / .02)}.hover\:bg-red-500:hover{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.hover\:bg-red-500\/10:hover{background-color:#ef44441a}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:bg-white\/10:hover{background-color:#ffffff1a}.hover\:bg-white\/\[0\.02\]:hover{background-color:#ffffff05}.hover\:text-amber-500:hover{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:text-primary:hover{color:hsl(var(--primary))}.hover\:text-primary-foreground:hover{color:hsl(var(--primary-foreground))}.hover\:text-red-500:hover{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-2xl:hover{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-primary\/20:hover{--tw-shadow-color: hsl(var(--primary) / .2);--tw-shadow: var(--tw-shadow-colored)}.hover\:shadow-primary\/40:hover{--tw-shadow-color: hsl(var(--primary) / .4);--tw-shadow: var(--tw-shadow-colored)}.focus\:border-primary\/50:focus{border-color:hsl(var(--primary) / .5)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-inset:focus{--tw-ring-inset: inset}.focus\:ring-primary:focus{--tw-ring-color: hsl(var(--primary))}.focus\:ring-primary\/20:focus{--tw-ring-color: hsl(var(--primary) / .2)}.focus\:ring-primary\/30:focus{--tw-ring-color: hsl(var(--primary) / .3)}.active\:scale-90:active{--tw-scale-x: .9;--tw-scale-y: .9;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-95:active{--tw-scale-x: .95;--tw-scale-y: .95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:hover\:scale-100:hover:disabled{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:rotate-12{--tw-rotate: 12deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:rotate-90{--tw-rotate: 90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:rotate-\[15deg\]{--tw-rotate: 15deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/item:hover .group-hover\/item\:scale-110{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x: 1.05;--tw-scale-y: 1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-110{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-150{--tw-scale-x: 1.5;--tw-scale-y: 1.5;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:border-border\/10{border-color:hsl(var(--border) / .1)}.group:hover .group-hover\:border-primary\/20{border-color:hsl(var(--primary) / .2)}.group:hover .group-hover\:bg-primary\/10{background-color:hsl(var(--primary) / .1)}.group:hover .group-hover\:bg-primary\/20{background-color:hsl(var(--primary) / .2)}.group:hover .group-hover\:text-foreground{color:hsl(var(--foreground))}.group:hover .group-hover\:text-primary{color:hsl(var(--primary))}.group\/tile:hover .group-hover\/tile\:opacity-100,.group:hover .group-hover\:opacity-100{opacity:1}.group:hover .group-hover\:opacity-20{opacity:.2}@media(min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}}@media(min-width:768px){.md\:col-span-2{grid-column:span 2 / span 2}.md\:inline{display:inline}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:1024px){.lg\:col-span-1{grid-column:span 1 / span 1}.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:inline{display:inline}.lg\:flex{display:flex}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(prefers-color-scheme:dark){.dark\:text-red-400\/60{color:#f8717199}}
|