Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-09 23:44:05 +08:00
commit b03707a794
47 changed files with 4084 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
---
description: System Dashboard and Monitoring Maintenance
---
# Dashboard and Monitoring Workflow
This workflow describes how to maintain and extend the System Load dashboard and the underlying statistics API.
## Architecture
1. **Backend Statistics API**:
- File: `apps/server/src/api.ts`
- Endpoint: `GET /api/stats` (Requires Admin)
- Logic: Aggregates `alert_tasks` and `alert_logs` using Drizzle ORM.
- Key Metrics:
- `alertsReceived`: Total task count.
- `plannedMessages`: Sum of `recipientCount` across tasks.
- `successCount`: Sum of `successCount`.
- `failedCount`: calculated as `plannedMessages - successCount`.
2. **Frontend Dashboard**:
- File: `apps/web/src/views/SystemLoadView.tsx`
- Component: `SystemLoadView`
- Features:
- 10s auto-refresh interval.
- SVG-based custom Gauges for success rate.
- Metric cards for immediate feedback.
- Live status indicator with breathing animation.
- **Audit Log**: A detailed table showing recent alert tasks, capturing the timestamp, topic, associated **Sender** (via personal token), and success counts.
## Common Tasks
### 1. Extending Metrics
To add a new metric (e.g., average delivery time):
1. Update `apps/server/src/api.ts` in the `/stats` handler.
2. Add the new fields to the query using Drizzle's `avg`, `min`, `max`, etc.
3. Update the `Stats` interface in `apps/web/src/views/SystemLoadView.tsx`.
4. Add a new `MetricCard` or a column to the table.
### 2. Tuning Refresh Rates
If the 10s refresh is too aggressive:
- Change the interval in `useEffect` within `SystemLoadView.tsx`.
- Balance between "real-time feel" and server load.
### 3. Debugging Type Erasure Issues
When working with Hono's RPC client (`hc`):
- The client in `apps/web/src/lib/client.ts` is currently cast as `any` to avoid complex type inference circularities.
- If adding new endpoints, ensure they are correctly routed in `apps/server/src/index.ts` so they appear in `AppType`.
## Performance Considerations
- The `/stats` endpoint uses standard SQL aggregations.
- For extremely large datasets, consider adding an index on `alert_tasks(created_at, topic_slug)`.
- The dashboard is restricted to Admins to minimize the impact of frequent polling.

View File

@@ -0,0 +1,54 @@
---
description: Topic Request Lifecycle and User Feedback
---
# Topic Request Workflow
This workflow describes the lifecycle of a Topic creation request, from submission by a normal user to approval/rejection by an admin, and how users track their requests.
## Architecture
1. **Backend API**:
- **Submission**: `POST /api/topics` (Authenticated users).
- If `isAdmin`, status is `approved`.
- If normal user, status is `pending`.
- **Global Admin View**: `GET /api/topics/requests` (Admin only).
- **User Personal View**: `GET /api/topics/my-requests` (Authenticated users).
- Retrieves all topics where `createdBy` matches the current session user ID.
- **Approval**: `POST /api/topics/:id/approve` (Admin only).
- **Rejection**: `POST /api/topics/:id/reject` (Admin only).
2. **Frontend Implementation**:
- File: `apps/web/src/views/TopicsView.tsx`
- **Submission Modal**:
- Uses `submitStatus` state to provide immediate success/error feedback.
- Automatically closes and refreshes after 1.5s on success.
- **My Requests Table**:
- Rendered below the main topic list.
- Uses color-coded badges for status:
- `Approved`: Green.
- `Rejected`: Red.
- `Pending`: Yellow.
## Common Maintenance Tasks
### 1. Adding/Removing Request Fields
If a new field is needed (e.g., "Justification"):
1. Update `topics` table in `apps/server/src/db/schema.ts`.
2. Update `topicSchema` in `apps/server/src/api.ts`.
3. Update the form in `TopicsView.tsx`.
4. Ensure the field is displayed in both `AdminView` and `TopicsView` (personal requests).
### 2. Customizing Feedback Messages
Feedback messages are managed in the `handleSubmit` function in `TopicsView.tsx`. Update the `message` property in `setSubmitStatus` to change the user-facing wording.
### 3. Handling Rejection Comments (Future)
To add a reason for rejection:
1. Add a `rejectionReason` column to the `topics` table.
2. Modify the `AdminView.tsx` to prompt for a reason during rejection.
3. Update `POST /api/topics/:id/reject` to accept this reason.
4. Display the reason in the `My Requests` section of `TopicsView.tsx`.
## Design Decisions
- **Separate Personal Requests**: We show personal requests in a dedicated section to avoid cluttering the primary Topic list, which only shows `approved` topics that can actually be subscribed to.
- **Optimistic UI vs. Simple Refresh**: Currently, we use a full refresh (`fetchTopics`, `fetchMyRequests`) for simplicity and data consistency.

View File

@@ -0,0 +1,14 @@
---
description: Update project context documentation for AI/IDE awareness
---
// turbo-all
1. Check recent file changes to identify new features, API changes, or configuration updates.
2. Read `docs/copilot-context.md` to understand the current documented state.
3. Update `docs/copilot-context.md` with:
- New or modified entities in the data model.
- Added or changed API endpoints.
- New key workflows or architectural decisions.
- Updated development conventions or environment variables.
4. If `todo.md` exists, ensure it is also updated to reflect completed tasks.
5. Summarize the updates made to the context for the user.

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
build
dist
out
# Misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor
.idea
.vscode
*.swp
*.swo
# Database
*.sqlite
*.db
apps/server/drizzle/meta
# Bun
.bun/
bun.lockb

136
README.md Normal file
View File

@@ -0,0 +1,136 @@
# Alert Message Center
这是一个基于 **Bun**, **Hono**, **Drizzle ORM (SQLite)****React (Vite + Tailwind)** 构建的企业级告警管理系统。
它采用 **Topic (主题)** 订阅模型,类似于 Sentry 的告警分发机制。告警发送到特定的 Topic系统根据订阅关系通过 **飞书机器人私聊 (Private Message)** 将告警精准推送给订阅该 Topic 的用户。
## 核心理念
- **Topic (主题)**: 业务逻辑上的告警分类,例如 `payment-service-error`, `frontend-performance`, `daily-report`
- **User (用户)**: 接收告警的实体,绑定飞书 User ID。
- **Subscription (订阅)**: 用户订阅感兴趣的 Topic。
- **Private Message (私聊)**: 告警不再发送到嘈杂的群组,而是直接私聊发送给相关负责人,确保触达。
## 功能特性
- **精准分发**: 告警只发给订阅的人,避免群消息轰炸。
- **集中管理**: 统一管理所有告警入口和订阅关系,无需维护大量硬编码的 Webhook URL。
- **飞书集成**: 使用飞书开放平台 API支持发送富文本和卡片消息。
- **全局监控**: 提供 Grafana 风格的系统负载看板,实时追踪告警接收数、应发消息数及发送成功率。
- **现代化技术栈**: 全栈 TypeScript高性能 Bun 运行时。
## 快速开始
### 1. 前置准备
你需要创建一个飞书企业自建应用 (Custom App)
1. 访问 [飞书开发者后台](https://open.feishu.cn/app)。
2. 创建企业自建应用。
3. **启用机器人能力**: 在 "添加应用能力" -> "机器人" 中启用。
4. **申请权限**: 在 "权限管理" 中申请以下权限:
- `im:message` (获取与发送单聊、群组消息)
- `im:message:send_as_bot` (以应用身份发送消息)
- (可选) `contact:user.id:readonly` (通过手机号或邮箱获取用户 ID)
5. **发布版本**: 创建版本并发布,等待管理员审核通过。
6. 获取 **App ID** and **App Secret**
### 2. 安装依赖
确保你已经安装了 [Bun](https://bun.sh/)。
```bash
# 在根目录运行
bun install
```
### 3. 配置环境变量
`apps/server` 目录下 (或者在启动命令中) 配置环境变量:
```bash
export FEISHU_APP_ID="你的AppID"
export FEISHU_APP_SECRET="你的AppSecret"
```
### 4. 启动开发环境
这将同时启动后端 API (端口 3000) 和前端界面 (端口 5173)。
```bash
bun run dev
```
访问前端界面: [http://localhost:5173](http://localhost:5173)
### 5. 使用指南
#### 第一步:配置 Topic
1. 进入 **Topics** 页面,点击 "Add Topic"。
2. 填写 Name (显示名) 和 Slug (唯一标识,用于 URL)。
* 例如: Name: "支付服务异常", Slug: `payment-error`
#### 第二步:添加用户
1. 进入 **Users** 页面,添加用户。
2. 必须填入用户的 **飞书 User ID** (Open ID 或 User ID)。
* *提示: 可以在飞书管理后台查看用户的 User ID或者通过 API 获取。*
#### 第三步:订阅告警
1. 回到 **Topics** 页面。
2. 点击 Topic 卡片上的 **订阅图标** (用户组图标)。
3. 勾选需要接收该 Topic 告警的用户。
#### 第四步:发送告警
使用你的程序或脚本向系统发送 POST 请求:
**POST** `http://localhost:3000/api/webhook/:slug`
示例 (curl):
```bash
curl -X POST http://localhost:3000/api/webhook/payment-error \
-H "Content-Type: application/json" \
-d '{
"msg_type": "text",
"content": {
"text": "支付接口响应超时 (500ms)"
}
}'
```
系统会查找订阅了 `payment-error` 的所有用户,并通过飞书机器人给他们分别发送私聊消息。
## API 参考
### 发送告警
`POST /api/webhook/:slug`
**Parameters:**
- `slug`: Topic 的唯一标识符。
**Body:**
直接透传给飞书消息 API 的 `content``msg_type`
1. **文本消息**:
```json
{
"msg_type": "text",
"content": {
"text": "告警内容..."
}
}
```
2. **富文本/卡片消息**:
请参考 [飞书发送消息 API 文档](https://open.feishu.cn/document/server-docs/im-v1/message/create) 构建 `content`
## 项目结构
* `apps/server`: 后端服务 (Hono + Drizzle)
* `src/index.ts`: 入口文件
* `src/feishu.ts`: 飞书 API 客户端
* `src/webhook.ts`: 告警处理与分发逻辑
* `src/db`: 数据库 Schema (Topics, Users, Subscriptions)
* `apps/web`: 前端界面 (React + Vite)
* `src/views/SystemLoadView.tsx`: 实时监控仪表盘
* `src/views/AdminView.tsx`: 后台管理与仪表盘集成

7
apps/server/.env.example Normal file
View File

@@ -0,0 +1,7 @@
DATABASE_URL=postgres://postgres:password@localhost:5432/alert_message_center
# Feishu Config
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_VERIFICATION_TOKEN=
FEISHU_ENCRYPT_KEY=
ADMIN_EMAILS=

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center',
},
});

View File

@@ -0,0 +1,52 @@
CREATE TABLE "alert_logs" (
"id" text PRIMARY KEY NOT NULL,
"task_id" text NOT NULL,
"user_id" text,
"status" text NOT NULL,
"error" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "alert_tasks" (
"id" text PRIMARY KEY NOT NULL,
"topic_slug" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"recipient_count" integer DEFAULT 0,
"success_count" integer DEFAULT 0,
"payload" jsonb,
"error" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "subscriptions" (
"user_id" text NOT NULL,
"topic_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "subscriptions_user_id_topic_id_pk" PRIMARY KEY("user_id","topic_id")
);
--> statement-breakpoint
CREATE TABLE "topics" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"description" text,
"status" text DEFAULT 'approved' NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "topics_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"feishu_user_id" text NOT NULL,
"email" text,
"is_admin" boolean DEFAULT false,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "alert_logs" ADD CONSTRAINT "alert_logs_task_id_alert_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."alert_tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "topics" ADD CONSTRAINT "topics_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

24
apps/server/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@alertmessagecenter/server",
"version": "1.0.0",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"drizzle-orm": "^0.45.1",
"hono": "^4.11.3",
"postgres": "^3.4.8",
"zod": "^3.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"bun-types": "latest",
"drizzle-kit": "^0.31.8"
}
}

View File

@@ -0,0 +1,27 @@
// Simulate admin checking requests
async function run() {
console.log('Fetching pending topics as admin...');
const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim();
const res = await fetch('http://localhost:3000/api/topics/requests', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Cookie': `session=${encodeURIComponent(JSON.stringify({
id: 'admin_123',
name: 'Admin User',
email: adminEmail,
isAdmin: true
}))}`
}
});
if (res.ok) {
const data = await res.json();
console.log('Pending topics:', JSON.stringify(data, null, 2));
} else {
console.log('Error:', res.status, await res.text());
}
}
run();

View File

@@ -0,0 +1,27 @@
async function run() {
console.log('Fetching dashboard stats as admin...');
const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim();
const res = await fetch('http://localhost:3000/api/dashboard/stats', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// Admin cookie
'Cookie': `session=${encodeURIComponent(JSON.stringify({
id: 'admin_123',
name: 'Admin User',
email: adminEmail,
isAdmin: true
}))}`
}
});
if (res.ok) {
const data = await res.json();
console.log('Dashboard Stats:', JSON.stringify(data, null, 2));
} else {
console.log('Error:', res.status, await res.text());
}
}
run();

View File

@@ -0,0 +1,9 @@
import { Database } from 'bun:sqlite';
const db = new Database('dev.db');
try {
const query = db.query("SELECT * FROM topics");
const topics = query.all();
console.log('Topics:', JSON.stringify(topics, null, 2));
} catch (e) {
console.error('Error querying topics:', e);
}

View File

@@ -0,0 +1,40 @@
// Simulate topic creation
import { client } from './client'; // This won't work in node script easily due to frontend dependencies
// Let's use fetch directly against the server
async function run() {
console.log('Creating pending topic...');
const res = await fetch('http://localhost:3000/api/topics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// We need to bake a cookie.
// But we can't easily bake a signed cookie without the secret.
// Wait, the cookies are not signed in the strict sense, just set.
// But `middleware.ts` parses `JSON.parse(sessionCookie)`.
// Let's fake a session cookie for a non-admin user.
'Cookie': `session=${encodeURIComponent(JSON.stringify({
id: 'user_123',
name: 'Test User',
email: 'test@example.com',
isAdmin: false
}))}`
},
body: JSON.stringify({
name: 'Test Pending Topic',
slug: 'test-pending',
description: 'This should be pending'
})
});
if (res.ok) {
const data = await res.json();
console.log('Created topic:', data);
} else {
console.log('Error:', res.status, await res.text());
}
}
run();

View File

@@ -0,0 +1,69 @@
import postgres from 'postgres';
const sql = postgres('postgres://localhost:5432/alertmessagecenter');
async function run() {
try {
// 1. Get a topic
const [topic] = await sql`SELECT * FROM topics LIMIT 1`;
if (!topic) {
console.log('No topics found. Create a topic first.');
return;
}
console.log('Using topic:', topic.id, topic.slug);
// 2. Define a fake user ID
const fakeUserId = 'user_fake_002';
// Clean up first
await sql`DELETE FROM subscriptions WHERE user_id = ${fakeUserId}`;
await sql`DELETE FROM users WHERE id = ${fakeUserId}`;
// 3. Try to subscribe with non-existent user
console.log('\n--- Attempt 1: Subscribe with non-existent user ---');
const res1 = await fetch(`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`, {
method: 'POST',
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify({
id: fakeUserId,
name: 'Fake User',
email: 'fake@example.com',
isAdmin: false
}))}`
}
});
console.log('Status:', res1.status);
const text1 = await res1.text();
console.log('Response:', text1); // Expect 500 FK violation
// 4. Create the user
console.log('\n--- Creating user... ---');
await sql`INSERT INTO users (id, name, feishu_user_id, email, is_admin)
VALUES (${fakeUserId}, 'Fake User', 'ou_fake', 'fake2@example.com', false)
ON CONFLICT (id) DO NOTHING`;
// 5. Try to subscribe again
console.log('\n--- Attempt 2: Subscribe with existing user ---');
const res2 = await fetch(`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`, {
method: 'POST',
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify({
id: fakeUserId,
name: 'Fake User',
email: 'fake@example.com',
isAdmin: false
}))}`
}
});
console.log('Status:', res2.status);
const text2 = await res2.text();
console.log('Response:', text2); // Expect 200
} catch (e) {
console.error(e);
} finally {
await sql.end();
}
}
run();

270
apps/server/src/api.ts Normal file
View File

@@ -0,0 +1,270 @@
import { Hono } from 'hono';
import { eq, and, desc, sql, gt, sum, count } from 'drizzle-orm';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { db } from './db';
import { topics, users, subscriptions, alertTasks } from './db/schema';
import { requireAuth, requireAdmin, AuthSession } from './middleware';
const api = new Hono<{ Variables: { session: AuthSession } }>();
const topicSchema = z.object({
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().optional(),
});
const userSchema = z.object({
name: z.string().min(1),
feishuUserId: z.string().min(1),
email: z.string().email().optional().or(z.literal('')),
});
// --- Topics ---
// --- Topics ---
api.get('/topics', requireAuth, async (c) => {
const session = c.get('session');
const isAdmin = session.isAdmin;
const currentUserId = session.id;
const allTopics = await db.query.topics.findMany({
where: eq(topics.status, 'approved'),
with: {
subscriptions: {
where: (subscriptions, { eq }) =>
isAdmin ? undefined : (currentUserId ? eq(subscriptions.userId, currentUserId) : undefined),
with: {
user: true
}
}
}
});
return c.json(allTopics);
});
api.get('/topics/requests', requireAdmin, async (c) => {
const requests = await db.query.topics.findMany({
where: eq(topics.status, 'pending'),
with: {
creator: true
}
});
return c.json(requests);
});
api.get('/topics/all', requireAdmin, async (c) => {
const allTopics = await db.query.topics.findMany({
with: {
creator: true,
subscriptions: true
},
orderBy: [desc(topics.createdAt)]
});
return c.json(allTopics);
});
api.get('/topics/my-requests', requireAuth, async (c) => {
const session = c.get('session');
const requests = await db.query.topics.findMany({
where: eq(topics.createdBy, session.id),
orderBy: [desc(topics.createdAt)],
});
return c.json(requests);
});
api.post('/topics/:id/approve', requireAdmin, async (c) => {
const id = c.req.param('id');
const result = await db.update(topics)
.set({ status: 'approved' })
.where(eq(topics.id, id))
.returning();
return c.json(result[0]);
});
api.post('/topics/:id/reject', requireAdmin, async (c) => {
const id = c.req.param('id');
const result = await db.update(topics)
.set({ status: 'rejected' })
.where(eq(topics.id, id))
.returning();
return c.json(result[0]);
});
// Only admins can create topics
// Authenticated users can create topics (requests)
api.post('/topics', requireAuth, zValidator('json', topicSchema), async (c) => {
const body = c.req.valid('json');
const session = c.get('session');
const status = session.isAdmin ? 'approved' : 'pending';
const result = await db.insert(topics).values({
...body,
status,
createdBy: session.id,
}).returning();
return c.json(result[0]);
});
// Only admins can update topics
api.put('/topics/:id', requireAdmin, zValidator('json', topicSchema.partial()), async (c) => {
const id = c.req.param('id');
const body = c.req.valid('json');
const result = await db.update(topics).set(body).where(eq(topics.id, id)).returning();
return c.json(result[0]);
});
// Only admins can delete topics
api.delete('/topics/:id', requireAdmin, async (c) => {
const id = c.req.param('id');
await db.delete(topics).where(eq(topics.id, id));
return c.json({ success: true });
});
// --- Users ---
api.get('/users', requireAdmin, async (c) => {
const allUsers = await db.query.users.findMany({
with: {
subscriptions: {
with: {
topic: true
}
}
}
});
return c.json(allUsers);
});
api.post('/users', requireAdmin, zValidator('json', userSchema), async (c) => {
const body = c.req.valid('json');
const result = await db.insert(users).values(body).returning();
return c.json(result[0]);
});
api.put('/users/:id', requireAdmin, zValidator('json', userSchema.partial()), async (c) => {
const id = c.req.param('id');
const body = c.req.valid('json');
const result = await db.update(users).set(body).where(eq(users.id, id)).returning();
return c.json(result[0]);
});
api.delete('/users/:id', requireAdmin, async (c) => {
const id = c.req.param('id');
await db.delete(users).where(eq(users.id, id));
return c.json({ success: true });
});
// --- Subscriptions ---
// Users can subscribe themselves or admins can subscribe anyone
api.post('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
const { topicId, userId } = c.req.param();
const session = c.get('session');
// Check if user is subscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) {
return c.json({ error: 'You can only subscribe yourself' }, 403);
}
const result = await db.insert(subscriptions).values({ topicId, userId }).returning();
return c.json(result[0]);
});
// Users can unsubscribe themselves or admins can unsubscribe anyone
api.delete('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
const { topicId, userId } = c.req.param();
const session = c.get('session');
// Check if user is unsubscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) {
return c.json({ error: 'You can only unsubscribe yourself' }, 403);
}
await db.delete(subscriptions)
.where(and(
eq(subscriptions.topicId, topicId),
eq(subscriptions.userId, userId)
));
return c.json({ success: true });
});
// --- Alert Tasks ---
api.get('/alerts/tasks', requireAdmin, async (c) => {
const limit = Math.min(Number(c.req.query('limit') || 50), 100);
const tasks = await db.query.alertTasks.findMany({
orderBy: [desc(alertTasks.createdAt)],
limit,
with: {
sender: true,
logs: {
limit: 10, // Only show first 10 logs inline
}
}
});
return c.json(tasks);
});
api.get('/alerts/tasks/:id', requireAuth, async (c) => {
const id = c.req.param('id');
const task = await db.query.alertTasks.findFirst({
where: eq(alertTasks.id, id),
with: {
sender: true,
logs: true // Show all logs for detail view
}
});
if (!task) {
return c.json({ error: 'Task not found' }, 404);
}
return c.json(task);
});
// --- Stats ---
api.get('/stats', requireAdmin, async (c) => {
// 1. Message count per topic
const topicStats = await db.select({
topicSlug: alertTasks.topicSlug,
totalTasks: count(),
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
})
.from(alertTasks)
.groupBy(alertTasks.topicSlug);
// 2. Recent metrics (last 24h)
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentStats = await db.select({
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
taskCount: count(),
})
.from(alertTasks)
.where(gt(alertTasks.createdAt, last24h));
const recent = recentStats[0] || { totalRecipients: 0, totalSuccess: 0, taskCount: 0 };
const totalRecipients = Number(recent.totalRecipients || 0);
const totalSuccess = Number(recent.totalSuccess || 0);
const failedCount = totalRecipients - totalSuccess;
const successRate = totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100;
return c.json({
topics: topicStats,
recent: {
alertsReceived: Number(recent.taskCount || 0),
plannedMessages: totalRecipients,
successCount: totalSuccess,
failedCount: failedCount,
successRate: successRate,
}
});
});
export default api;

141
apps/server/src/auth.ts Normal file
View File

@@ -0,0 +1,141 @@
import { Hono } from 'hono';
import { setCookie, getCookie } from 'hono/cookie';
import { db } from './db';
import { users } from './db/schema';
import { eq } from 'drizzle-orm';
import { feishuClient } from './feishu';
const auth = new Hono();
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map(email => email.trim()).filter(Boolean);
// Get the login URL for frontend to redirect
auth.get('/login-url', (c) => {
const appId = process.env.FEISHU_APP_ID;
const redirectUri = encodeURIComponent(process.env.REDIRECT_URI || 'http://localhost:5173/auth/callback');
const state = crypto.randomUUID();
// Store state in cookie for CSRF protection
setCookie(c, 'oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 600, // 10 minutes
sameSite: 'Lax',
});
const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`;
return c.json({ loginUrl });
});
// Handle OAuth callback
auth.get('/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = getCookie(c, 'oauth_state');
// Verify state for CSRF protection
if (!state || state !== storedState) {
return c.json({ error: 'Invalid state parameter' }, 400);
}
if (!code) {
return c.json({ error: 'No code provided' }, 400);
}
try {
// Exchange code for user access token and user info
const userData = await feishuClient.getUserAccessToken(code);
// Check if user exists, otherwise create
let user = await db.query.users.findFirst({
where: eq(users.feishuUserId, userData.open_id),
});
const isAdmin = ADMIN_EMAILS.includes(userData.email || '');
if (!user) {
// Create new user
const result = await db.insert(users).values({
name: userData.name,
feishuUserId: userData.open_id,
email: userData.email || null,
isAdmin,
}).returning();
user = result[0];
} else {
// Update user info (in case name or admin status changed)
const result = await db.update(users)
.set({
name: userData.name,
email: userData.email || user.email,
isAdmin,
})
.where(eq(users.id, user.id))
.returning();
user = result[0];
}
// Set session cookie
setCookie(c, 'session', JSON.stringify({
id: user.id,
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
personalToken: user.personalToken,
}), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'Lax',
});
return c.json({
success: true,
user: {
id: user.id,
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
},
});
} catch (error) {
console.error('OAuth callback error:', error);
return c.json({ error: 'Authentication failed' }, 500);
}
});
// Get current user from session
auth.get('/me', (c) => {
const sessionCookie = getCookie(c, 'session');
if (!sessionCookie) {
return c.json({ error: 'Not authenticated' }, 401);
}
try {
const session = sessionCookie ? JSON.parse(sessionCookie) : null;
if (!session) {
return c.json({ error: 'Not authenticated' }, 401);
}
// Normalize user object to ensure id is present (handle legacy session with userId)
const user = {
...session,
id: session.id || session.userId,
};
return c.json({ user });
} catch (error) {
console.error('[Auth] Failed to parse session cookie:', error);
return c.json({ error: 'Invalid session' }, 401);
}
});
// Logout
auth.post('/logout', (c) => {
setCookie(c, 'session', '', {
maxAge: 0,
});
return c.json({ success: true });
});
export default auth;

View File

@@ -0,0 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,93 @@
import { pgTable, text, integer, primaryKey, boolean, jsonb, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Topics: 类似于 Kafka 的 Topic 或 告警的 Tag例如 "payment-service", "prod-env"
export const topics = pgTable('topics', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
slug: text('slug').notNull().unique(), // 告警发送时使用的 key
name: text('name').notNull(),
description: text('description'),
status: text('status', { enum: ['pending', 'approved', 'rejected'] }).default('approved').notNull(),
createdBy: text('created_by').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const topicsRelations = relations(topics, ({ many, one }) => ({
subscriptions: many(subscriptions),
creator: one(users, {
fields: [topics.createdBy],
references: [users.id],
}),
}));
export const users = pgTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
feishuUserId: text('feishu_user_id').notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
email: text('email').unique(),
isAdmin: boolean('is_admin').default(false),
personalToken: text('personal_token').notNull().unique().$defaultFn(() => crypto.randomUUID().replace(/-/g, '')),
});
export const usersRelations = relations(users, ({ many }) => ({
subscriptions: many(subscriptions),
}));
// Subscriptions: 用户直接订阅 Topic
export const subscriptions = pgTable('subscriptions', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.userId, t.topicId] }),
}));
export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
user: one(users, {
fields: [subscriptions.userId],
references: [users.id],
}),
topic: one(topics, {
fields: [subscriptions.topicId],
references: [topics.id],
}),
}));
// API Tasks: 记录 webhook 请求的处理状态
export const alertTasks = pgTable('alert_tasks', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
topicSlug: text('topic_slug').notNull(),
senderId: text('sender_id').references(() => users.id), // 记录是谁发送的 (通过 personal_token)
status: text('status', { enum: ['pending', 'processing', 'completed', 'failed'] }).default('pending').notNull(),
recipientCount: integer('recipient_count').default(0),
successCount: integer('success_count').default(0),
payload: jsonb('payload'), // 存储 webhook body
error: text('error'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Logs for each recipient in a task (optional detail)
export const alertLogs = pgTable('alert_logs', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
taskId: text('task_id').notNull().references(() => alertTasks.id, { onDelete: 'cascade' }),
userId: text('user_id'), // Optional, in case user is deleted later
status: text('status', { enum: ['sent', 'failed'] }).notNull(),
error: text('error'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const alertTasksRelations = relations(alertTasks, ({ many, one }) => ({
logs: many(alertLogs),
sender: one(users, {
fields: [alertTasks.senderId],
references: [users.id],
}),
}));
export const alertLogsRelations = relations(alertLogs, ({ one }) => ({
task: one(alertTasks, {
fields: [alertLogs.taskId],
references: [alertTasks.id],
}),
}));

94
apps/server/src/feishu.ts Normal file
View File

@@ -0,0 +1,94 @@
export class FeishuClient {
private appId: string;
private appSecret: string;
private token: string | null = null;
private tokenExpireAt: number = 0;
constructor(appId: string, appSecret: string) {
this.appId = appId;
this.appSecret = appSecret;
}
private async getTenantAccessToken(): Promise<string> {
if (this.token && Date.now() < this.tokenExpireAt) {
return this.token;
}
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: this.appId,
app_secret: this.appSecret,
}),
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(`Failed to get tenant access token: ${data.msg}`);
}
this.token = data.tenant_access_token;
// Expire 5 minutes early to be safe
this.tokenExpireAt = Date.now() + (data.expire * 1000) - 300000;
return this.token!;
}
async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email', msgType: string, content: any) {
const token = await this.getTenantAccessToken();
// Content needs to be stringified for 'text' type, but might be object for 'interactive'
// Feishu API expects 'content' field to be a JSON string for most types
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
receive_id: receiveId,
msg_type: msgType,
content: contentStr,
}),
});
const data = await res.json();
if (data.code !== 0) {
console.error('Feishu send message error:', data);
throw new Error(`Failed to send message: ${data.msg}`);
}
return data;
}
async getUserAccessToken(code: string): Promise<any> {
const token = await this.getTenantAccessToken();
const res = await fetch('https://open.feishu.cn/open-apis/authen/v1/access_token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
}),
});
const data = await res.json();
if (data.code !== 0) {
console.error('Feishu get user access token error:', data);
throw new Error(`Failed to get user access token: ${data.msg}`);
}
return data.data;
}
}
// Singleton instance - replace with env vars in production
export const feishuClient = new FeishuClient(
process.env.FEISHU_APP_ID || 'cli_xxx',
process.env.FEISHU_APP_SECRET || 'xxx'
);

36
apps/server/src/index.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { db } from './db';
import { topics } from './db/schema';
import webhook from './webhook';
import api from './api';
import auth from './auth';
const app = new Hono();
// Enable CORS for frontend
app.use('/*', cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
}));
app.get('/', (c) => {
return c.text('Alert Message Center API is running!');
});
const routes = app.route('/api/auth', auth)
.route('/api', api)
.route('/webhook', webhook);
app.onError((err, c) => {
console.error(`[Global Error] ${c.req.method} ${c.req.url}:`, err);
return c.json({ error: err.message || 'Internal Server Error' }, 500);
});
app.get('/topics', async (c) => {
const allTopics = await db.select().from(topics);
return c.json(allTopics);
});
export type AppType = typeof routes;
export default app;

View File

@@ -0,0 +1,51 @@
import { Context, Next } from 'hono';
import { getCookie } from 'hono/cookie';
export interface AuthSession {
id: string;
name: string;
email: string | null;
isAdmin: boolean;
}
export async function requireAuth(c: Context, next: Next) {
const sessionCookie = getCookie(c, 'session');
if (!sessionCookie) {
return c.json({ error: 'Authentication required' }, 401);
}
try {
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null;
if (!session) {
return c.json({ error: 'Authentication required' }, 401);
}
c.set('session', session);
await next();
} catch (error) {
console.error('[Middleware] Failed to parse session cookie:', error);
return c.json({ error: 'Invalid session' }, 401);
}
}
export async function requireAdmin(c: Context, next: Next) {
const sessionCookie = getCookie(c, 'session');
if (!sessionCookie) {
return c.json({ error: 'Authentication required' }, 401);
}
try {
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null;
if (!session || !session.isAdmin) {
return c.json({ error: 'Admin access required' }, 403);
}
c.set('session', session);
await next();
} catch (error) {
console.error('[Middleware] Failed to parse session cookie in requireAdmin:', error);
return c.json({ error: 'Invalid session' }, 401);
}
}

View File

@@ -0,0 +1,148 @@
import app from './index';
import { db } from './db';
import { users, topics, subscriptions } from './db/schema';
import { eq } from 'drizzle-orm';
async function verify() {
console.log('Starting Verification...');
let errors = 0;
// 1. Setup Test Data
const timestamp = Date.now();
// Create Non-Admin User
const [userUser] = await db.insert(users).values({
name: `TestUser_${timestamp}`,
feishuUserId: `test_user_${timestamp}`,
email: `test_user_${timestamp}@example.com`,
isAdmin: false
}).returning();
// Create Admin User
const [adminUser] = await db.insert(users).values({
name: `TestAdmin_${timestamp}`,
feishuUserId: `test_admin_${timestamp}`,
email: `test_admin_${timestamp}@example.com`,
isAdmin: true
}).returning();
// Create Topic
const [topic] = await db.insert(topics).values({
name: `TestTopic_${timestamp}`,
slug: `test-topic-${timestamp}`,
description: 'Test Description'
}).returning();
// Subscribe User to Topic
await db.insert(subscriptions).values({
userId: userUser.id,
topicId: topic.id
});
console.log('Test Data Created:', { user: userUser.id, admin: adminUser.id, topic: topic.id });
try {
// 2. Test GET /users (Admin Only)
// Test as Non-Admin
const sessionUser = { userId: userUser.id, name: userUser.name, email: userUser.email, isAdmin: userUser.isAdmin };
const req1 = new Request('http://localhost/api/users', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}`
}
});
const res1 = await app.request(req1);
if (res1.status === 403) {
console.log('✅ PASS: GET /users as Non-Admin returned 403');
} else {
console.error(`❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`);
errors++;
}
// Test as Admin
const sessionAdmin = { userId: adminUser.id, name: adminUser.name, email: adminUser.email, isAdmin: adminUser.isAdmin };
const req2 = new Request('http://localhost/api/users', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`
}
});
const res2 = await app.request(req2);
if (res2.status === 200) {
console.log('✅ PASS: GET /users as Admin returned 200');
} else {
console.error(`❌ FAIL: GET /users as Admin returned ${res2.status} (expected 200)`);
errors++;
}
// 3. Test GET /topics (Filtered)
// Test as Non-Admin (Should see ONLY their subscription)
const req3 = new Request('http://localhost/api/topics', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}`
}
});
const res3 = await app.request(req3);
const data3 = await res3.json();
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
if (targetTopic) {
if (targetTopic.subscriptions.length === 1 && targetTopic.subscriptions[0].userId === userUser.id) {
console.log('✅ PASS: GET /topics as Non-Admin shows correct personal subscription');
} else {
console.error('❌ FAIL: GET /topics as Non-Admin showed wrong subscriptions:', targetTopic.subscriptions);
errors++;
}
} else {
console.error('❌ FAIL: Test topic not found in list');
errors++;
}
// Test as Admin (Should see ALL subscriptions?? Wait, I didn't add another subscription. Let's add admin subscription too)
// Actually, let's just check that Admin sees the User's subscription.
// In my logic: isAdmin ? undefined (all) : ...
// So Admin should see User's subscription.
const req4 = new Request('http://localhost/api/topics', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`
}
});
const res4 = await app.request(req4);
const data4 = await res4.json();
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
// Should see the subscription for userUser
const hasUserSub = targetTopicAdmin.subscriptions.some((s: any) => s.userId === userUser.id);
if (hasUserSub) {
console.log('✅ PASS: GET /topics as Admin sees other users subscriptions');
} else {
console.error('❌ FAIL: GET /topics as Admin did NOT see other users subscriptions');
errors++;
}
} catch (e) {
console.error('Test Exception:', e);
errors++;
} finally {
// 4. Cleanup
await db.delete(subscriptions).where(eq(subscriptions.topicId, topic.id));
await db.delete(topics).where(eq(topics.id, topic.id));
await db.delete(users).where(eq(users.id, userUser.id));
await db.delete(users).where(eq(users.id, adminUser.id));
console.log('Cleanup Completed');
}
if (errors === 0) {
console.log('🎉 ALL TESTS PASSED');
process.exit(0);
} else {
console.error('💥 SOME TESTS FAILED');
process.exit(1);
}
}
verify();

174
apps/server/src/webhook.ts Normal file
View File

@@ -0,0 +1,174 @@
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from './db';
import { topics, alertTasks, alertLogs, users } from './db/schema';
import { feishuClient } from './feishu';
const webhook = new Hono();
webhook.post('/:token/topic/:slug', async (c) => {
const token = c.req.param('token');
const slug = c.req.param('slug');
console.log(`[Webhook] Received request for token: ${token}, slug: ${slug}`);
// 0. Find the User by Token
const user = await db.query.users.findFirst({
where: eq(users.personalToken, token),
});
if (!user) {
console.warn(`[Webhook] Invalid personal token: ${token}`);
return c.json({ error: 'Invalid personal token' }, 401);
}
let body;
try {
const rawBody = await c.req.text();
console.log(`[Webhook] Raw body length: ${rawBody.length}, content: "${rawBody}"`);
if (!rawBody || rawBody.trim() === '') {
return c.json({ error: 'Empty body' }, 400);
}
body = JSON.parse(rawBody);
} catch (e) {
console.error(`[Webhook] Failed to parse JSON body:`, e);
return c.json({ error: 'Invalid JSON body' }, 400);
}
// 1. Find the Topic
const topic = await db.query.topics.findFirst({
where: eq(topics.slug, slug),
with: {
subscriptions: {
with: {
user: true
}
}
}
});
if (!topic) {
console.warn(`[Webhook] Topic not found: ${slug}`);
return c.json({ error: 'Topic not found' }, 404);
}
console.log(`[Webhook] Found topic: ${topic.name}, subscribers: ${topic.subscriptions.length}`);
// 2. Collect subscribers
const subscribers = topic.subscriptions
.map(sub => sub.user)
.filter(u => !!u && !!u.feishuUserId);
const [task] = await db.insert(alertTasks).values({
topicSlug: topic.slug,
senderId: user.id,
status: 'processing',
recipientCount: subscribers.length,
successCount: 0,
payload: body,
}).returning();
if (subscribers.length === 0) {
await db.update(alertTasks)
.set({ status: 'completed', updatedAt: new Date() })
.where(eq(alertTasks.id, task.id));
return c.json({
message: 'No subscribers for this topic',
taskId: task.id,
status: 'completed'
});
}
// 4. Send Private Messages asynchronously
Promise.allSettled(subscribers.map(async (user) => {
try {
// Construct message content
let msgType = body.msg_type || 'text';
let content = body.content;
if (!content) {
msgType = 'text';
content = { text: JSON.stringify(body, null, 2) };
}
// Add metadata
if (msgType === 'text' && content.text) {
content.text = `[Topic: ${topic.name}]\n${content.text}`;
}
if (msgType === 'interactive' && content.header) {
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
}
const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id';
await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content);
return { userId: user.id, status: 'sent', error: null };
} catch (error: any) {
console.error(`Failed to send to user ${user.name}:`, error);
return { userId: user.id, status: 'failed', error: error.message };
}
})).then(async (results) => {
const successCount = results.filter(r => r.status === 'fulfilled' && (r.value as any).status === 'sent').length;
const failures = results
.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && (r.value as any).status === 'failed'))
.length;
// Determine final status
const finalStatus = failures === 0 ? 'completed' : (successCount > 0 ? 'completed' : 'failed');
// Update Task
await db.update(alertTasks).set({
status: finalStatus,
successCount,
updatedAt: new Date(),
// If fully failed, maybe store the first error in the task record for quick view
error: failures > 0 ? `Failed to send to ${failures} recipients` : null,
}).where(eq(alertTasks.id, task.id));
// Insert Logs (Optional: insert only failures to save space, or all for audit)
// Let's insert all for now
const logs = results.map((r, index) => {
const user = subscribers[index];
if (r.status === 'fulfilled') {
const val = r.value as any;
return {
taskId: task.id,
userId: user.id,
status: val.status,
error: val.error,
};
} else {
return {
taskId: task.id,
userId: user.id,
status: 'failed',
error: r.reason ? String(r.reason) : 'Unknown error',
};
}
});
if (logs.length > 0) {
await db.insert(alertLogs).values(logs as any);
}
console.log(`[Webhook] Task ${task.id}: Sent ${successCount}/${subscribers.length} alerts for topic ${slug}`);
});
return c.json({
message: 'Alert received and processing started',
taskId: task.id,
status: 'processing',
recipientCount: subscribers.length
});
});
// Help message for non-POST requests or malformed URLs
webhook.all('/:token/topic/:slug', (c) => {
return c.json({
error: 'Method not allowed',
message: 'Please use POST to send alerts to this webhook',
format: 'POST /webhook/:token/topic/:slug',
example: 'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL'
}, 405);
});
export default webhook;

21
apps/server/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"types": [
"bun-types"
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
]
}

13
apps/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Alert Message Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
apps/web/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@alertmessagecenter/web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.0.0",
"hono": "^4.11.3",
"lucide-react": "^0.300.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.0.0",
"postcss": "^8.0.0",
"tailwindcss": "^3.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"drizzle-orm": "^0.45.1",
"zod": "^3.0.0",
"@types/node": "^20.0.0",
"bun-types": "latest"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

125
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react'
import { Hash, Users, Activity, LogIn, LogOut, ShieldCheck, Settings } from 'lucide-react'
import { useAuth } from './contexts/AuthContext'
import TopicsView from './views/TopicsView'
import UsersView from './views/UsersView'
import AdminView from './views/AdminView'
function App() {
const { user, loading, login, logout } = useAuth()
const [activeTab, setActiveTab] = useState('topics')
const [hasSetDefault, setHasSetDefault] = useState(false)
useEffect(() => {
if (!loading && user && !hasSetDefault) {
setActiveTab(user.isAdmin ? 'admin' : 'topics')
setHasSetDefault(true)
}
}, [user, loading, hasSetDefault])
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
)
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center">
<Activity className="h-16 w-16 text-indigo-600 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">Alert Message Center</h1>
<p className="text-gray-600 mb-6">Please sign in with Feishu to continue</p>
<button
onClick={login}
className="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition"
>
<LogIn className="mr-2 h-5 w-5" />
Sign in with Feishu
</button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Activity className="h-8 w-8 text-indigo-600" />
<span className="ml-2 text-xl font-bold text-gray-900">Alert Message Center</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{user.isAdmin && (
<button
onClick={() => setActiveTab('admin')}
className={`${activeTab === 'admin'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Settings className="mr-2 h-4 w-4" />
Admin
</button>
)}
<button
onClick={() => setActiveTab('topics')}
className={`${activeTab === 'topics'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Hash className="mr-2 h-4 w-4" />
Topics
</button>
{user.isAdmin && (
<button
onClick={() => setActiveTab('users')}
className={`${activeTab === 'users'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Users className="mr-2 h-4 w-4" />
Users
</button>
)}
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700">{user.name}</span>
{user.isAdmin && (
<ShieldCheck className="h-5 w-5 text-indigo-600" />
)}
</div>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100"
>
<LogOut className="mr-1 h-4 w-4" />
Logout
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{activeTab === 'topics' && <TopicsView />}
{activeTab === 'users' && user.isAdmin && <UsersView />}
{activeTab === 'admin' && user.isAdmin && <AdminView />}
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
<div className="absolute inset-0 bg-gray-500 opacity-75" onClick={onClose}></div>
</div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex justify-between items-start">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<button
onClick={onClose}
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="mt-2">
{children}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { client } from '../lib/client';
interface User {
id: string;
name: string;
email: string | null;
isAdmin: boolean;
personalToken: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: () => void;
logout: () => void;
checkAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const checkAuth = useCallback(async () => {
try {
const res = await client.api.auth.me.$get(undefined, {
init: { credentials: 'include' }
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
} else {
setUser(null);
}
} catch (error) {
console.error('Auth check failed:', error);
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkAuth();
}, [checkAuth]);
const login = useCallback(async () => {
try {
const res = await client.api.auth['login-url'].$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
window.location.href = data.loginUrl;
} catch (error) {
console.error('Login failed:', error);
}
}, []);
const logout = useCallback(async () => {
try {
await client.api.auth.logout.$post(undefined, {
init: { credentials: 'include' }
});
setUser(null);
} catch (error) {
console.error('Logout failed:', error);
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, checkAuth }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

14
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -0,0 +1,4 @@
import { hc } from 'hono/client';
import type { AppType } from '../../../server/src/index';
export const client = hc<AppType>('/') as any;

17
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import AuthCallback from './views/AuthCallback.tsx'
import { AuthProvider } from './contexts/AuthContext.tsx'
import './index.css'
// Simple routing based on pathname
const pathname = window.location.pathname;
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
{pathname === '/auth/callback' ? <AuthCallback /> : <App />}
</AuthProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react';
import { client } from '../lib/client';
import SystemLoadView from './SystemLoadView';
export default function AdminView() {
const [activeTab, setActiveTab] = useState('load');
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Admin Dashboard</h2>
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('load')}
className={`${activeTab === 'load'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
System Load
</button>
<button
onClick={() => setActiveTab('requests')}
className={`${activeTab === 'requests'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
Topic Requests
</button>
<button
onClick={() => setActiveTab('topics')}
className={`${activeTab === 'topics'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
All Topics
</button>
</nav>
</div>
{activeTab === 'load' && <SystemLoadView />}
{activeTab === 'requests' && <TopicRequestsList />}
{activeTab === 'topics' && <TopicsManagement />}
</div>
</div>
);
}
function TopicsManagement() {
const [topics, setTopics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchAllTopics = async () => {
setLoading(true);
try {
const res = await client.api.topics.all.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setTopics(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAllTopics();
}, []);
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`)) {
return;
}
try {
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } });
fetchAllTopics();
} catch (error) {
console.error(error);
}
};
if (loading) return <div>Loading topics...</div>;
return (
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Topic</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Subscribers</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created By</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{topics.map((topic) => (
<tr key={topic.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{topic.name}</div>
<div className="text-sm text-gray-500 font-mono">{topic.slug}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${topic.status === 'approved' ? 'bg-green-100 text-green-800' :
topic.status === 'rejected' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{topic.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.subscriptions?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.creator?.name || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDelete(topic.id, topic.name)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function TopicRequestsList() {
const [requests, setRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchRequests = async () => {
setLoading(true);
try {
const res = await client.api.topics.requests.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setRequests(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRequests();
}, []);
const handleAction = async (id: string, action: 'approve' | 'reject' | 'delete', name?: string) => {
try {
if (action === 'approve') {
await client.api.topics[':id'].approve.$post({ param: { id } }, { init: { credentials: 'include' } });
} else if (action === 'reject') {
await client.api.topics[':id'].reject.$post({ param: { id } }, { init: { credentials: 'include' } });
} else if (action === 'delete') {
if (!confirm(`Are you sure you want to delete request "${name}"?`)) return;
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } });
}
fetchRequests();
} catch (error) {
console.error(error);
}
};
if (loading) return <div>Loading requests...</div>;
if (requests.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No pending topic requests.
</div>
);
}
return (
<div className="overflow-hidden">
<ul className="divide-y divide-gray-200">
{requests.map(req => (
<li key={req.id} className="py-4 flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{req.name}</p>
<p className="text-sm text-gray-500">Slug: <span className="font-mono">{req.slug}</span></p>
<p className="text-sm text-gray-500">
Requested by: {req.creator?.name || 'Unknown'}
{req.creator?.email ? ` (${req.creator.email})` : ''}
</p>
{req.description && (
<p className="text-sm text-gray-500 mt-1 italic">"{req.description}"</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleAction(req.id, 'approve')}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium shadow-sm transition-colors"
>
Approve
</button>
<button
onClick={() => handleAction(req.id, 'reject')}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
>
Reject
</button>
<button
onClick={() => handleAction(req.id, 'delete', req.name)}
className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors"
>
Delete
</button>
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useEffect, useState, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { client } from '../lib/client';
export default function AuthCallback() {
const [error, setError] = useState<string | null>(null);
const { checkAuth } = useAuth();
const processed = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (processed.current) return;
processed.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (!code) {
setError('No authorization code received');
return;
}
try {
const res = await client.api.auth.callback.$get({
query: {
code,
state: state || undefined
}
}, {
init: { credentials: 'include' }
});
if (res.ok) {
await checkAuth();
// Redirect to home
window.location.href = '/';
} else {
const data = await res.json();
setError(data.error || 'Authentication failed');
}
} catch (err) {
setError('Authentication failed');
console.error(err);
}
};
handleCallback();
}, [checkAuth]);
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-red-600 mb-4">Authentication Error</h2>
<p className="text-gray-700">{error}</p>
<button
onClick={() => window.location.href = '/'}
className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
>
Return to Home
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Authenticating...</h2>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useState, useEffect } from 'react';
import { client } from '../lib/client';
import { Activity, CheckCircle, XCircle, BarChart3, Clock } from 'lucide-react';
interface Stats {
topics: {
topicSlug: string;
totalTasks: number;
totalRecipients: number;
totalSuccess: number;
}[];
recent: {
alertsReceived: number;
plannedMessages: number;
successCount: number;
failedCount: number;
successRate: number;
};
tasks: any[];
}
export default function SystemLoadView() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
const fetchStats = async () => {
try {
const res = await client.api.stats.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
// Fetch recent tasks as well
const tasksRes = await client.api.alerts.tasks.$get({ query: { limit: '10' } }, {
init: { credentials: 'include' }
});
const tasks = await tasksRes.json();
setStats({ ...data, tasks } as Stats);
setLastUpdated(new Date());
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
return () => clearInterval(interval);
}, []);
if (loading) return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
if (!stats) return <div className="text-center py-12 text-gray-500">Failed to load statistics.</div>;
return (
<div className="space-y-8 animate-fade-in">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">Live Feedback</span>
</div>
<span className="text-xs text-gray-400">Last updated: {lastUpdated.toLocaleTimeString()}</span>
</div>
{/* Top Row: General Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<MetricCard
title="Alerts Received"
value={stats.recent.alertsReceived}
icon={<Activity className="w-5 h-5 text-purple-500" />}
color="purple"
description="Total webhook hits"
/>
<MetricCard
title="Planned Deliveries"
value={stats.recent.plannedMessages}
icon={<Clock className="w-5 h-5 text-blue-500" />}
color="blue"
description="Total subscribers"
/>
<MetricCard
title="Success"
value={stats.recent.successCount}
icon={<CheckCircle className="w-5 h-5 text-green-500" />}
color="green"
description="Successfully sent"
/>
<MetricCard
title="Failed"
value={stats.recent.failedCount}
icon={<XCircle className="w-5 h-5 text-red-500" />}
color="red"
description="API errors/failures"
/>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center justify-center">
<span className="text-xs font-medium text-gray-500 mb-2">Success Rate</span>
<Gauge value={stats.recent.successRate} />
</div>
</div>
{/* Middle Row: Topic Message Counts */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">Historical Topic Metrics</h3>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<tr>
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">Alerts (Tasks)</th>
<th className="px-6 py-4 text-left tracking-wider">Planned (Recipients)</th>
<th className="px-6 py-4 text-left tracking-wider">Distributed (Success)</th>
<th className="px-6 py-4 text-left tracking-wider">Health Rate</th>
<th className="px-6 py-4 text-left tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats.topics.map((topic) => {
const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100;
return (
<tr key={topic.topicSlug} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-1 rounded-md">
{topic.topicSlug}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">{topic.totalTasks}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalRecipients}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalSuccess}</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-20 bg-gray-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-1000 ${rate > 90 ? 'bg-green-500' : rate > 70 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${rate}%` }}
></div>
</div>
<span className="text-[11px] font-bold text-gray-700">{rate.toFixed(1)}%</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${rate === 100 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rate === 100 ? 'Healthy' : 'Errors'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Bottom Row: Recent Alerts with Sender Info */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">Recent Alerts (Audit Log)</h3>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<tr>
<th className="px-6 py-4 text-left tracking-wider">Time</th>
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">Sender</th>
<th className="px-6 py-4 text-left tracking-wider">Recipients</th>
<th className="px-6 py-4 text-left tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats.tasks.map((task: any) => (
<tr key={task.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-xs text-gray-500 font-medium">
{new Date(task.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-1 rounded-md">
{task.topicSlug}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">{task.sender?.name || 'Unknown'}</span>
<span className="text-[10px] text-gray-400">{task.sender?.email || 'N/A'}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{task.successCount} / {task.recipientCount}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${task.status === 'completed' ? 'bg-green-100 text-green-700' : task.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
}`}>
{task.status}
</span>
</td>
</tr>
))}
{stats.tasks.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500 italic text-sm">
No alerts sent yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function MetricCard({ title, value, icon, color, description }: { title: string, value: number, icon: React.ReactNode, color: string, description?: string }) {
return (
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex flex-col transition-all hover:shadow-md hover:-translate-y-1">
<div className="flex items-start justify-between mb-4">
<div className={`p-2.5 rounded-xl bg-${color}-50`}>
{icon}
</div>
<div className="text-right">
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">{title}</p>
<h3 className="text-2xl font-black text-gray-900 leading-none mt-1">{value.toLocaleString()}</h3>
</div>
</div>
{description && <p className="text-[10px] text-gray-500 font-medium italic">/ {description}</p>}
</div>
);
}
function Gauge({ value }: { value: number }) {
const radius = 40;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
// Determine color based on value
const getColor = (v: number) => {
if (v >= 95) return '#10b981'; // green-500
if (v >= 80) return '#f59e0b'; // yellow-500
return '#ef4444'; // red-500
};
return (
<div className="relative flex items-center justify-center">
<svg className="w-32 h-32 transform -rotate-90">
<circle
className="text-gray-100"
strokeWidth="8"
stroke="currentColor"
fill="transparent"
r={radius}
cx="64"
cy="64"
/>
<circle
className="transition-all duration-1000 ease-out"
strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
stroke={getColor(value)}
fill="transparent"
r={radius}
cx="64"
cy="64"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center -mt-1">
<span className="text-2xl font-bold text-gray-900">{value.toFixed(1)}%</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,472 @@
import { useState, useEffect } from 'react';
import { Plus, Settings, UserPlus, UserMinus, Copy, Check } from 'lucide-react';
import Modal from '../components/Modal';
import { useAuth } from '../contexts/AuthContext';
import { client } from '../lib/client';
interface User {
id: string;
name: string;
email?: string | null;
}
interface Subscription {
userId: string;
user: User;
}
interface Topic {
id: string;
name: string;
slug: string;
description?: string;
subscriptions: Subscription[];
}
export default function TopicsView() {
const { user: currentUser } = useAuth();
const [topics, setTopics] = useState<Topic[]>([]);
const [myRequests, setMyRequests] = useState<any[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubModalOpen, setIsSubModalOpen] = useState(false);
const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [formData, setFormData] = useState<Partial<Topic>>({
name: '',
slug: '',
description: '',
});
const [submitStatus, setSubmitStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const fetchTopics = async () => {
setLoading(true);
try {
const res = await client.api.topics.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setTopics(data as unknown as Topic[]);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const fetchMyRequests = async () => {
try {
const res = await client.api.topics['my-requests'].$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setMyRequests(data);
} catch (err) {
console.error(err);
}
};
const fetchUsers = async () => {
try {
const res = await client.api.users.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setUsers(data as unknown as User[]);
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetchTopics();
fetchMyRequests();
if (currentUser?.isAdmin) {
fetchUsers();
}
}, [currentUser]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitStatus(null);
try {
const res = await client.api.topics.$post({
json: formData as any
}, {
init: { credentials: 'include' }
});
if (res.ok) {
setSubmitStatus({
type: 'success',
message: currentUser?.isAdmin ? 'Topic created successfully!' : 'Request submitted! Waiting for approval.'
});
setFormData({ name: '', slug: '', description: '' });
fetchTopics();
fetchMyRequests();
setTimeout(() => {
setIsModalOpen(false);
setSubmitStatus(null);
}, 1500);
} else {
const error = await res.json();
setSubmitStatus({ type: 'error', message: error.message || 'Failed to submit request.' });
}
} catch (error) {
console.error('Error creating topic:', error);
setSubmitStatus({ type: 'error', message: 'An unexpected error occurred.' });
}
};
const handleSubscriptionClick = (topic: Topic) => {
setSelectedTopic(topic);
setIsSubModalOpen(true);
};
const toggleSubscription = async (topicId: string, userId: string, isSubscribed: boolean) => {
try {
console.log('Toggling subscription:', { topicId, userId, isSubscribed });
if (isSubscribed) {
await client.api.topics[':topicId'].subscribe[':userId'].$delete({
param: { topicId, userId }
}, {
init: { credentials: 'include' }
});
} else {
await client.api.topics[':topicId'].subscribe[':userId'].$post({
param: { topicId, userId }
}, {
init: { credentials: 'include' }
});
}
// Optimistic update for the main list
setTopics(prevTopics =>
prevTopics.map(t => {
if (t.id === topicId) {
const updatedSubs = isSubscribed
? t.subscriptions.filter(s => s.userId !== userId)
: [...t.subscriptions, { userId, user: users.find(u => u.id === userId) || currentUser! }];
return { ...t, subscriptions: updatedSubs };
}
return t;
})
);
// Also update selectedTopic if it's open
if (selectedTopic && selectedTopic.id === topicId) {
const updatedSubs = isSubscribed
? selectedTopic.subscriptions.filter(s => s.userId !== userId)
: [...selectedTopic.subscriptions, { userId, user: users.find(u => u.id === userId) || currentUser! }];
setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs });
}
fetchTopics(); // Re-fetch to ensure consistency
} catch (error) {
console.error('Error toggling subscription:', error);
}
};
const isSubscribed = (topic: Topic) => {
return topic.subscriptions.some(sub => sub.userId === currentUser?.id);
};
const handleSelfSubscribe = async (topic: Topic) => {
if (!currentUser) return;
const subscribed = isSubscribed(topic);
await toggleSubscription(topic.id, currentUser.id, subscribed);
};
const copyToClipboard = (text: string, topicId: string) => {
navigator.clipboard.writeText(text);
setCopiedId(topicId);
setTimeout(() => setCopiedId(null), 2000);
};
const getWebhookUrl = (topicSlug: string) => {
if (!currentUser?.personalToken) return '';
// Use an environment variable if available, otherwise fallback to current origin
const baseUrl = ((import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin).replace(/\/$/, '');
return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`;
};
if (loading) return <div className="p-4">Loading...</div>;
return (
<div>
<div className="bg-indigo-50 border-l-4 border-indigo-400 p-4 mb-8 rounded-r-md shadow-sm">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-bold text-indigo-800">How it works?</h3>
<div className="mt-2 text-sm text-indigo-700">
<ul className="list-disc pl-5 space-y-1">
<li><strong>Subscribe:</strong> Click <span className="text-green-700 font-semibold">Subscribe</span> on any topic to start receiving alerts via Feishu private message.</li>
<li><strong>Personal Webhook:</strong> Each topic provides a <span className="font-semibold">unique URL</span> just for you. Send JSON alerts to this URL to notify yourself.</li>
<li><strong>Need more?</strong> If you can't find a suitable topic, click <span className="font-semibold">Request Topic</span> to ask admins for a new one.</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Topics</h2>
<div className="flex gap-2">
{currentUser && (
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
{currentUser.isAdmin ? 'Add Topic' : 'Request Topic'}
</button>
)}
</div>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{topics.map((topic) => (
<li key={topic.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-indigo-600 truncate">{topic.name}</p>
<div className="flex items-center space-x-2">
<button
onClick={() => handleSelfSubscribe(topic)}
className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${isSubscribed(topic)
? 'border-red-300 text-red-700 bg-red-50 hover:bg-red-100'
: 'border-green-300 text-green-700 bg-green-50 hover:bg-green-100'
}`}
>
{isSubscribed(topic) ? (
<>
<UserMinus className="w-3 h-3 mr-1" />
Unsubscribe
</>
) : (
<>
<UserPlus className="w-3 h-3 mr-1" />
Subscribe
</>
)}
</button>
{currentUser?.isAdmin && (
<>
<button
onClick={() => handleSubscriptionClick(topic)}
className="text-gray-400 hover:text-gray-500"
title="Manage Subscriptions"
>
<Settings className="w-5 h-5" />
</button>
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex flex-col">
<p className="flex items-center text-sm text-gray-500">
Slug: <span className="font-mono ml-1 bg-gray-100 px-1 rounded">{topic.slug}</span>
</p>
<p className="flex items-center text-sm text-gray-500 mt-1">
{topic.description}
</p>
{currentUser && (
<div className="mt-3 bg-gray-50 p-2 rounded border border-gray-200">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Your Personal Webhook</span>
<button
onClick={() => copyToClipboard(getWebhookUrl(topic.slug), topic.id)}
className="text-indigo-600 hover:text-indigo-800 flex items-center text-xs font-medium"
>
{copiedId === topic.id ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied!
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy URL
</>
)}
</button>
</div>
<div className="mt-1 text-xs font-mono text-gray-600 break-all select-all">
{getWebhookUrl(topic.slug)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</li>
))}
{topics.length === 0 && (
<li className="px-4 py-12 text-center">
<div className="flex flex-col items-center">
<div className="bg-gray-100 p-3 rounded-full mb-4">
<Plus className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-900 font-medium">No topics available yet.</p>
<p className="text-gray-500 text-sm mt-1 max-w-xs mx-auto">
{currentUser?.isAdmin
? "Click 'Add Topic' above to create the first alert topic for your team."
: "There are no approved topics yet. You can request one by clicking 'Request Topic' above."}
</p>
</div>
</li>
)}
</ul>
</div>
{myRequests.length > 0 && (
<div className="mt-12">
<h3 className="text-lg font-bold text-gray-900 mb-4">My Requests</h3>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{myRequests.map((req) => (
<li key={req.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-indigo-600 truncate">{req.name}</p>
<div className="flex items-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${req.status === 'approved' ? 'bg-green-100 text-green-800' :
req.status === 'rejected' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{req.status === 'approved' ? 'Approved' :
req.status === 'rejected' ? 'Rejected' :
'Pending'}
</span>
</div>
</div>
<div className="mt-2 text-sm text-gray-500">
<p>Slug: <span className="font-mono">{req.slug}</span></p>
{req.description && <p className="mt-1">{req.description}</p>}
<p className="mt-1 text-xs text-gray-400">Requested on: {new Date(req.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
</div>
)}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={currentUser?.isAdmin ? "Add New Topic" : "Request New Topic"}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Slug (Unique ID)</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.slug}
onChange={e => setFormData({ ...formData, slug: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
{submitStatus && (
<div className={`p-3 rounded-md text-sm ${submitStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
}`}>
{submitStatus.message}
</div>
)}
<div className="flex justify-end pt-4">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700"
>
Create Topic
</button>
</div>
</form>
</Modal>
<Modal
isOpen={isSubModalOpen}
onClose={() => setIsSubModalOpen(false)}
title={`Manage Subscribers for ${selectedTopic?.name}`}
>
<div className="mt-4">
<p className="text-sm text-gray-500 mb-4">Select users who should receive alerts for this topic.</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{users.map(user => {
const isSubscribed = selectedTopic?.subscriptions.some(s => s.userId === user.id);
return (
<div key={user.id} className="flex items-center">
<input
id={`user-${user.id}`}
type="checkbox"
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
checked={isSubscribed || false}
onChange={() => selectedTopic && toggleSubscription(selectedTopic.id, user.id, isSubscribed || false)}
/>
<label htmlFor={`user-${user.id}`} className="ml-2 block text-sm text-gray-900">
{user.name} <span className="text-gray-500 text-xs">({user.email})</span>
</label>
</div>
);
})}
{users.length === 0 && <p className="text-sm text-gray-500">No users available.</p>}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setIsSubModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Close
</button>
</div>
</div>
</Modal>
</div >
);
}

View File

@@ -0,0 +1,192 @@
import { useState, useEffect } from 'react';
import { Trash2, Plus } from 'lucide-react';
import Modal from '../components/Modal';
import { useAuth } from '../contexts/AuthContext';
import { client } from '../lib/client';
interface User {
id: string;
name: string;
feishuUserId?: string;
email?: string;
personalToken?: string;
}
export default function UsersView() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Partial<User>>({
name: '',
feishuUserId: '',
email: '',
});
const fetchUsers = async () => {
setLoading(true);
try {
const res = await client.api.users.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setUsers(data as unknown as User[]);
setLoading(false);
} catch (err) {
console.error(err);
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await client.api.users.$post({
json: formData as any
}, {
init: { credentials: 'include' }
});
if (res.ok) {
setIsModalOpen(false);
setFormData({ name: '', feishuUserId: '', email: '' });
fetchUsers();
}
} catch (error) {
console.error('Error creating user:', error);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await client.api.users[':id'].$delete({
param: { id }
}, {
init: { credentials: 'include' }
});
fetchUsers();
} catch (error) {
console.error('Error deleting user:', error);
}
};
if (loading) return <div className="p-4">Loading...</div>;
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Users</h2>
{currentUser?.isAdmin && (
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Add User
</button>
)}
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{users.map((user) => (
<li key={user.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-indigo-600 truncate">{user.name}</p>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex flex-col">
<p className="flex items-center text-sm text-gray-500">
Feishu ID: <span className="font-mono ml-1 bg-gray-100 px-1 rounded">{user.feishuUserId || 'N/A'}</span>
</p>
<p className="flex items-center text-sm text-gray-500 mt-1">
Email: {user.email || 'N/A'}
</p>
<p className="flex items-center text-sm text-gray-500 mt-1">
Personal Token: <span className="font-mono ml-1 bg-blue-50 text-blue-700 px-1 rounded">{user.personalToken || 'N/A'}</span>
</p>
</div>
</div>
</div>
{currentUser?.isAdmin && (
<div className="ml-4 flex items-center space-x-2">
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900 p-2"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
)}
</div>
</div>
</li>
))}
{users.length === 0 && (
<li className="px-4 py-8 text-center text-gray-500">
No users found. Create one to get started.
</li>
)}
</ul>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Add New User"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Feishu User ID</label>
<input
type="text"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.feishuUserId}
onChange={e => setFormData({ ...formData, feishuUserId: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="flex justify-end pt-4">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700"
>
Create User
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

30
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

25
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/webhook': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})

628
bun.lock Normal file
View File

@@ -0,0 +1,628 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "alert-manager",
"devDependencies": {
"bun-types": "latest",
},
},
"apps/server": {
"name": "@alertmessagecenter/server",
"version": "1.0.0",
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"drizzle-orm": "^0.45.1",
"hono": "^4.11.3",
"postgres": "^3.4.8",
"zod": "^3.0.0",
},
"devDependencies": {
"@types/node": "^20.0.0",
"bun-types": "latest",
"drizzle-kit": "^0.31.8",
},
},
"apps/web": {
"name": "@alertmessagecenter/web",
"version": "1.0.0",
"dependencies": {
"clsx": "^2.0.0",
"hono": "^4.11.3",
"lucide-react": "^0.300.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.0.0",
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.0.0",
"bun-types": "latest",
"drizzle-orm": "^0.45.1",
"postcss": "^8.0.0",
"tailwindcss": "^3.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"zod": "^3.0.0",
},
},
},
"packages": {
"@alertmessagecenter/server": ["@alertmessagecenter/server@workspace:apps/server"],
"@alertmessagecenter/web": ["@alertmessagecenter/web@workspace:apps/web"],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.300.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-rQxUUCmWAvNLoAsMZ5j04b2+OJv6UuNLYMY7VK0eVlm4aTwUEjEEHc09/DipkNIlhXUSDn2xoyIzVT0uh7dRsg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"node-abi": ["node-abi@3.85.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"node-abi/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
}
}

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
postgres:
image: postgres:17-alpine
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: alert_message_center
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

126
docs/copilot-context.md Normal file
View File

@@ -0,0 +1,126 @@
# Project Context for GitHub Copilot
This document provides technical context, architectural decisions, and code conventions for the **Alert Message Center** project. It is intended to help AI assistants understand the codebase.
## 1. Project Overview
**Alert Message Center** (formerly Alert Manager) is a centralized alert dispatching system.
- **Goal**: Decouple alert sources from alert recipients.
- **Mechanism**: Alerts are sent to a **Topic**. Users subscribe to Topics. The system dispatches alerts to subscribers via **Feishu (Lark) Private Messages**.
- **Runtime**: Bun (JavaScript/TypeScript runtime).
## 2. Tech Stack
- **Monorepo**: Simple directory structure (`apps/server`, `apps/web`).
- **Backend**:
- **Runtime**: Bun.
- **Framework**: Hono (Web Standard based).
- **Database**: SQLite (via `better-sqlite3`).
- **ORM**: Drizzle ORM.
- **Authentication**: Feishu OAuth2 (Session-based with cookies).
- **External API**: Feishu Open Platform (Server-side API).
- **Frontend**:
- **Build Tool**: Vite.
- **Framework**: React.
- **Styling**: Tailwind CSS.
- **Icons**: Lucide React.
- **Client**: `hono/client` (RPC-style type-safe client).
## 3. Data Model (Schema)
The database schema is defined in `apps/server/src/db/schema.ts`.
### Entities
1. **Topic** (`topics`)
- `id`: UUID (Primary Key)
- `name`: Display name (e.g., "Payment Service Errors").
- `slug`: URL-safe identifier (e.g., `payment-errors`). Used in webhook URLs.
- `description`: Optional text.
2. **User** (`users`)
- `id`: UUID (Primary Key).
- `name`: Display name.
- `feishuUserId`: The Feishu `open_id`. **Critical** for sending messages.
- `email`: Contact info.
- `isAdmin`: Boolean flag for administrative privileges (create topics, view all users).
3. **Subscription** (`subscriptions`)
- `topicId`: Foreign Key -> `topics.id`.
- `userId`: Foreign Key -> `users.id`.
- **Relationship**: Many-to-Many between Topics and Users.
## 4. Key Workflows
### Authentication
- **Strategy**: Feishu OAuth2.
- **Flow**:
1. Frontend calls `/api/auth/login-url` to get Feishu auth URL.
2. User redirects to Feishu, approves, redirects back to `/api/auth/callback`.
3. Server exchanges code for token, gets user info, creates/updates user in DB.
4. Server sets `session` cookie (httpOnly).
- **Context**: `AuthContext.tsx` manages user state on frontend.
### Alert Ingestion & Dispatch
**File**: `apps/server/src/webhook.ts`
1. **Ingest**: `POST /api/webhook/:slug` receives a JSON payload.
2. **Lookup**:
- Find `Topic` by `slug`.
- Fetch all `subscriptions` for this topic, including the associated `user`.
3. **Dispatch**:
- Iterate through subscribers.
- For each user, call `FeishuClient.sendMessage`.
- **Payload**: The `content` and `msg_type` from the request body are passed directly to Feishu.
### Subscription Management
- Users can subscribe/unsubscribe themselves to any topic.
- Admins can manage subscriptions for other users globally in `AdminView`.
- **Topic Deletion**: Centralized in the **Admin Dashboard (All Topics Tab)** to avoid accidental deletion from the main topic list.
- Button logic on frontend toggles between "Subscribe" and "Unsubscribe".
## 5. API Endpoints
### Auth
- `GET /api/auth/login-url`
- `GET /api/auth/callback`
- `GET /api/auth/me`
- `POST /api/auth/logout`
### Management
- `GET /api/topics`: List all approved topics.
- `GET /api/topics/my-requests`: List user's own topic requests.
- `GET /api/topics/requests`: List pending topic requests (Admin only).
- `GET /api/topics/all`: List all topics regardless of status (Admin only).
- `POST /api/topics`: Create a topic (Admin creates approved, User creates pending).
- `POST /api/topics/:id/approve`: Approve a topic request (Admin only).
- `POST /api/topics/:id/reject`: Reject a topic request (Admin only).
- `DELETE /api/topics/:id`: Delete a topic (Admin only).
- `POST /api/topics/:id/subscribe/:userId`: Subscribe.
- `DELETE /api/topics/:id/subscribe/:userId`: Unsubscribe.
- `GET /api/users`: List users (Admin only).
### Webhook
- `POST /api/webhook/:slug`: Trigger an alert for a topic.
## 6. Future Roadmap (Planned)
- [ ] **Message Preview**: Preview Feishu card JSON in the UI.
- [ ] **History/Logs**: Keep a log of sent alerts for auditing.
- [ ] **Retry Mechanism**: Handle Feishu API failures.
- [ ] **Deployment**: Dockerfile and deployment scripts.
## 7. Development Conventions
- **Imports**: Use relative imports.
- **Styling**: Use Tailwind utility classes directly in JSX.
- **Async/Await**: Prefer `async/await` over `.then()`.
- **Type Safety**: strict TypeScript usage. Backend and Frontend share types via Hono RPC or shared interfaces.
- **Environment Variables**:
- `FEISHU_APP_ID`, `FEISHU_APP_SECRET`, `REDIRECT_URI`, `ADMIN_EMAILS`.
- **Administrators**:
- Configured via the `ADMIN_EMAILS` environment variable (comma-separated list of emails).

14
package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "alertmessagecenter",
"version": "1.0.0",
"workspaces": [
"apps/*"
],
"scripts": {
"dev": "bun run --filter '*' dev",
"build": "bun run --filter '*' build"
},
"devDependencies": {
"bun-types": "latest"
}
}

24
todo.md Normal file
View File

@@ -0,0 +1,24 @@
# Alert Message Center Project Plan
## Phase 1: Core Functionality (Completed)
- [x] Initialize project structure (Bun, Monorepo)
- [x] Setup Backend (Hono + Bun)
- [x] Setup Drizzle (SQLite)
- [x] **Refactor Schema**: Switch from Bots/Roles to Topics/Users/Subscriptions
- [x] **Feishu Integration**: Implement Tenant Access Token & Private Message sending
- [x] Implement Webhook API (`POST /api/webhook/:slug`)
- [x] Implement Management APIs (CRUD for Topics, Users)
- [x] Setup Frontend (Vite + React + Tailwind)
- [x] **Topics View**: Manage topics and subscriptions
- [x] **Users View**: Manage users and Feishu IDs
- [x] Remove obsolete Bots/Roles views
## Phase 2: Enhancements
- [x] **Authentication**: Feishu SSO integration and role-based access control.
- [x] **Global Monitoring Dashboard**: Real-time System Load metrics (Grafana-style).
- [ ] **Message Preview**: Preview Feishu card JSON in the UI.
- [x] **History/Logs**: Basic tracking for sent alerts (Alert Tasks/Logs).
- [x] **Admin Topic Management**: Approve, reject, and delete topics.
- [ ] **Retry Mechanism**: Handle Feishu API failures.
- [ ] **Deployment**: Dockerfile and deployment scripts.