chore: import upstream snapshot with attribution
@@ -0,0 +1,148 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Tianji Project Cursor Rules
|
||||
|
||||
IMPORTANT: Do not operate my git to add or rm files at any time.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Tianji** is the project name - a proprietary monitoring and analytics platform. Always use "Tianji" (not "tianji" or other variations) when referring to the project in documentation, comments, and user-facing text.
|
||||
|
||||
## Project Structure (Monorepo)
|
||||
|
||||
This is a monorepo with the following structure:
|
||||
|
||||
```
|
||||
tianji/
|
||||
├── apps/ # Application packages
|
||||
│ ├── appstore-review/ # App store review automation
|
||||
│ ├── daily-ai-trigger/ # Daily AI trigger service
|
||||
│ └── mcp-server/ # MCP server implementation
|
||||
├── packages/ # Shared packages
|
||||
│ ├── client-sdk/ # Client SDK for external integration
|
||||
│ ├── react/ # React components sdk library
|
||||
│ └── react-native/ # React Native sdk library
|
||||
├── src/
|
||||
│ ├── client/ # Frontend application (React)
|
||||
│ │ ├── components/ # React components
|
||||
│ │ │ └── ui/ # Shadcn/ui components
|
||||
│ │ ├── routes/ # Application routes
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── store/ # State management
|
||||
│ │ └── utils/ # Client utilities
|
||||
│ ├── server/ # Backend application (Node.js)
|
||||
│ │ ├── model/ # Database models and business logic
|
||||
│ │ ├── router/ # Express routes
|
||||
│ │ ├── trpc/ # tRPC API definitions
|
||||
│ │ ├── middleware/ # Express middleware
|
||||
│ │ └── utils/ # Server utilities
|
||||
│ ├── shared/ # Shared code between client/server
|
||||
│ └── tracker/ # Tracking library
|
||||
├── website/ # Documentation website (Docusaurus)
|
||||
├── geo/ # Geographic data
|
||||
├── docker/ # Docker configurations
|
||||
└── example/ # Example applications
|
||||
├── expo/ # React Native example
|
||||
└── web/ # Web example
|
||||
```
|
||||
|
||||
## Component Usage Rules
|
||||
|
||||
### Priority Order for Components
|
||||
|
||||
1. **Shadcn/ui Components (Highest Priority)**
|
||||
- Located in `src/client/components/ui/`
|
||||
- Import as: `import { Button } from '@/components/ui/button'`
|
||||
- These are the preferred components for new development
|
||||
- Modern, accessible, and customizable
|
||||
- Built with Radix UI primitives and Tailwind CSS
|
||||
|
||||
2. **Antd Components (Secondary)**
|
||||
- Use only when Shadcn/ui doesn't provide equivalent functionality
|
||||
- Import as: `import { Form, Input } from 'antd'`
|
||||
- Good for complex form components and data display
|
||||
|
||||
### Available Shadcn/ui Components
|
||||
|
||||
Always check `src/client/components/ui/` directory first. Available components include:
|
||||
|
||||
- `alert`, `alert-dialog`, `avatar`, `badge`, `button`, `calendar`, `card`, `chart`
|
||||
- `checkbox`, `collapsible`, `command`, `dialog`, `drawer`, `dropdown-menu`
|
||||
- `form`, `input`, `label`, `menubar`, `popover`, `progress`, `radio-group`
|
||||
- `resizable`, `scroll-area`, `select`, `separator`, `sheet`, `sonner`, `spinner`
|
||||
- `switch`, `table`, `tabs`, `textarea`, `tooltip`
|
||||
|
||||
### Component Development Guidelines
|
||||
|
||||
1. **Use existing components** - Always check if a component already exists before creating new ones
|
||||
2. **Shadcn/ui first** - Prefer Shadcn/ui components over Antd when possible
|
||||
3. **Consistent styling** - Follow Tailwind CSS patterns used in existing components
|
||||
4. **Accessibility** - Ensure all components are accessible (Shadcn/ui components include this by default)
|
||||
5. **TypeScript** - All components must be properly typed
|
||||
|
||||
### Example Component Usage
|
||||
|
||||
```tsx
|
||||
// ✅ Preferred - Using Shadcn/ui
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
|
||||
import { Form } from '@/components/ui/form';
|
||||
|
||||
// ✅ Acceptable - When Shadcn/ui doesn't have equivalent
|
||||
import { Form, Input } from 'antd';
|
||||
|
||||
// ❌ Avoid - Don't mix when Shadcn/ui alternative exists
|
||||
import { Button } from 'antd'; // Use Shadcn/ui Button instead
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Project name**: Always use "Tianji" (capitalized)
|
||||
- **Components**: PascalCase (e.g., `MonitorProvider`, `PushTokenForm`)
|
||||
- **Files**: kebab-case for components (e.g., `push-monitor.tsx`)
|
||||
- **Functions**: camelCase (e.g., `handleSubmit`, `validateForm`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE (e.g., `API_BASE_URL`)
|
||||
|
||||
### Import Order
|
||||
|
||||
1. React and external libraries
|
||||
2. Internal utilities and hooks
|
||||
3. UI components (Shadcn/ui first, then Antd)
|
||||
4. Types and interfaces
|
||||
5. Relative imports
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentWorkspaceId } from '@/store/user';
|
||||
import { trpc } from '@/api/trpc';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { Form, Input } from 'antd';
|
||||
|
||||
import type { MonitorInfo } from './types';
|
||||
```
|
||||
|
||||
### Language Usage
|
||||
|
||||
- **Comments**: Always in English
|
||||
- **User-facing text**: Use i18n translation keys
|
||||
- **Documentation**: English preferred
|
||||
- **Console logs**: English for development messages
|
||||
|
||||
## API and Backend Guidelines
|
||||
|
||||
- Use **tRPC** for type-safe API calls
|
||||
- Follow **Prisma** patterns for database operations
|
||||
- Implement proper **error handling** and **validation**
|
||||
- Use **zod** for schema validation
|
||||
- Follow **OpenAPI** standards for public endpoints
|
||||
|
||||
This is a sophisticated, production-grade application with high standards for code quality, type safety, and user experience.
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends iputils-ping
|
||||
|
||||
# [Optional] Uncomment if you want to install an additional version of node using nvm
|
||||
ARG EXTRA_NODE_VERSION=18
|
||||
RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||
|
||||
# [Optional] Uncomment if you want to install more global node modules
|
||||
# RUN su node -c "npm install -g <your-package-list-here>"
|
||||
@@ -0,0 +1,56 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "Tianji Devcontainer",
|
||||
|
||||
// Update the 'dockerComposeFile' list if you have more compose files or use different names.
|
||||
// The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
|
||||
"dockerComposeFile": [
|
||||
"docker-compose.yml"
|
||||
],
|
||||
|
||||
// The 'service' property is the name of the service for the container that VS Code should
|
||||
// use. Update this value and .devcontainer/docker-compose.yml to the real service name.
|
||||
"service": "devcontainer",
|
||||
|
||||
// The optional 'workspaceFolder' property is the path VS Code should open by default when
|
||||
// connected. This is typically a file mount in .devcontainer/docker-compose.yml
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"containerEnv": {
|
||||
"DATABASE_URL": "postgresql://tianji:tianji@postgres:5432/tianji",
|
||||
"JWT_SECRET": "tianji-any-string",
|
||||
"ALLOW_REGISTER": "false",
|
||||
"ALLOW_OPENAPI": "true"
|
||||
},
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [12345],
|
||||
|
||||
// Uncomment the next line if you want start specific services in your Docker Compose config.
|
||||
// "runServices": [],
|
||||
|
||||
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
|
||||
// "shutdownAction": "none",
|
||||
|
||||
// Uncomment the next line to run commands after the container is created.
|
||||
"postCreateCommand": "pnpm install && pnpm db:migrate:apply",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"maattdd.gitless",
|
||||
"esbenp.prettier-vscode",
|
||||
"njzy.stats-bar",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "devcontainer"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
version: '3.5'
|
||||
services:
|
||||
# Update this to the name of the service you want to work with in your docker-compose.yml file
|
||||
devcontainer:
|
||||
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
|
||||
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
|
||||
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
|
||||
# array). The sample below assumes your primary file is in the root of your project.
|
||||
#
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
|
||||
volumes:
|
||||
# Update this to wherever you want VS Code to mount the folder of your project
|
||||
- ..:/workspaces:cached
|
||||
|
||||
# Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
|
||||
# cap_add:
|
||||
# - SYS_PTRACE
|
||||
# security_opt:
|
||||
# - seccomp:unconfined
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: /bin/sh -c "while sleep 1000; do :; done"
|
||||
|
||||
# Database
|
||||
postgres:
|
||||
image: postgres:15.4-alpine
|
||||
environment:
|
||||
POSTGRES_DB: tianji
|
||||
POSTGRES_USER: tianji
|
||||
POSTGRES_PASSWORD: tianji
|
||||
volumes:
|
||||
- tianji-db-data:/var/lib/postgresql/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
tianji-db-data:
|
||||
@@ -0,0 +1,8 @@
|
||||
apps
|
||||
example
|
||||
docker
|
||||
node_modules
|
||||
packages/*
|
||||
!packages/client-sdk
|
||||
!packages/client-sdk/**
|
||||
website
|
||||
@@ -0,0 +1,20 @@
|
||||
# postgresql url
|
||||
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
|
||||
|
||||
# Whether allow feature
|
||||
ALLOW_REGISTER=false
|
||||
ALLOW_OPENAPI=true
|
||||
|
||||
# For analyze tianji self
|
||||
WEBSITE_ID=
|
||||
|
||||
# For secury
|
||||
JWT_SECRET=replace-with-random-string # default is `daily string`
|
||||
JWT_ISSUER= # default is `tianji.msgbyte.com`
|
||||
JWT_AUDIENCE= # default is `msgbyte.com`
|
||||
|
||||
# ClickHouse
|
||||
CLICKHOUSE_URL=
|
||||
CLICKHOUSE_USER=
|
||||
CLICKHOUSE_PASSWORD=
|
||||
CLICKHOUSE_DATABASE=
|
||||
@@ -0,0 +1,38 @@
|
||||
name: "Docker Build CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
id: qemu
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: moonrailgun/tianji
|
||||
tags: |
|
||||
type=sha
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
# context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,44 @@
|
||||
name: "CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.27.0
|
||||
run_install: false
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v5
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Check Type
|
||||
run: pnpm check:type
|
||||
- name: Check Build
|
||||
run: pnpm build
|
||||
@@ -0,0 +1,26 @@
|
||||
name: "Deployment Website"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "website/**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: prj_V3iY3AcH0wIXxMHW4oDy3W3XzfnN
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
- name: Deploy to Vercel
|
||||
run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
@@ -0,0 +1,54 @@
|
||||
name: "Docker Publish Canary"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4 # peter-evans/dockerhub-description need checkout README.md
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
id: qemu
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
moonrailgun/tianji
|
||||
ghcr.io/msgbyte/tianji
|
||||
tags: |
|
||||
type=sha
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log into ghcr.io registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
# context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: VERSION=canary-${{ steps.meta.outputs.tags }}
|
||||
@@ -0,0 +1,64 @@
|
||||
name: "Docker Publish"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4 # peter-evans/dockerhub-description need checkout README.md
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
id: qemu
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
moonrailgun/tianji
|
||||
ghcr.io/msgbyte/tianji
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log into ghcr.io registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
# context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Update repo description
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: moonrailgun/tianji
|
||||
readme-filepath: ./README.md
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Build Reporter Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
build-go-binary:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, windows, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
- goarch: arm64
|
||||
goos: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: wangyoucao577/go-release-action@v1.40
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
project_path: ./reporter
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
goversion: 1.25.11
|
||||
binary_name: "tianji-reporter"
|
||||
compress_assets: "OFF"
|
||||
asset_name: "tianji-reporter-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
build-go-binary-static:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: wangyoucao577/go-release-action@v1.40
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: linux
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
project_path: ./reporter
|
||||
ldflags: '-extldflags "-static"'
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
goversion: 1.25.11
|
||||
binary_name: "tianji-reporter"
|
||||
compress_assets: "OFF"
|
||||
asset_name: "tianji-reporter-${{ matrix.goos }}-${{ matrix.goarch }}-alpine"
|
||||
@@ -0,0 +1,32 @@
|
||||
.env
|
||||
src/client/public/tracker.js
|
||||
geo
|
||||
docs/superpowers/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
@@ -0,0 +1,2 @@
|
||||
package-manager-strict=true
|
||||
package-manager-strict-version=false
|
||||
@@ -0,0 +1 @@
|
||||
src/client/routeTree.gen.ts
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"jsxBracketSameLine": false,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true
|
||||
},
|
||||
"git": {
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"tag": true,
|
||||
"tagName": "v${version}"
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"hooks": {
|
||||
"after:bump": "echo Version Upgrade Success. checkout more in CHANGELOG"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"infile": "CHANGELOG.md",
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"section": "Document"
|
||||
},
|
||||
{
|
||||
"type": "refactor",
|
||||
"section": "Others"
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"section": "Others"
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"section": "Others"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Contributor Guide
|
||||
|
||||
## Development Tips
|
||||
- Install Node.js 22.14.0+ and pnpm 9.7.1.
|
||||
- Run `pnpm install` in the repo root to bootstrap all workspace packages.
|
||||
- Use `pnpm dev` to start the server and client concurrently for local development.
|
||||
- Run `pnpm build` to generate production assets.
|
||||
|
||||
## Translation Files
|
||||
When generating code, **do not modify** any JSON files in `src/client/public/locales`. These translations are managed separately.
|
||||
|
||||
## Testing Instructions
|
||||
- CI configuration is under `.github/workflows`.
|
||||
- Run `pnpm check:type` and `pnpm build` to mirror CI checks.
|
||||
- Execute `pnpm test` to run Vitest across packages (or `pnpm -r test` for individual packages).
|
||||
- Focus on one test with `pnpm vitest run -t "<test name>"`.
|
||||
|
||||
## PR instructions
|
||||
- Title your PR using Angular commit style, e.g. `feat: add new feature`.
|
||||
@@ -0,0 +1,92 @@
|
||||
# tianji reporter
|
||||
FROM golang:1.25.11-bookworm AS reporter
|
||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./reporter/ ./reporter/
|
||||
|
||||
RUN apt update
|
||||
RUN cd reporter && CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o tianji-reporter .
|
||||
|
||||
# Base ------------------------------
|
||||
# Pin below Alpine 3.24 until Docker Hub static scans handle it reliably.
|
||||
FROM node:22.22-alpine3.23 AS base
|
||||
|
||||
RUN npm install -g pnpm@10.27.0
|
||||
|
||||
# For apprise and Prisma
|
||||
RUN apk add --update --no-cache python3 py3-pip g++ make openssl
|
||||
|
||||
# For puppeteer
|
||||
RUN apk upgrade --no-cache --available glib \
|
||||
&& apk add --no-cache \
|
||||
chromium-swiftshader \
|
||||
ttf-freefont \
|
||||
font-noto-emoji \
|
||||
&& apk add --no-cache \
|
||||
--repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \
|
||||
font-wqy-zenhei
|
||||
|
||||
# For zeromq
|
||||
RUN apk add --update --no-cache curl cmake
|
||||
|
||||
# Tianji frontend ------------------------------
|
||||
FROM base AS static
|
||||
WORKDIR /app/tianji
|
||||
|
||||
# use with --build-arg VERSION=xxxx
|
||||
ARG VERSION
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --filter @tianji/client... --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||
|
||||
ENV VITE_VERSION=$VERSION
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
RUN pnpm build:static
|
||||
|
||||
# Tianji server ------------------------------
|
||||
FROM base AS app
|
||||
WORKDIR /app/tianji
|
||||
|
||||
# We don't need the standalone Chromium in alpine.
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --filter @tianji/server... --config.dedupe-peer-dependents=false
|
||||
|
||||
RUN mkdir -p ./src/server/public
|
||||
COPY --from=static /app/tianji/geo /app/tianji/geo
|
||||
COPY --from=static /app/tianji/src/server/public /app/tianji/src/server/public
|
||||
|
||||
# Copy reporter binary from reporter stage
|
||||
COPY --from=reporter /app/reporter/tianji-reporter /usr/local/bin/tianji-reporter
|
||||
RUN chmod +x /usr/local/bin/tianji-reporter
|
||||
|
||||
RUN pnpm build:server
|
||||
|
||||
RUN CI=true pnpm prune --prod --config.dedupe-peer-dependents=false
|
||||
RUN CI=true pnpm install --filter @tianji/server... --prod --offline --ignore-scripts --config.dedupe-peer-dependents=false
|
||||
|
||||
RUN pip install apprise cryptography --break-system-packages
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm \
|
||||
/usr/local/lib/node_modules/pnpm \
|
||||
/usr/local/bin/npm \
|
||||
/usr/local/bin/npx \
|
||||
/usr/local/bin/pnpm \
|
||||
/usr/local/bin/pnpx
|
||||
|
||||
RUN rm -rf ./src/client
|
||||
RUN rm -rf ./website
|
||||
RUN rm -rf ./reporter
|
||||
|
||||
EXPOSE 12345
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:12345/health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "(cd /app/tianji/src/server && ./node_modules/.bin/prisma migrate deploy && ./node_modules/.bin/tsx ./clickhouse/scripts/apply.ts && NODE_ENV=production node ./dist/src/server/main.js) & sleep 10; /usr/local/bin/tianji-reporter --url \"http://localhost:12345\" --workspace \"clnzoxcy10001vy2ohi4obbi0\" --name \"tianji-container\" --silent > /dev/null & wait -n"]
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,103 @@
|
||||
[](https://github.com/msgbyte/tianji/actions/workflows/ci.yaml)
|
||||
[](https://github.com/msgbyte/tianji/actions/workflows/reporter-release.yml)
|
||||
[](https://github.com/msgbyte/tianji/actions/workflows/ci-docker.yaml)
|
||||

|
||||

|
||||

|
||||
|
||||
# Tianji
|
||||
|
||||
<img src="./website/static/img/logo.svg" width="128" />
|
||||
|
||||
**All-in-One Insight Hub**
|
||||
|
||||
`Website analytics` + `Uptime Monitor` + `Server Status` = `Tianji`
|
||||
|
||||
All in one project!
|
||||
|
||||
## Motivation
|
||||
|
||||
During our observations of the website. We often need to use multiple applications together. For example, we need analysis tools such as `GA`/`umami` to check pv/uv and the number of visits to each page, we need an uptime monitor to check the network quality and connectivity of the server, and we need to use prometheus to obtain the status reported by the server to check the quality of the server. In addition, if we develop an application that allows open source deployment, we often need a telemetry system to help us collect the simplest information about other people's deployment situations.
|
||||
|
||||
I think these tools should serve the same purpose, so is there an application that can integrate these common needs in a lightweight way? After all, most of the time we don't need very professional and in-depth functions. But in order to achieve comprehensive monitoring, I need to install so many services.
|
||||
|
||||
It's good to specialize in one thing, if we are experts in related abilities we need such specialized tools. But for most users who only have lightweight needs, an **All-in-One** application will be more convenient and easier to use.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] website analysis
|
||||
- [x] monitor
|
||||
- [x] support passive reception of results
|
||||
- [x] server status
|
||||
- [x] problem notification
|
||||
- [x] telemetry
|
||||
- [x] openapi
|
||||
- [x] website
|
||||
- [x] team collaboration
|
||||
- [x] utm track
|
||||
- [x] waitlist
|
||||
- [x] survey
|
||||
- [ ] survey page
|
||||
- [x] lighthouse report
|
||||
- [x] hooks
|
||||
- [x] helm install support
|
||||
- [x] allow install from public
|
||||
- [x] improve monitor reporter usage
|
||||
- [x] uninstall guide
|
||||
- [x] download from server
|
||||
- [x] custom params guide
|
||||
|
||||
## Preview
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Translation
|
||||
|
||||
### Add a new translation
|
||||
|
||||
modify those file:
|
||||
- `src/client/i18next-toolkit.config.cjs` in this file, edit country code
|
||||
- `src/client/utils/i18n.ts` in this file, add for display
|
||||
|
||||
Then, run below code to auto generate
|
||||
|
||||
```bash
|
||||
cd src/client
|
||||
pnpm install
|
||||
pnpm run translation:extract
|
||||
pnpm run translation:translate # this will call chatgpt to run auto translation, so you need set env `OPENAPI_KEY` to make sure run correct
|
||||
```
|
||||
|
||||
Then manual check translation file in `src/client/public/locales`
|
||||
|
||||
### Improve translation
|
||||
|
||||
Direct update `src/client/public/locales`
|
||||
|
||||
## Open Source
|
||||
|
||||
`Tianji` is open source with `Apache 2.0` license.
|
||||
|
||||
And its inspired by `umami` license which under `MIT` and `uptime-kuma` which under `MIT` license too
|
||||
|
||||
### One-Click Deployment
|
||||
|
||||
[](https://www.hostinger.com/vps/docker-hosting?compose_url=https://github.com/msgbyte/tianji/)
|
||||
|
||||
[](https://cloud.sealos.io/?openapp=system-template%3FtemplateName%3Dtianji)
|
||||
|
||||
[](https://repocloud.io/details/?app_id=270)
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/msgbyte/tianji)
|
||||
|
||||
[](https://template.run.claw.cloud/?referralCode=TNW6NVWTLHPQ&openapp=system-fastdeploy%3FtemplateName%3Dtianji)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`msgbyte/tianji`
|
||||
- 原始仓库:https://github.com/msgbyte/tianji
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,4 @@
|
||||
published_reviews.json
|
||||
publisher.json
|
||||
config.json
|
||||
lib
|
||||
@@ -0,0 +1,3 @@
|
||||
## appstore review to tianji survey
|
||||
|
||||
Quick start and fork from `https://github.com/TradeMe/ReviewMe`
|
||||
@@ -0,0 +1,25 @@
|
||||
#! /usr/bin/env node
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var reviewer = require('../lib/index');
|
||||
var program = require('commander');
|
||||
|
||||
var configFile;
|
||||
var version = JSON.parse(fs.readFileSync(path.resolve(__dirname, './../package.json'), 'utf8')).version;
|
||||
|
||||
program
|
||||
.version(version, '-v, --version')
|
||||
.arguments('<file>')
|
||||
.action(function (file) {
|
||||
configFile = file;
|
||||
})
|
||||
.parse(process.argv);
|
||||
|
||||
if (typeof configFile === 'undefined') {
|
||||
console.error('No config file specified');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Loading config:', configFile)
|
||||
var config = JSON.parse(fs.readFileSync(configFile));
|
||||
reviewer.start(config);
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "tianji-appstore-review",
|
||||
"version": "1.0.4",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": {
|
||||
"appreview": "bin/appreview.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"lib"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx ./src/dev.ts",
|
||||
"build": "tsc",
|
||||
"prepare": "tsc",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "moonrailgun <moonrailgun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"app-store-scraper": "^0.18.0",
|
||||
"axios": "1.7.7",
|
||||
"commander": "^4.1.1",
|
||||
"google-play-scraper": "^9.1.1",
|
||||
"googleapis": "^144.0.0",
|
||||
"tianji-client-sdk": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import axios from 'axios';
|
||||
import { markReviewAsPublished, reviewPublished } from './reviews';
|
||||
import { regions } from './regions';
|
||||
import { AppInformation, AppstoreConfig, Review } from './types';
|
||||
import { postToTianji } from './tianji';
|
||||
|
||||
const DEFAULT_INTERVAL_SECONDS = 3600;
|
||||
|
||||
export const startReview = (
|
||||
config: AppstoreConfig,
|
||||
firstRun: boolean
|
||||
): void => {
|
||||
if (config.regions === true) {
|
||||
try {
|
||||
config.regions = regions;
|
||||
} catch (err) {
|
||||
config.regions = ['us'];
|
||||
}
|
||||
}
|
||||
|
||||
config.regions = config.regions || ['us'];
|
||||
config.interval = config.interval || DEFAULT_INTERVAL_SECONDS;
|
||||
|
||||
config.regions.forEach(async (region, i) => {
|
||||
try {
|
||||
const globalAppInformation = await fetchAppInformation(config, region);
|
||||
const appInformation = { ...globalAppInformation };
|
||||
|
||||
try {
|
||||
const reviews = await fetchAppStoreReviews(config, appInformation);
|
||||
if (firstRun) {
|
||||
reviews.forEach((review) => markReviewAsPublished(config, review));
|
||||
|
||||
if (config.dryRun && reviews.length > 0) {
|
||||
publishReview(
|
||||
appInformation,
|
||||
config,
|
||||
reviews[reviews.length - 1],
|
||||
true
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleFetchedAppStoreReviews(config, appInformation, reviews);
|
||||
}
|
||||
} catch (error) {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Failed to fetch App Store reviews (${config.appId}) (${appInformation.region})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const intervalSeconds =
|
||||
(config.interval ?? DEFAULT_INTERVAL_SECONDS) + i * 10;
|
||||
|
||||
setInterval(async () => {
|
||||
if (config.verbose) {
|
||||
console.log(`INFO: [${config.appId}] Fetching App Store reviews`);
|
||||
}
|
||||
|
||||
try {
|
||||
const reviews = await fetchAppStoreReviews(config, appInformation);
|
||||
handleFetchedAppStoreReviews(config, appInformation, reviews);
|
||||
} catch (error) {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Failed to fetch App Store reviews during interval (${config.appId}) (${appInformation.region})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}, intervalSeconds * 1000);
|
||||
} catch (error) {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Failed to fetch app information (${config.appId}) (${region})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAppStoreReviewsByPage = (
|
||||
config: AppstoreConfig,
|
||||
appInformation: AppInformation,
|
||||
page: number
|
||||
): Promise<Review[]> => {
|
||||
const url = `https://itunes.apple.com/${appInformation.region}/rss/customerreviews/page=${page}/id=${config.appId}/sortBy=mostRecent/json`;
|
||||
|
||||
return axios
|
||||
.get(url)
|
||||
.then((res) => {
|
||||
const rss = res.data;
|
||||
const entries = rss.feed.entry;
|
||||
if (!entries || entries.length === 0) {
|
||||
if (config.verbose) {
|
||||
console.log(
|
||||
`INFO: No reviews from App Store (${config.appId}) (${appInformation.region})`
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (config.verbose) {
|
||||
console.log(
|
||||
`INFO: Received reviews from App Store (${config.appId}) (${appInformation.region})`
|
||||
);
|
||||
}
|
||||
|
||||
const reviews = entries
|
||||
.filter((entry: any) => !isAppInformationEntry(entry))
|
||||
.reverse()
|
||||
.map((entry: any) => parseAppStoreReview(entry, appInformation));
|
||||
|
||||
return reviews;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Error fetching reviews from App Store (${config.appId}) (${appInformation.region})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchAppStoreReviews = async (
|
||||
config: AppstoreConfig,
|
||||
appInformation: AppInformation
|
||||
): Promise<Review[]> => {
|
||||
let page = 1;
|
||||
const allReviews: Review[] = [];
|
||||
|
||||
while (page <= 10) {
|
||||
try {
|
||||
const reviews = await fetchAppStoreReviewsByPage(
|
||||
config,
|
||||
appInformation,
|
||||
page
|
||||
);
|
||||
allReviews.push(...reviews);
|
||||
|
||||
if (reviews.length === 0) {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Failed to fetch App Store reviews for page ${page} (${config.appId}) (${appInformation.region})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
// Continue to next page even if current page fails
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return allReviews;
|
||||
};
|
||||
|
||||
export const handleFetchedAppStoreReviews = (
|
||||
config: AppstoreConfig,
|
||||
appInformation: AppInformation,
|
||||
reviews: Review[]
|
||||
): void => {
|
||||
if (config.verbose) {
|
||||
console.log(
|
||||
`INFO: Handling fetched reviews for (${config.appId}) (${appInformation.region})`
|
||||
);
|
||||
}
|
||||
reviews.forEach((review) =>
|
||||
publishReview(appInformation, config, review, false)
|
||||
);
|
||||
};
|
||||
|
||||
const parseAppStoreReview = (
|
||||
rssItem: any,
|
||||
appInformation: AppInformation
|
||||
): Review => {
|
||||
return {
|
||||
id: rssItem.id.label,
|
||||
version: rssItem['im:version']?.label,
|
||||
title: rssItem.title.label,
|
||||
text: rssItem.content.label,
|
||||
rating: parseInt(rssItem['im:rating']?.label || '-1', 10),
|
||||
author: rssItem.author?.name.label || '',
|
||||
link: rssItem.author?.uri.label || appInformation.appLink || '',
|
||||
storeName: 'App Store',
|
||||
};
|
||||
};
|
||||
|
||||
const publishReview = (
|
||||
appInformation: AppInformation,
|
||||
config: AppstoreConfig,
|
||||
review: Review,
|
||||
force: boolean
|
||||
): void => {
|
||||
if (!reviewPublished(config, review) || force) {
|
||||
if (config.verbose) {
|
||||
console.log(`INFO: New review: ${JSON.stringify(review)}`);
|
||||
}
|
||||
|
||||
postToTianji(review, config, appInformation);
|
||||
markReviewAsPublished(config, review);
|
||||
} else {
|
||||
if (config.verbose) {
|
||||
console.log(`INFO: Review already published: ${review.text}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchAppInformation = (
|
||||
config: AppstoreConfig,
|
||||
region: string
|
||||
): Promise<AppInformation> => {
|
||||
const url = `https://itunes.apple.com/lookup?id=${config.appId}&country=${region}`;
|
||||
const appInformation: AppInformation = {
|
||||
// appName: config.appName,
|
||||
// appIcon: config.appIcon,
|
||||
// appLink: config.appLink,
|
||||
appName: '',
|
||||
appIcon: '',
|
||||
appLink: '',
|
||||
region,
|
||||
};
|
||||
|
||||
return axios
|
||||
.get(url)
|
||||
.then(({ data }) => {
|
||||
const entries = data.results;
|
||||
if (!entries || entries.length === 0) {
|
||||
if (config.verbose)
|
||||
console.log(`INFO: No data from App Store (${config.appId})`);
|
||||
return appInformation;
|
||||
}
|
||||
|
||||
if (config.verbose)
|
||||
console.log(`INFO: Received app data from App Store (${config.appId})`);
|
||||
const entry = entries[0];
|
||||
appInformation.appName =
|
||||
appInformation.appName || entry.trackCensoredName;
|
||||
appInformation.appIcon = appInformation.appIcon || entry.artworkUrl100;
|
||||
appInformation.appLink = appInformation.appLink || entry.trackViewUrl;
|
||||
|
||||
return appInformation;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Error fetching app data from App Store (${config.appId})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
return appInformation;
|
||||
});
|
||||
};
|
||||
|
||||
const isAppInformationEntry = (entry: any): boolean => {
|
||||
return !!entry['im:name'];
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const REVIEWS_LIMIT = 1000;
|
||||
export const DEFAULT_INTERVAL_SECONDS = 300;
|
||||
|
||||
export const REVIEWS_STORES = {
|
||||
APP_STORE: 'app-store',
|
||||
GOOGLE_PLAY: 'google-play',
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { start } from './';
|
||||
import fs from 'fs';
|
||||
|
||||
var config = JSON.parse(String(fs.readFileSync('./config.json')));
|
||||
start(config);
|
||||
@@ -0,0 +1,201 @@
|
||||
import { google } from 'googleapis';
|
||||
// @ts-ignore
|
||||
import playScraper from 'google-play-scraper';
|
||||
import fs from 'fs';
|
||||
import { markReviewAsPublished, reviewPublished } from './reviews';
|
||||
import { DEFAULT_INTERVAL_SECONDS } from './const';
|
||||
import { AppInformation, GooglePlayConfig, Review } from './types';
|
||||
import { postToTianji } from './tianji';
|
||||
|
||||
export const startReview = async (
|
||||
config: GooglePlayConfig,
|
||||
firstRun: boolean
|
||||
): Promise<void> => {
|
||||
const appInformation: AppInformation = {
|
||||
appName: '',
|
||||
appIcon: '',
|
||||
};
|
||||
|
||||
try {
|
||||
// Scrape Google Play for app information
|
||||
const appData = await playScraper.app({ appId: config.appId });
|
||||
appInformation.appName = appData.title;
|
||||
appInformation.appIcon = appData.icon;
|
||||
|
||||
try {
|
||||
const reviews = await fetchGooglePlayReviews(config, appInformation);
|
||||
if (firstRun) {
|
||||
reviews.forEach((review) => {
|
||||
markReviewAsPublished(config, review);
|
||||
});
|
||||
|
||||
if (config.dryRun && reviews.length > 0) {
|
||||
publishReview(
|
||||
appInformation,
|
||||
config,
|
||||
reviews[reviews.length - 1],
|
||||
true
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleFetchedGooglePlayReviews(config, appInformation, reviews);
|
||||
}
|
||||
} catch (error) {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Failed to fetch Google Play reviews (${config.appId})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const intervalSeconds = config.interval || DEFAULT_INTERVAL_SECONDS;
|
||||
setInterval(async () => {
|
||||
if (config.verbose)
|
||||
console.log(`INFO: [${config.appId}] Fetching Google Play reviews`);
|
||||
|
||||
try {
|
||||
const reviews = await fetchGooglePlayReviews(config, appInformation);
|
||||
handleFetchedGooglePlayReviews(config, appInformation, reviews);
|
||||
} catch (error) {
|
||||
if (config.verbose) {
|
||||
console.error(
|
||||
`ERROR: Failed to fetch Google Play reviews during interval (${config.appId})`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}, intervalSeconds * 1000);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`ERROR: [${config.appId}] Could not scrape Google Play, ${error}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const publishReview = (
|
||||
appInformation: AppInformation,
|
||||
config: GooglePlayConfig,
|
||||
review: Review,
|
||||
force: boolean
|
||||
): void => {
|
||||
if (!reviewPublished(config, review) || force) {
|
||||
if (config.verbose) {
|
||||
console.log(`INFO: Received new review: ${JSON.stringify(review)}`);
|
||||
}
|
||||
|
||||
postToTianji(review, config, appInformation);
|
||||
markReviewAsPublished(config, review);
|
||||
} else {
|
||||
if (config.verbose)
|
||||
console.log(`INFO: Review already published: ${review.text}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFetchedGooglePlayReviews = (
|
||||
config: GooglePlayConfig,
|
||||
appInformation: AppInformation,
|
||||
reviews: Review[]
|
||||
): void => {
|
||||
if (config.verbose) {
|
||||
console.log(`INFO: [${config.appId}] Handling fetched reviews`);
|
||||
}
|
||||
|
||||
reviews.forEach((review) => {
|
||||
publishReview(appInformation, config, review, false);
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchGooglePlayReviews = (
|
||||
config: GooglePlayConfig,
|
||||
appInformation: AppInformation
|
||||
): Promise<Review[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (config.verbose)
|
||||
console.log(`INFO: Fetching Google Play reviews for ${config.appId}`);
|
||||
|
||||
const scopes = ['https://www.googleapis.com/auth/androidpublisher'];
|
||||
let publisherJson: any;
|
||||
|
||||
if (typeof config.publisherKey === 'object') {
|
||||
publisherJson = config.publisherKey;
|
||||
} else {
|
||||
try {
|
||||
publisherJson = JSON.parse(
|
||||
fs.readFileSync(config.publisherKey, 'utf8')
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let jwt;
|
||||
try {
|
||||
jwt = new google.auth.JWT(
|
||||
publisherJson.client_id,
|
||||
undefined,
|
||||
publisherJson.private_key,
|
||||
scopes
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
|
||||
jwt.authorize((err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
google.androidpublisher('v3').reviews.list(
|
||||
{
|
||||
auth: jwt,
|
||||
packageName: config.appId,
|
||||
},
|
||||
(err, resp) => {
|
||||
if (err) {
|
||||
console.error(
|
||||
`ERROR: [${config.appId}] Could not fetch Google Play reviews, ${err}`
|
||||
);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.verbose)
|
||||
console.log(
|
||||
`INFO: [${config.appId}] Received reviews from Google Play`
|
||||
);
|
||||
|
||||
if (!resp?.data?.reviews) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const reviews = resp.data.reviews.map((review) => {
|
||||
const comment = review.comments?.[0].userComment;
|
||||
|
||||
return {
|
||||
id: String(review.reviewId),
|
||||
author: String(review.authorName),
|
||||
version: String(comment?.appVersionName),
|
||||
versionCode: Number(comment?.appVersionCode),
|
||||
osVersion: Number(comment?.androidOsVersion),
|
||||
device: String(comment?.deviceMetadata?.productName),
|
||||
text: String(comment?.text),
|
||||
rating: Number(comment?.starRating),
|
||||
link: `https://play.google.com/store/apps/details?id=${config.appId}&reviewId=${review.reviewId}`,
|
||||
storeName: 'Google Play',
|
||||
} satisfies Review;
|
||||
});
|
||||
|
||||
resolve(reviews);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as reviews from './reviews';
|
||||
import { Config } from './types';
|
||||
|
||||
export function start(config: Config) {
|
||||
for (let i = 0; i < config.apps.length; i++) {
|
||||
const app = config.apps[i];
|
||||
|
||||
reviews.start({
|
||||
verbose: config.verbose,
|
||||
dryRun: config.dryRun,
|
||||
interval: config.interval,
|
||||
tianji: config.tianji,
|
||||
|
||||
// @ts-ignore
|
||||
publisherKey: app.publisherKey,
|
||||
appId: app.appId,
|
||||
store: app.store,
|
||||
// @ts-ignore
|
||||
regions: app.regions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
export const regions = [
|
||||
'cl',
|
||||
'us',
|
||||
'bo',
|
||||
'bh',
|
||||
'az',
|
||||
'fm',
|
||||
'dz',
|
||||
'bd',
|
||||
'co',
|
||||
'bz',
|
||||
'at',
|
||||
'mz',
|
||||
'ai',
|
||||
'au',
|
||||
'cz',
|
||||
'ca',
|
||||
'kn',
|
||||
'bf',
|
||||
'by',
|
||||
'al',
|
||||
'pa',
|
||||
'fr',
|
||||
'ie',
|
||||
'bm',
|
||||
'mu',
|
||||
'ms',
|
||||
'ne',
|
||||
'bb',
|
||||
'bw',
|
||||
'ec',
|
||||
'uy',
|
||||
'sb',
|
||||
'pt',
|
||||
'ke',
|
||||
'si',
|
||||
'bg',
|
||||
'sa',
|
||||
'ag',
|
||||
'cv',
|
||||
'se',
|
||||
'bj',
|
||||
'mt',
|
||||
'td',
|
||||
'ye',
|
||||
'cm',
|
||||
'vn',
|
||||
'lr',
|
||||
'bs',
|
||||
'is',
|
||||
'il',
|
||||
'ky',
|
||||
'sl',
|
||||
'no',
|
||||
'hr',
|
||||
'kh',
|
||||
'ar',
|
||||
'vc',
|
||||
'ug',
|
||||
'py',
|
||||
'kg',
|
||||
'nl',
|
||||
'mx',
|
||||
'tt',
|
||||
'lt',
|
||||
'cg',
|
||||
'bt',
|
||||
'my',
|
||||
'jp',
|
||||
'gy',
|
||||
'ci',
|
||||
'ph',
|
||||
'sg',
|
||||
'tz',
|
||||
'na',
|
||||
'lb',
|
||||
'ly',
|
||||
'ni',
|
||||
'qa',
|
||||
'pg',
|
||||
'lv',
|
||||
'kz',
|
||||
'dk',
|
||||
'la',
|
||||
'bn',
|
||||
'gh',
|
||||
'tr',
|
||||
'th',
|
||||
'be',
|
||||
'mg',
|
||||
'uz',
|
||||
'cy',
|
||||
'md',
|
||||
've',
|
||||
'ee',
|
||||
'kr',
|
||||
'ro',
|
||||
'mm',
|
||||
'mv',
|
||||
'ch',
|
||||
'ao',
|
||||
'gd',
|
||||
'am',
|
||||
'tj',
|
||||
'de',
|
||||
'mk',
|
||||
'cn',
|
||||
'hn',
|
||||
'sv',
|
||||
'mn',
|
||||
'br',
|
||||
'do',
|
||||
'zw',
|
||||
'id',
|
||||
'sk',
|
||||
'st',
|
||||
'np',
|
||||
'pl',
|
||||
'vg',
|
||||
'gb',
|
||||
'gr',
|
||||
'pk',
|
||||
'hu',
|
||||
'nz',
|
||||
'dm',
|
||||
'mo',
|
||||
'et',
|
||||
'tn',
|
||||
'fi',
|
||||
'sn',
|
||||
'in',
|
||||
'tc',
|
||||
'gt',
|
||||
'lk',
|
||||
'jo',
|
||||
'it',
|
||||
'ru',
|
||||
'fj',
|
||||
'za',
|
||||
'rs',
|
||||
'kw',
|
||||
'sr',
|
||||
'ae',
|
||||
'cr',
|
||||
'mw',
|
||||
'ml',
|
||||
'gw',
|
||||
'sc',
|
||||
'li',
|
||||
'eg',
|
||||
'lu',
|
||||
'sz',
|
||||
'jm',
|
||||
'es',
|
||||
'ps',
|
||||
'hk',
|
||||
'ng',
|
||||
'pe',
|
||||
'ua',
|
||||
'pw',
|
||||
'lc',
|
||||
'tm',
|
||||
'gm',
|
||||
'om',
|
||||
'tw',
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as appstore from './appstorereviews';
|
||||
import * as googlePlay from './googleplayreviews';
|
||||
import fs from 'fs';
|
||||
import { AppstoreConfig, GooglePlayConfig } from './types';
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const REVIEWS_LIMIT = 5000;
|
||||
|
||||
let publishedReviews: Record<string, string[]>;
|
||||
|
||||
try {
|
||||
publishedReviews = JSON.parse(
|
||||
fs.readFileSync('./published_reviews.json', 'utf8')
|
||||
);
|
||||
} catch (err) {
|
||||
publishedReviews = {};
|
||||
}
|
||||
|
||||
export const start = (config: AppstoreConfig | GooglePlayConfig): void => {
|
||||
if (config.store === 'app-store') {
|
||||
appstore.startReview(config, !publishedReviews[config.appId]);
|
||||
console.log('Start Review:', config.store, config.appId);
|
||||
} else {
|
||||
googlePlay.startReview(config, !publishedReviews[config.appId]);
|
||||
console.log('Start Review:', config.store, config.appId);
|
||||
}
|
||||
};
|
||||
|
||||
export const markReviewAsPublished = (
|
||||
config: AppstoreConfig | GooglePlayConfig,
|
||||
review: Review
|
||||
): void => {
|
||||
if (!review || !review.id || reviewPublished(config, review)) return;
|
||||
|
||||
if (!publishedReviews[config.appId]) {
|
||||
publishedReviews[config.appId] = [];
|
||||
}
|
||||
|
||||
if (config.verbose) {
|
||||
console.log(
|
||||
`INFO: Checking if we need to prune published reviews have (${publishedReviews[config.appId].length}) limit (${REVIEWS_LIMIT})`
|
||||
);
|
||||
}
|
||||
|
||||
if (publishedReviews[config.appId].length >= REVIEWS_LIMIT) {
|
||||
publishedReviews[config.appId] = publishedReviews[config.appId].slice(
|
||||
0,
|
||||
REVIEWS_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
publishedReviews[config.appId].unshift(review.id);
|
||||
|
||||
if (config.verbose) {
|
||||
console.log(
|
||||
`INFO: Review marked as published: ${JSON.stringify(publishedReviews[config.appId])}`
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
'./published_reviews.json',
|
||||
JSON.stringify(publishedReviews),
|
||||
{ flag: 'w' }
|
||||
);
|
||||
};
|
||||
|
||||
export const reviewPublished = (
|
||||
config: AppstoreConfig | GooglePlayConfig,
|
||||
review: Review
|
||||
): boolean => {
|
||||
if (!review || !review.id || !publishedReviews[config.appId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return publishedReviews[config.appId].includes(review.id);
|
||||
};
|
||||
|
||||
export const getPublishedReviews = (): Record<string, string[]> => {
|
||||
return publishedReviews;
|
||||
};
|
||||
|
||||
export const resetPublishedReviews = (): Record<string, string[]> => {
|
||||
publishedReviews = {};
|
||||
return publishedReviews;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AppInformation, AppstoreConfig, GooglePlayConfig } from './types';
|
||||
import { submitSurvey, initOpenapiSDK } from 'tianji-client-sdk';
|
||||
|
||||
export async function postToTianji(
|
||||
review: any,
|
||||
config: GooglePlayConfig | AppstoreConfig,
|
||||
appInformation: AppInformation
|
||||
) {
|
||||
initOpenapiSDK(config.tianji.baseUrl);
|
||||
|
||||
const payload = {
|
||||
...review,
|
||||
...appInformation,
|
||||
};
|
||||
|
||||
console.log('Report to tianji:', payload);
|
||||
await submitSurvey(
|
||||
config.tianji.workspaceId,
|
||||
config.tianji.surveyId,
|
||||
payload
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type StoreType = 'app-store' | 'google-play';
|
||||
|
||||
interface TianjiConfig {
|
||||
baseUrl: string;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
tianji: TianjiConfig;
|
||||
apps: (
|
||||
| {
|
||||
appId: string;
|
||||
publisherKey: string;
|
||||
store: 'google-play';
|
||||
}
|
||||
| {
|
||||
appId: string;
|
||||
regions: string[] | false;
|
||||
store: 'app-store';
|
||||
}
|
||||
)[];
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface AppstoreConfig {
|
||||
tianji: TianjiConfig;
|
||||
appId: string;
|
||||
regions: string[] | true;
|
||||
store: 'app-store';
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface GooglePlayConfig {
|
||||
tianji: TianjiConfig;
|
||||
appId: string;
|
||||
publisherKey: string | object;
|
||||
store: 'google-play';
|
||||
verbose?: boolean;
|
||||
dryRun?: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface AppInformation {
|
||||
appName: string;
|
||||
appIcon: string;
|
||||
appLink?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
author: string;
|
||||
version: string;
|
||||
versionCode?: number;
|
||||
osVersion?: number;
|
||||
device?: string;
|
||||
title?: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
link: string;
|
||||
storeName: 'Google Play' | 'App Store';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
@@ -0,0 +1,172 @@
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# wrangler project
|
||||
|
||||
.dev.vars
|
||||
.wrangler/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"useTabs": true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Daily AI Trigger (for Tianji)
|
||||
|
||||
A Cloudflare Workers-based scheduled trigger project for executing timed Tianji Survey AI-related tasks and summary generation.
|
||||
|
||||
## Features
|
||||
|
||||
- Built on Cloudflare Workers
|
||||
- Supports scheduled task triggering
|
||||
- Developed with TypeScript
|
||||
|
||||
## Development Requirements
|
||||
|
||||
- Node.js
|
||||
- pnpm
|
||||
- Wrangler CLI (Cloudflare Workers CLI tool)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev:scheduled
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Deploy to Cloudflare Workers
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The project uses a `.dev.vars` file to manage local development environment variables. When deploying to production, ensure these environment variables are properly set in the Cloudflare Workers console.
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
#### Tianji Configuration
|
||||
- `BASE_URL`: Base URL for Tianji API
|
||||
- `API_KEY`: Tianji API key
|
||||
- `WORKSPACE_ID`: Workspace ID, can be found in workspace information
|
||||
- `SURVEY_ID`: Tianji Survey ID
|
||||
- `PAYLOAD_CONTENT_FIELD`: Field name for the main survey content (target for classification and translation)
|
||||
- `LANGUAGE`: Language setting, e.g., 'en', helps Tianji AI generate results in the corresponding language
|
||||
|
||||
#### Feishu Configuration (Optional)
|
||||
- `FEISHU_WEBHOOK`: Feishu bot webhook URL
|
||||
- `FEISHU_APP_ID`: Feishu application ID
|
||||
- `FEISHU_APP_SECRET`: Feishu application secret
|
||||
- `FEISHU_TABLE_APPTOKEN`: Feishu multi-dimensional table AppToken
|
||||
- `FEISHU_TABLE_ID`: Feishu multi-dimensional table ID
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Create a `.dev.vars` file in the project root directory
|
||||
2. Copy the above environment variables and fill in their respective values
|
||||
|
||||
### Production Environment
|
||||
|
||||
In the Cloudflare Workers console, configure the same environment variables. Ensure all required environment variables are properly set.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/` - Source code directory
|
||||
- `wrangler.jsonc` - Cloudflare Workers configuration file
|
||||
- `worker-configuration.d.ts` - Workers type definition file
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Vitest](https://vitest.dev/)
|
||||
- [dayjs](https://day.js.org/)
|
||||
- [tianji-client-sdk](https://github.com/msgbyte/tianji)
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "daily-ai-trigger",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"dev:scheduled": "wrangler dev --test-scheduled",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.7.5",
|
||||
"@cloudflare/workers-types": "^4.20250310.0",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "~3.0.8",
|
||||
"wrangler": "^4.20.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.9",
|
||||
"tianji-client-sdk": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { openApiClient } from 'tianji-client-sdk';
|
||||
import { addFeishuBitableRecord, notifyToFeishu } from './notify/feishu';
|
||||
|
||||
export async function handleTriggerAITask(env: Env) {
|
||||
const workspaceId = env.WORKSPACE_ID;
|
||||
const surveyId = env.SURVEY_ID;
|
||||
const payloadContentField = env.PAYLOAD_CONTENT_FIELD;
|
||||
|
||||
let suggestionCategory: string[] = [];
|
||||
if (typeof env.FOCUS_CATEGORY === 'string' && env.FOCUS_CATEGORY.length > 0) {
|
||||
suggestionCategory = env.FOCUS_CATEGORY.split(',');
|
||||
} else {
|
||||
const survey = await openApiClient.SurveyService.surveyGet({
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
suggestionCategory = survey?.recentSuggestionCategory || [];
|
||||
}
|
||||
|
||||
const startAt = dayjs().subtract(1, 'day').startOf('day').valueOf();
|
||||
const endAt = dayjs().endOf('day').valueOf();
|
||||
|
||||
await Promise.all([
|
||||
await openApiClient.AiService.aiClassifySurvey({
|
||||
requestBody: {
|
||||
workspaceId,
|
||||
surveyId,
|
||||
startAt,
|
||||
endAt,
|
||||
payloadContentField,
|
||||
suggestionCategory,
|
||||
languageStrategy: 'user',
|
||||
runStrategy: 'skipInSuggest',
|
||||
},
|
||||
}),
|
||||
await openApiClient.AiService.aiTranslateSurvey({
|
||||
requestBody: {
|
||||
workspaceId,
|
||||
surveyId,
|
||||
startAt,
|
||||
endAt,
|
||||
payloadContentField,
|
||||
languageStrategy: 'user',
|
||||
runStrategy: 'skipExist',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('Run aiClassifySurvey completed.');
|
||||
}
|
||||
|
||||
export async function handleCreateDailyReport(env: Env) {
|
||||
console.log('handleCreateDailyReport');
|
||||
|
||||
const workspaceId = env.WORKSPACE_ID;
|
||||
const surveyId = env.SURVEY_ID;
|
||||
|
||||
const startDate = dayjs().subtract(1, 'day').startOf('day');
|
||||
const endDate = dayjs().subtract(1, 'day').endOf('day');
|
||||
|
||||
const { items } = await openApiClient.SurveyService.surveyResultList({
|
||||
workspaceId,
|
||||
surveyId,
|
||||
startAt: startDate.valueOf(),
|
||||
endAt: endDate.valueOf(),
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const resultList = items.filter((item) => Boolean(item.aiCategory));
|
||||
|
||||
const title = `[${startDate.format('YYYY-MM-DD HH:mm:ss')} ~ ${endDate.format('HH:mm:ss')}] Survey Daily Report`;
|
||||
|
||||
let content = '';
|
||||
|
||||
const categoryCount: { [category: string]: number } = {};
|
||||
resultList.forEach((record) => {
|
||||
const category = record.aiCategory || 'Uncategorized';
|
||||
categoryCount[category] = (categoryCount[category] || 0) + 1;
|
||||
});
|
||||
|
||||
const sortedCategories = Object.entries(categoryCount).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
content += `All Categories(${resultList.length}):\n`;
|
||||
sortedCategories.forEach(([category, count]) => {
|
||||
content += `- ${category}(${count})\n`;
|
||||
});
|
||||
|
||||
// 3. Get top 3 categories with the most records
|
||||
const top3Categories = sortedCategories.slice(0, 3);
|
||||
|
||||
content += `\n\nTop Issues:\n`;
|
||||
top3Categories.forEach(([category, count]) => {
|
||||
// Filter all records of the corresponding category
|
||||
const records = resultList.filter((record) => record.aiCategory === category);
|
||||
// Sample 3 records (or return all if less than 3)
|
||||
const sampledRecords = sampleItems(records, 3);
|
||||
|
||||
content += `- ${category} (${count})\n`;
|
||||
sampledRecords.forEach((record) => {
|
||||
content += ` - ${record.aiTranslation}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
notifyToFeishu(env, title, content),
|
||||
addFeishuBitableRecord(
|
||||
env,
|
||||
items.map((item) => ({
|
||||
id: item.id,
|
||||
content: item.aiTranslation ?? '',
|
||||
category: item.aiCategory ?? '',
|
||||
createdAt: item.createdAt ?? dayjs(),
|
||||
})),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Randomly sample a specified number of elements from an array
|
||||
*/
|
||||
function sampleItems<T>(items: T[], sampleSize: number): T[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy.slice(0, sampleSize);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Welcome to Cloudflare Workers! This is your first worker.
|
||||
*
|
||||
* - Run `npm run dev` in your terminal to start a development server
|
||||
* - Open a browser tab at http://localhost:8787/ to see your worker in action
|
||||
* - Run `npm run deploy` to publish your worker
|
||||
*
|
||||
* Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the
|
||||
* `Env` object can be regenerated with `npm run cf-typegen`.
|
||||
*
|
||||
* Learn more at https://developers.cloudflare.com/workers/
|
||||
*/
|
||||
|
||||
import { initOpenapiSDK } from 'tianji-client-sdk';
|
||||
import { handleCreateDailyReport, handleTriggerAITask } from './handler';
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx): Promise<Response> {
|
||||
initOpenapiSDK(env.BASE_URL, {
|
||||
apiKey: env.API_KEY,
|
||||
header: {
|
||||
'accept-language': env.LANGUAGE ?? 'en',
|
||||
},
|
||||
});
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/__/handleTriggerAITask') {
|
||||
await handleTriggerAITask(env);
|
||||
return new Response('handleTriggerAITask DONE');
|
||||
}
|
||||
|
||||
if (url.pathname === '/__/handleCreateDailyReport') {
|
||||
await handleCreateDailyReport(env);
|
||||
return new Response('handleCreateDailyReport DONE');
|
||||
}
|
||||
|
||||
return new Response('Hello World!');
|
||||
},
|
||||
async scheduled(controller, env, ctx) {
|
||||
initOpenapiSDK(env.BASE_URL, {
|
||||
apiKey: env.API_KEY,
|
||||
header: {
|
||||
'accept-language': env.LANGUAGE ?? 'en',
|
||||
},
|
||||
});
|
||||
|
||||
switch (controller.cron) {
|
||||
case '0 1 * * *':
|
||||
await handleTriggerAITask(env);
|
||||
break;
|
||||
case '0 2 * * *':
|
||||
await handleCreateDailyReport(env);
|
||||
break;
|
||||
}
|
||||
console.log('cron processed');
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
@@ -0,0 +1,103 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export async function notifyToFeishu(env: Env, title: string, content: string) {
|
||||
const feishuWebhook = env.FEISHU_WEBHOOK;
|
||||
|
||||
if (!feishuWebhook) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendRequestToFeishu(feishuWebhook, {
|
||||
msg_type: 'interactive',
|
||||
card: {
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content,
|
||||
},
|
||||
],
|
||||
header: {
|
||||
title: {
|
||||
content: title,
|
||||
tag: 'plain_text',
|
||||
},
|
||||
template: 'yellow',
|
||||
text_tag_list: [
|
||||
{
|
||||
tag: 'text_tag',
|
||||
text: {
|
||||
tag: 'plain_text',
|
||||
content: 'Tianji Agent',
|
||||
},
|
||||
color: 'neutral',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference: https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/title
|
||||
*/
|
||||
async function sendRequestToFeishu(feishuWebhook: string, body: any) {
|
||||
await fetch(feishuWebhook, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal
|
||||
*/
|
||||
async function getFeishuTenantAccessToken(env: Env) {
|
||||
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
app_id: env.FEISHU_APP_ID,
|
||||
app_secret: env.FEISHU_APP_SECRET,
|
||||
}),
|
||||
}).then((res) => res.json<any>());
|
||||
|
||||
if (!res.tenant_access_token) {
|
||||
throw new Error('Get tenant access token failed');
|
||||
}
|
||||
|
||||
const token = res.tenant_access_token;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function addFeishuBitableRecord(env: Env, records: { id: string; content: string; category: string; createdAt: string }[]) {
|
||||
if (!env.FEISHU_TABLE_APPTOKEN || !env.FEISHU_TABLE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getFeishuTenantAccessToken(env);
|
||||
await fetch(
|
||||
`https://open.feishu.cn/open-apis/bitable/v1/apps/${env.FEISHU_TABLE_APPTOKEN}/tables/${env.FEISHU_TABLE_ID}/records/batch_create`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
records: records.map((r) => ({
|
||||
fields: {
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
category: r.category,
|
||||
createdAt: dayjs(r.createdAt).valueOf(),
|
||||
},
|
||||
})),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// test/index.spec.ts
|
||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import worker from '../src/index';
|
||||
|
||||
// For now, you'll need to do something like this to get a correctly-typed
|
||||
// `Request` to pass to `worker.fetch()`.
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
|
||||
|
||||
describe('Hello World worker', () => {
|
||||
it('responds with Hello World! (unit style)', async () => {
|
||||
const request = new IncomingRequest('http://example.com');
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
|
||||
it('responds with Hello World! (integration style)', async () => {
|
||||
const response = await SELF.fetch('https://example.com');
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers"]
|
||||
},
|
||||
"include": ["./**/*.ts", "../worker-configuration.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
"target": "es2021",
|
||||
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
"lib": ["es2021"],
|
||||
/* Specify what JSX code is generated. */
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Specify what module code is generated. */
|
||||
"module": "es2022",
|
||||
/* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"moduleResolution": "Bundler",
|
||||
/* Specify type package names to be included without being referenced in a source file. */
|
||||
"types": [
|
||||
"@cloudflare/workers-types/2023-07-01"
|
||||
],
|
||||
/* Enable importing .json files */
|
||||
"resolveJsonModule": true,
|
||||
|
||||
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||
"allowJs": true,
|
||||
/* Enable error reporting in type-checked JavaScript files. */
|
||||
"checkJs": false,
|
||||
|
||||
/* Disable emitting files from a compilation. */
|
||||
"noEmit": true,
|
||||
|
||||
/* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
"isolatedModules": true,
|
||||
/* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"allowSyntheticDefaultImports": true,
|
||||
/* Ensure that casing is correct in imports. */
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
/* Enable all strict type-checking options. */
|
||||
"strict": true,
|
||||
|
||||
/* Skip type checking all .d.ts files. */
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["test"],
|
||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.jsonc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* For more details on how to configure Wrangler, refer to:
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
||||
*/
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "daily-ai-trigger",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-03-10",
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"triggers": {
|
||||
"crons": [
|
||||
"0 1 * * *",
|
||||
"0 2 * * *"
|
||||
]
|
||||
}
|
||||
/**
|
||||
* Smart Placement
|
||||
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
||||
*/
|
||||
// "placement": { "mode": "smart" },
|
||||
|
||||
/**
|
||||
* Bindings
|
||||
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
|
||||
* databases, object storage, AI inference, real-time communication and more.
|
||||
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment Variables
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
|
||||
*/
|
||||
// "vars": { "MY_VARIABLE": "production_value" },
|
||||
/**
|
||||
* Note: Use secrets to store sensitive data.
|
||||
* https://developers.cloudflare.com/workers/configuration/secrets/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Static Assets
|
||||
* https://developers.cloudflare.com/workers/static-assets/binding/
|
||||
*/
|
||||
// "assets": { "directory": "./public/", "binding": "ASSETS" },
|
||||
|
||||
/**
|
||||
* Service Bindings (communicate between multiple Workers)
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
|
||||
*/
|
||||
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug MCP Server",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/inspector",
|
||||
"node",
|
||||
"dist/index.js"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"preLaunchTask": "npm: watch",
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "running at (https?://\\S+)",
|
||||
"uriFormat": "%s?timeout=60000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"servers": {
|
||||
"tianji-mcp-server": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["${workspaceFolder}/dist/index.js"],
|
||||
"env": {
|
||||
"TIANJI_BASE_URL": "https://tianji.example.com",
|
||||
"TIANJI_API_KEY": "<your-api-key>",
|
||||
"TIANJI_WORKSPACE_ID": "<your-workspace-id>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch",
|
||||
"problemMatcher": ["$tsc-watch"],
|
||||
"isBackground": true,
|
||||
"label": "npm: watch",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Tianji MCP Server
|
||||
|
||||
A server based on Model Context Protocol (MCP) that provides tools for interacting with the Tianji platform.
|
||||
|
||||
## Introduction
|
||||
|
||||
Tianji MCP Server is a bridge connecting AI assistants with the Tianji platform, exposing survey functionality from the Tianji platform to AI assistants through the MCP protocol. This server provides the following core features:
|
||||
|
||||
- Query survey results
|
||||
- Get detailed survey information
|
||||
- Get list of all surveys in workspace
|
||||
|
||||
## Install
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"tianji": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"tianji-mcp-server"
|
||||
],
|
||||
"env": {
|
||||
"TIANJI_BASE_URL": "https://tianji.example.com",
|
||||
"TIANJI_API_KEY": "<your-api-key>",
|
||||
"TIANJI_WORKSPACE_ID": "<your-workspace-id>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Before using, you need to set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Tianji Platform API Base URL
|
||||
TIANJI_BASE_URL=https://tianji.example.com
|
||||
|
||||
# Tianji Platform API Key
|
||||
TIANJI_API_KEY=your_api_key_here
|
||||
|
||||
# Tianji Platform Workspace ID
|
||||
TIANJI_WORKSPACE_ID=your_workspace_id_here
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "tianji-mcp-server",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"tianji-mcp-server": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && shx chmod +x dist/index.js",
|
||||
"watch": "tsc --watch",
|
||||
"start": "node ./dist/index.js",
|
||||
"prepare": "pnpm build",
|
||||
"inspector": "mcp-inspector node ./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"tianji",
|
||||
"modelcontextprotocol",
|
||||
"tianji-mcp",
|
||||
"tianji-mcp-server",
|
||||
"mcp"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
},
|
||||
"author": "moonrailgun <moonrailgun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"description": "Tianji MCP Server",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"tianji-client-sdk": "workspace:^",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/inspector": "^0.16.7",
|
||||
"@types/node": "^22.13.10",
|
||||
"shx": "^0.3.4",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
import { createServer } from './server.js';
|
||||
|
||||
async function main() {
|
||||
const server: McpServer = createServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
// console.debug("Tianji MCP Server running on stdio");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { initOpenapiSDK, openApiClient } from 'tianji-client-sdk';
|
||||
import { z } from 'zod';
|
||||
import { fetchAllPaginatedData } from './utils/pagination.js';
|
||||
|
||||
export function createServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'Tianji MCP Server',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
const tianjiBaseUrl = process.env.TIANJI_BASE_URL;
|
||||
if (!tianjiBaseUrl) {
|
||||
throw new Error('TIANJI_BASE_URL is required');
|
||||
}
|
||||
const apiKey = process.env.TIANJI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('TIANJI_API_KEY is required');
|
||||
}
|
||||
const workspaceId = process.env.TIANJI_WORKSPACE_ID;
|
||||
if (!workspaceId) {
|
||||
throw new Error('TIANJI_WORKSPACE_ID is required');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
initOpenapiSDK(tianjiBaseUrl, {
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
// Add service to query survey results
|
||||
server.tool(
|
||||
'tianji_get_survey_results',
|
||||
'Query survey questionnaire result data',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.default(workspaceId)
|
||||
.describe('Tianji workspace ID'),
|
||||
surveyId: z.string().describe('Survey questionnaire ID'),
|
||||
limit: z
|
||||
.number()
|
||||
.default(20)
|
||||
.describe('Limit the number of records returned'),
|
||||
cursor: z.string().optional().describe('Pagination cursor'),
|
||||
startAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`Start time, ISO format, example: 2023-10-01T00:00:00Z, current time is: ${now}`
|
||||
),
|
||||
endAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`End time, ISO format, example: 2023-10-31T23:59:59Z, current time is: ${now}`
|
||||
),
|
||||
filter: z.string().optional().describe('Filter conditions'),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const results = await fetchAllPaginatedData(
|
||||
(queryParams: { cursor?: string }) =>
|
||||
openApiClient.SurveyService.surveyResultList({
|
||||
workspaceId: params.workspaceId,
|
||||
surveyId: params.surveyId,
|
||||
limit: params.limit,
|
||||
cursor: queryParams.cursor,
|
||||
startAt: new Date(params.startAt).valueOf(),
|
||||
endAt: new Date(params.endAt).valueOf(),
|
||||
filter: params.filter,
|
||||
}),
|
||||
{ cursor: params.cursor }
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(results),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get survey results:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get survey results',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add service to get individual survey information
|
||||
server.tool(
|
||||
'tianji_get_survey_info',
|
||||
'Get basic information of a specified survey questionnaire',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.default(workspaceId)
|
||||
.describe('Tianji workspace ID'),
|
||||
surveyId: z.string().describe('Survey questionnaire ID'),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const surveyInfo = await openApiClient.SurveyService.surveyGet({
|
||||
workspaceId: params.workspaceId,
|
||||
surveyId: params.surveyId,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(surveyInfo),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get survey information:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get survey info',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add service to get survey list
|
||||
server.tool(
|
||||
'tianji_get_all_survey_list',
|
||||
'Get all survey list in the workspace',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.describe(`Tianji workspace ID, default: ${workspaceId}`),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const surveys = await openApiClient.SurveyService.surveyAll({
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
content: surveys.map((survey) => ({
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
id: survey.id,
|
||||
name: survey.name,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get survey list:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get survey list',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add service to get website list
|
||||
server.tool(
|
||||
'tianji_get_website_list',
|
||||
'Get all websites in the workspace',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.default(workspaceId)
|
||||
.describe('Tianji workspace ID'),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const websites = await openApiClient.WebsiteService.websiteAll({
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(websites),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get website list:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get website list',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add service to get website info
|
||||
server.tool(
|
||||
'tianji_get_website_info',
|
||||
'Get detailed information about a specific website',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.default(workspaceId)
|
||||
.describe('Tianji workspace ID'),
|
||||
websiteId: z.string().describe('Website ID'),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const websiteInfo = await openApiClient.WebsiteService.websiteInfo({
|
||||
workspaceId: params.workspaceId,
|
||||
websiteId: params.websiteId,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(websiteInfo),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get website information:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get website info',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add service to get website statistics
|
||||
server.tool(
|
||||
'tianji_get_website_stats',
|
||||
'Get statistics data for a website',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.default(workspaceId)
|
||||
.describe('Tianji workspace ID'),
|
||||
websiteId: z.string().describe('Website ID'),
|
||||
startAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`Start time, ISO format, example: 2023-10-01T00:00:00Z, current time is: ${now}`
|
||||
),
|
||||
endAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`End time, ISO format, example: 2023-10-31T23:59:59Z, current time is: ${now}`
|
||||
),
|
||||
unit: z.string().optional().describe('Time unit for statistics grouping'),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const stats = await openApiClient.WebsiteService.websiteStats({
|
||||
workspaceId: params.workspaceId,
|
||||
websiteId: params.websiteId,
|
||||
startAt: new Date(params.startAt).valueOf(),
|
||||
endAt: new Date(params.endAt).valueOf(),
|
||||
unit: params.unit,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(stats),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get website statistics:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get website statistics',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add service to get website pageviews
|
||||
server.tool(
|
||||
'tianji_get_website_pageviews',
|
||||
'Get pageview and session data for a website',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.default(workspaceId)
|
||||
.describe('Tianji workspace ID'),
|
||||
websiteId: z.string().describe('Website ID'),
|
||||
startAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`Start time, ISO format, example: 2023-10-01T00:00:00Z, current time is: ${now}`
|
||||
),
|
||||
endAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`End time, ISO format, example: 2023-10-31T23:59:59Z, current time is: ${now}`
|
||||
),
|
||||
unit: z.string().optional().describe('Time unit for data grouping'),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const pageviewData =
|
||||
await openApiClient.WebsiteService.websitePageviews({
|
||||
workspaceId: params.workspaceId,
|
||||
websiteId: params.websiteId,
|
||||
startAt: new Date(params.startAt).valueOf(),
|
||||
endAt: new Date(params.endAt).valueOf(),
|
||||
unit: params.unit,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(pageviewData),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get website pageviews:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get website pageview data',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add service to get website metrics
|
||||
server.tool(
|
||||
'tianji_get_website_metrics',
|
||||
'Get specific metrics for a website (url, referrer, browser, os, etc.)',
|
||||
{
|
||||
workspaceId: z
|
||||
.string()
|
||||
.default(workspaceId)
|
||||
.describe('Tianji workspace ID'),
|
||||
websiteId: z.string().describe('Website ID'),
|
||||
type: z
|
||||
.enum([
|
||||
'url',
|
||||
'language',
|
||||
'referrer',
|
||||
'title',
|
||||
'browser',
|
||||
'os',
|
||||
'device',
|
||||
'country',
|
||||
'event',
|
||||
])
|
||||
.describe('Type of metrics to retrieve'),
|
||||
startAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`Start time, ISO format, example: 2023-10-01T00:00:00Z, current time is: ${now}`
|
||||
),
|
||||
endAt: z
|
||||
.string()
|
||||
.describe(
|
||||
`End time, ISO format, example: 2023-10-31T23:59:59Z, current time is: ${now}`
|
||||
),
|
||||
},
|
||||
async (
|
||||
params,
|
||||
extra
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const metricsData = await openApiClient.WebsiteService.websiteMetrics({
|
||||
workspaceId: params.workspaceId,
|
||||
websiteId: params.websiteId,
|
||||
type: params.type,
|
||||
startAt: new Date(params.startAt).valueOf(),
|
||||
endAt: new Date(params.endAt).valueOf(),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(metricsData),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get website metrics:', error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Failed to get website metrics data',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Generic paginated data fetcher
|
||||
* Automatically fetches all pages of data using a cursor-based pagination approach
|
||||
*/
|
||||
|
||||
export interface PaginationParams {
|
||||
cursor?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export async function fetchAllPaginatedData<T, P extends PaginationParams>(
|
||||
fetchFunction: (params: P) => Promise<PaginatedResponse<T>>,
|
||||
params: P
|
||||
): Promise<{ total: number; items: T[] }> {
|
||||
const allResults: T[] = [];
|
||||
let currentCursor = params.cursor;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
// Create a new params object with the current cursor
|
||||
const currentParams = {
|
||||
...params,
|
||||
cursor: currentCursor,
|
||||
} as P;
|
||||
|
||||
const results = await fetchFunction(currentParams);
|
||||
|
||||
// Add current page results to the total results array
|
||||
if (results.items && Array.isArray(results.items)) {
|
||||
allResults.push(...results.items);
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
if (results.nextCursor) {
|
||||
currentCursor = results.nextCursor;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: allResults.length,
|
||||
items: allResults,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
version: '3'
|
||||
services:
|
||||
tianji:
|
||||
image: moonrailgun/tianji
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./Dockerfile
|
||||
ports:
|
||||
- "12345:12345"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://tianji:tianji@postgres:5432/tianji
|
||||
JWT_SECRET: replace-me-with-a-random-string
|
||||
ALLOW_REGISTER: "false"
|
||||
ALLOW_OPENAPI: "true"
|
||||
PUBLIC_URL: "" # for example: https://app.tianji.dev
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: always
|
||||
postgres:
|
||||
image: postgres:15.4-alpine
|
||||
environment:
|
||||
POSTGRES_DB: tianji
|
||||
POSTGRES_USER: tianji
|
||||
POSTGRES_PASSWORD: tianji
|
||||
volumes:
|
||||
- tianji-db-data:/var/lib/postgresql/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
tianji-db-data:
|
||||
@@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
container_name: tianji-clickhouse
|
||||
ports:
|
||||
- "127.0.0.1:8123:8123" # HTTP endpoint
|
||||
# - "9000:9000" # Native endpoint
|
||||
- "127.0.0.1:9004:9004" # Mysql endpoint
|
||||
environment:
|
||||
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
|
||||
- CLICKHOUSE_DB=tianji
|
||||
- CLICKHOUSE_USER=tianji
|
||||
- CLICKHOUSE_PASSWORD=tianji
|
||||
# volumes:
|
||||
# - clickhouse_data:/var/lib/clickhouse
|
||||
# - ./config.xml:/etc/clickhouse-server/config.xml
|
||||
restart: unless-stopped
|
||||
ch-ui:
|
||||
image: ghcr.io/caioricciuti/ch-ui:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "5521:5521"
|
||||
environment:
|
||||
VITE_CLICKHOUSE_URL: "http://127.0.0.1:8123"
|
||||
VITE_CLICKHOUSE_USER: "tianji"
|
||||
VITE_CLICKHOUSE_PASS: "tianji"
|
||||
|
||||
# volumes:
|
||||
# clickhouse_data:
|
||||
# driver: local
|
||||
# clickhouse_config:
|
||||
# driver: local
|
||||
@@ -0,0 +1 @@
|
||||
charts
|
||||
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,6 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
version: 15.4.2
|
||||
digest: sha256:57308af2821726255fe0c52374260c2ea98f9091c03d82cf82b9c6d499e6f214
|
||||
generated: "2024-06-09T14:04:36.881437+08:00"
|
||||
@@ -0,0 +1,42 @@
|
||||
apiVersion: v2
|
||||
name: tianji
|
||||
description: All-in-One Insight Hub
|
||||
icon: https://tianji.dev/img/logo.svg
|
||||
maintainers:
|
||||
- name: moonrailgun
|
||||
email: moonrailgun@gmail.com
|
||||
keywords:
|
||||
- tianji
|
||||
- uptime
|
||||
- umami
|
||||
- server status
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.1.17
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "1.11.2"
|
||||
|
||||
sources:
|
||||
- https://github.com/msgbyte/tianji
|
||||
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 15.4.2
|
||||
repository: "https://charts.bitnami.com/bitnami"
|
||||
condition: postgresql.enabled
|
||||
@@ -0,0 +1,26 @@
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "tianji.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "tianji.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "tianji.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "tianji.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. Tianji's default account:
|
||||
Username: admin
|
||||
Password: admin
|
||||
@@ -0,0 +1,62 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "tianji.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "tianji.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "tianji.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "tianji.labels" -}}
|
||||
helm.sh/chart: {{ include "tianji.chart" . }}
|
||||
{{ include "tianji.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "tianji.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "tianji.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "tianji.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "tianji.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "tianji.fullname" . }}
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 4 }}
|
||||
data:
|
||||
DATABASE_URL: postgresql://{{ .Values.postgresql.auth.username }}:{{ .Values.postgresql.auth.password }}@{{ .Release.Name }}-postgresql:5432/{{ .Values.postgresql.auth.database }}
|
||||
{{/*
|
||||
Rather than maintain a comprehensive ConfigMap, we map all sub-keys of the "env" value here.
|
||||
This allows for more flexibility and less Chart churn as Drone evolves.
|
||||
*/}}
|
||||
{{- range $envKey, $envVal := .Values.env }}
|
||||
{{ $envKey | upper }}: {{ $envVal | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,71 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "tianji.fullname" . }}
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "tianji.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "tianji.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "tianji.fullname" . }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "tianji.fullname" . }}
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "tianji.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "tianji.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||
pathType: {{ .pathType }}
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}
|
||||
port:
|
||||
number: {{ $svcPort }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ $svcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "tianji.fullname" . }}
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "tianji.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "tianji.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ include "tianji.fullname" . }}-test-connection"
|
||||
labels:
|
||||
{{- include "tianji.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": test
|
||||
spec:
|
||||
containers:
|
||||
- name: wget
|
||||
image: busybox
|
||||
command: ['wget']
|
||||
args: ['{{ include "tianji.fullname" . }}:{{ .Values.service.port }}']
|
||||
restartPolicy: Never
|
||||
@@ -0,0 +1,121 @@
|
||||
# Default values for tianji.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: moonrailgun/tianji
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Automatically mount a ServiceAccount's API credentials?
|
||||
automount: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
auth:
|
||||
username: "tianji"
|
||||
password: "tianji"
|
||||
database: "tianji"
|
||||
postgresPassword: "tianji"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 12345
|
||||
|
||||
env:
|
||||
# DATABASE_URL: postgresql://tianji:tianji@postgresql:5432/tianji
|
||||
JWT_SECRET: replace-me-with-a-random-string
|
||||
ALLOW_REGISTER: "false"
|
||||
ALLOW_OPENAPI: "true"
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 100
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
|
||||
# Additional volumes on the output Deployment definition.
|
||||
volumes: []
|
||||
# - name: foo
|
||||
# secret:
|
||||
# secretName: mysecret
|
||||
# optional: false
|
||||
|
||||
# Additional volumeMounts on the output Deployment definition.
|
||||
volumeMounts: []
|
||||
# - name: foo
|
||||
# mountPath: "/etc/foo"
|
||||
# readOnly: true
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
@@ -0,0 +1,56 @@
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: tianji-reporter
|
||||
labels:
|
||||
app: tianji-reporter
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: tianji-reporter
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: tianji-reporter
|
||||
spec:
|
||||
containers:
|
||||
- name: reporter
|
||||
image: moonrailgun/tianji:latest
|
||||
args:
|
||||
- /usr/local/bin/tianji-reporter
|
||||
- --url=$(TIANJI_SERVER_URL)
|
||||
- --workspace=$(TIANJI_WORKSPACE_ID)
|
||||
- --name=$(NODE_NAME)
|
||||
env:
|
||||
- name: NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
- name: TIANJI_SERVER_URL
|
||||
value: "http://tianji.example.com"
|
||||
- name: TIANJI_WORKSPACE_ID
|
||||
value: "<your_workspace_id>"
|
||||
volumeMounts:
|
||||
- name: proc
|
||||
mountPath: /proc
|
||||
readOnly: true
|
||||
- name: sys
|
||||
mountPath: /sys
|
||||
readOnly: true
|
||||
- name: rootfs
|
||||
mountPath: /rootfs
|
||||
readOnly: true
|
||||
securityContext:
|
||||
privileged: true
|
||||
hostNetwork: true
|
||||
hostPID: true
|
||||
volumes:
|
||||
- name: proc
|
||||
hostPath:
|
||||
path: /proc
|
||||
- name: sys
|
||||
hostPath:
|
||||
path: /sys
|
||||
- name: rootfs
|
||||
hostPath:
|
||||
path: /
|
||||
@@ -0,0 +1,322 @@
|
||||
apiVersion: app.sealos.io/v1
|
||||
kind: Template
|
||||
metadata:
|
||||
name: tianji
|
||||
spec:
|
||||
title: 'Tianji'
|
||||
url: 'https://tianji.dev/'
|
||||
gitRepo: 'https://github.com/msgbyte/tianji'
|
||||
author: 'moonrailgun'
|
||||
description: 'Tianji: Insight into everything, Website Analytics + Uptime Monitor + Server Status. not only another GA alternatives'
|
||||
readme: 'https://raw.githubusercontent.com/msgbyte/tianji/master/README.md'
|
||||
icon: 'https://tianji.dev/img/logo.svg'
|
||||
templateType: inline
|
||||
defaults:
|
||||
app_host:
|
||||
# number or string..
|
||||
type: string
|
||||
value: tianji-${{ random(8) }}
|
||||
app_name:
|
||||
type: string
|
||||
value: tianji-${{ random(8) }}
|
||||
inputs:
|
||||
JWT_SECRET:
|
||||
description: 'replace me with a random string'
|
||||
type: string
|
||||
default: 'replace-me-with-a-random-string'
|
||||
required: true
|
||||
ALLOW_REGISTER:
|
||||
description: 'whether allow register account'
|
||||
type: string
|
||||
default: "false"
|
||||
required: false
|
||||
ALLOW_OPENAPI:
|
||||
description: 'whether allow open openapi'
|
||||
type: string
|
||||
default: "true"
|
||||
required: false
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${{ defaults.app_name }}
|
||||
annotations:
|
||||
originImageName: moonrailgun/tianji
|
||||
deploy.cloud.sealos.io/minReplicas: '1'
|
||||
deploy.cloud.sealos.io/maxReplicas: '1'
|
||||
labels:
|
||||
cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}
|
||||
app: ${{ defaults.app_name }}
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${{ defaults.app_name }}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
maxSurge: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${{ defaults.app_name }}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${{ defaults.app_name }}
|
||||
image: moonrailgun/tianji
|
||||
env:
|
||||
- name: PG_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${{ defaults.app_name }}-pg-conn-credential
|
||||
key: password
|
||||
- name: DATABASE_URL
|
||||
value: postgresql://postgres:$(PG_PASSWORD)@${{ defaults.app_name }}-pg-postgresql.${{ SEALOS_NAMESPACE }}.svc:5432/tianji
|
||||
- name: JWT_SECRET
|
||||
value: ${{ inputs.JWT_SECRET }}
|
||||
- name: ALLOW_REGISTER
|
||||
value: ${{ inputs.ALLOW_REGISTER }}
|
||||
- name: ALLOW_OPENAPI
|
||||
value: ${{ inputs.ALLOW_OPENAPI }}
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 102Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
command: []
|
||||
args: []
|
||||
ports:
|
||||
- containerPort: 12345
|
||||
imagePullPolicy: Always
|
||||
volumeMounts: []
|
||||
volumes: []
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${{ defaults.app_name }}
|
||||
labels:
|
||||
cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}
|
||||
spec:
|
||||
ports:
|
||||
- port: 12345
|
||||
selector:
|
||||
app: ${{ defaults.app_name }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ${{ defaults.app_name }}
|
||||
labels:
|
||||
cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}
|
||||
cloud.sealos.io/app-deploy-manager-domain: ${{ defaults.app_host }}
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 32m
|
||||
nginx.ingress.kubernetes.io/server-snippet: |
|
||||
client_header_buffer_size 64k;
|
||||
large_client_header_buffers 4 128k;
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: 'false'
|
||||
nginx.ingress.kubernetes.io/backend-protocol: HTTP
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /$2
|
||||
nginx.ingress.kubernetes.io/client-body-buffer-size: 64k
|
||||
nginx.ingress.kubernetes.io/proxy-buffer-size: 64k
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: '300'
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: '300'
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
if ($request_uri ~* \.(js|css|gif|jpe?g|png)) {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
spec:
|
||||
rules:
|
||||
- host: ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: /()(.*)
|
||||
backend:
|
||||
service:
|
||||
name: ${{ defaults.app_name }}
|
||||
port:
|
||||
number: 12345
|
||||
tls:
|
||||
- hosts:
|
||||
- ${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}
|
||||
secretName: ${{ SEALOS_CERT_SECRET_NAME }}
|
||||
---
|
||||
apiVersion: apps.kubeblocks.io/v1alpha1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
finalizers:
|
||||
- cluster.kubeblocks.io/finalizer
|
||||
labels:
|
||||
clusterdefinition.kubeblocks.io/name: postgresql
|
||||
clusterversion.kubeblocks.io/name: postgresql-14.8.0
|
||||
sealos-db-provider-cr: ${{ defaults.app_name }}-pg
|
||||
annotations: {}
|
||||
name: ${{ defaults.app_name }}-pg
|
||||
spec:
|
||||
affinity:
|
||||
nodeLabels: {}
|
||||
podAntiAffinity: Preferred
|
||||
tenancy: SharedNode
|
||||
topologyKeys: []
|
||||
clusterDefinitionRef: postgresql
|
||||
clusterVersionRef: postgresql-14.8.0
|
||||
componentSpecs:
|
||||
- componentDefRef: postgresql
|
||||
monitor: true
|
||||
name: postgresql
|
||||
replicas: 1
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1024Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 102Mi
|
||||
serviceAccountName: ${{ defaults.app_name }}-pg
|
||||
switchPolicy:
|
||||
type: Noop
|
||||
volumeClaimTemplates:
|
||||
- name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
storageClassName: openebs-backup
|
||||
terminationPolicy: Delete
|
||||
tolerations: []
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
labels:
|
||||
sealos-db-provider-cr: ${{ defaults.app_name }}-pg
|
||||
app.kubernetes.io/instance: ${{ defaults.app_name }}-pg
|
||||
app.kubernetes.io/managed-by: kbcli
|
||||
name: ${{ defaults.app_name }}-pg
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
labels:
|
||||
sealos-db-provider-cr: ${{ defaults.app_name }}-pg
|
||||
app.kubernetes.io/instance: ${{ defaults.app_name }}-pg
|
||||
app.kubernetes.io/managed-by: kbcli
|
||||
name: ${{ defaults.app_name }}-pg
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ''
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- apiGroups:
|
||||
- ''
|
||||
resources:
|
||||
- configmaps
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
- delete
|
||||
- apiGroups:
|
||||
- ''
|
||||
resources:
|
||||
- endpoints
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
- delete
|
||||
- apiGroups:
|
||||
- ''
|
||||
resources:
|
||||
- pods
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
labels:
|
||||
sealos-db-provider-cr: ${{ defaults.app_name }}-pg
|
||||
app.kubernetes.io/instance: ${{ defaults.app_name }}-pg
|
||||
app.kubernetes.io/managed-by: kbcli
|
||||
name: ${{ defaults.app_name }}-pg
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: ${{ defaults.app_name }}-pg
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: ${{ defaults.app_name }}-pg
|
||||
namespace: ${{ SEALOS_NAMESPACE }}
|
||||
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: ${{ defaults.app_name }}-init
|
||||
spec:
|
||||
completions: 1
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: pgsql-init
|
||||
image: senzing/postgresql-client:latest
|
||||
env:
|
||||
- name: PG_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${{ defaults.app_name }}-pg-conn-credential
|
||||
key: password
|
||||
- name: DATABASE_URL
|
||||
value: postgresql://postgres:$(PG_PASSWORD)@${{ defaults.app_name }}-pg-postgresql.${{ SEALOS_NAMESPACE }}.svc:5432
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
until psql ${DATABASE_URL} -c 'CREATE DATABASE tianji;' &>/dev/null; do sleep 1; done
|
||||
restartPolicy: Never
|
||||
backoffLimit: 0
|
||||
ttlSecondsAfterFinished: 300
|
||||
|
||||
---
|
||||
apiVersion: app.sealos.io/v1
|
||||
kind: App
|
||||
metadata:
|
||||
name: ${{ defaults.app_name }}
|
||||
labels:
|
||||
cloud.sealos.io/app-deploy-manager: ${{ defaults.app_name }}
|
||||
spec:
|
||||
data:
|
||||
url: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}
|
||||
displayType: normal
|
||||
icon: "https://tianji.dev/img/logo.svg"
|
||||
menuData:
|
||||
nameColor: text-black
|
||||
name: ${{ defaults.app_name }}
|
||||
type: iframe
|
||||
@@ -0,0 +1,38 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
@@ -0,0 +1 @@
|
||||
node-linker=hoisted
|
||||
@@ -0,0 +1,50 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "tianji-expo-example",
|
||||
"slug": "tianji-expo-example",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/HapticTab';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarBackground: TabBarBackground,
|
||||
tabBarStyle: Platform.select({
|
||||
ios: {
|
||||
// Use a transparent background on iOS to show the blur effect
|
||||
position: 'absolute',
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="house.fill" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="paperplane.fill" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="application-test"
|
||||
options={{
|
||||
title: 'Application Test',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="app.badge.fill" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, TextInput, Alert } from 'react-native';
|
||||
import { ScrollView } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import {
|
||||
reportApplicationEvent,
|
||||
identifyApplicationUser,
|
||||
ApplicationTrackingOptions,
|
||||
} from 'tianji-react-native';
|
||||
|
||||
import { Collapsible } from '@/components/Collapsible';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { VStack } from '@/components/ui/vstack';
|
||||
import {
|
||||
Toast,
|
||||
ToastDescription,
|
||||
ToastTitle,
|
||||
useToast,
|
||||
} from '@/components/ui/toast';
|
||||
import {
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormControlLabelText,
|
||||
} from '@/components/ui/form-control';
|
||||
import { Input, InputField } from '@/components/ui/input';
|
||||
import { Button, ButtonText } from '@/components/ui/button';
|
||||
import { HStack } from '@/components/ui/hstack';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
|
||||
interface EventResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export default function ApplicationTestScreen() {
|
||||
const [serverUrl, setServerUrl] = useState('http://localhost:12345');
|
||||
const [applicationId, setApplicationId] = useState(
|
||||
'cm8aepds100qrge0xiv982zj4'
|
||||
);
|
||||
const [eventName, setEventName] = useState('test_event');
|
||||
const [eventData, setEventData] = useState('{"value": 1, "action": "click"}');
|
||||
const [results, setResults] = useState<EventResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
const insets = useSafeAreaInsets();
|
||||
const buttonTabbarHeight = useBottomTabBarHeight();
|
||||
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
|
||||
const sendApplicationEvent = async () => {
|
||||
if (!applicationId) {
|
||||
Alert.alert('Error', 'Please enter application ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Parse event data from JSON string
|
||||
let parsedEventData;
|
||||
try {
|
||||
parsedEventData = JSON.parse(eventData);
|
||||
} catch (e) {
|
||||
Alert.alert('Error', 'Invalid JSON format in event data');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create tracking options
|
||||
const trackingOptions: ApplicationTrackingOptions = {
|
||||
serverUrl,
|
||||
applicationId,
|
||||
};
|
||||
|
||||
// Use the client SDK to report the event
|
||||
const result = await reportApplicationEvent(
|
||||
trackingOptions,
|
||||
eventName,
|
||||
parsedEventData
|
||||
);
|
||||
|
||||
toast.show({
|
||||
placement: 'top',
|
||||
duration: 3000,
|
||||
render: () => {
|
||||
return (
|
||||
<Toast action="success" variant="solid">
|
||||
<ToastTitle>Success!</ToastTitle>
|
||||
<ToastDescription>Event has been sent.</ToastDescription>
|
||||
</Toast>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
setResults((prev) => [
|
||||
{
|
||||
success: true,
|
||||
message: `Event sent successfully: ${result}`,
|
||||
timestamp,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
} catch (error) {
|
||||
setResults((prev) => [
|
||||
{
|
||||
success: false,
|
||||
message: `Exception: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendIdentifyEvent = async () => {
|
||||
if (!applicationId) {
|
||||
Alert.alert('Error', 'Please enter application ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const userData = {
|
||||
userId: 'user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
plan: 'premium',
|
||||
signupDate: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Create tracking options
|
||||
const trackingOptions: ApplicationTrackingOptions = {
|
||||
serverUrl,
|
||||
applicationId,
|
||||
};
|
||||
|
||||
// Use the client SDK to identify the user
|
||||
const result = await identifyApplicationUser(trackingOptions, userData);
|
||||
|
||||
toast.show({
|
||||
placement: 'top',
|
||||
duration: 3000,
|
||||
render: () => {
|
||||
return (
|
||||
<Toast action="success" variant="solid">
|
||||
<ToastTitle>Success!</ToastTitle>
|
||||
<ToastDescription>Event has been sent.</ToastDescription>
|
||||
</Toast>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
setResults((prev) => [
|
||||
{
|
||||
success: true,
|
||||
message: `User identification event sent successfully: ${result}`,
|
||||
timestamp,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
} catch (error) {
|
||||
setResults((prev) => [
|
||||
{
|
||||
success: false,
|
||||
message: `Exception: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { paddingTop: insets.top }]}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + buttonTabbarHeight,
|
||||
}}
|
||||
>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Application Event Test</ThemedText>
|
||||
<IconSymbol
|
||||
name="app.badge.fill"
|
||||
size={28}
|
||||
color={colorScheme === 'light' ? Colors.light.tint : Colors.dark.tint}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
<Collapsible title="Configuration">
|
||||
<VStack space="md" style={styles.formSection}>
|
||||
<FormControl>
|
||||
<FormControlLabel>
|
||||
<FormControlLabelText>Server Address</FormControlLabelText>
|
||||
</FormControlLabel>
|
||||
<Input>
|
||||
<InputField
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
placeholder="Enter server address"
|
||||
/>
|
||||
</Input>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel>
|
||||
<FormControlLabelText>Application ID</FormControlLabelText>
|
||||
</FormControlLabel>
|
||||
<Input>
|
||||
<InputField
|
||||
value={applicationId}
|
||||
onChangeText={setApplicationId}
|
||||
placeholder="Enter application ID"
|
||||
/>
|
||||
</Input>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Custom Event">
|
||||
<VStack space="md" style={styles.formSection}>
|
||||
<FormControl>
|
||||
<FormControlLabel>
|
||||
<FormControlLabelText>Event Name</FormControlLabelText>
|
||||
</FormControlLabel>
|
||||
<Input>
|
||||
<InputField
|
||||
value={eventName}
|
||||
onChangeText={setEventName}
|
||||
placeholder="Enter event name"
|
||||
/>
|
||||
</Input>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel>
|
||||
<FormControlLabelText>Event Data (JSON)</FormControlLabelText>
|
||||
</FormControlLabel>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.jsonInput,
|
||||
{
|
||||
backgroundColor: colorScheme === 'light' ? '#f0f0f0' : '#333',
|
||||
},
|
||||
]}
|
||||
value={eventData}
|
||||
onChangeText={setEventData}
|
||||
placeholder="Enter JSON format event data"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button onPress={sendApplicationEvent} isDisabled={loading}>
|
||||
<ButtonText>Send Custom Event</ButtonText>
|
||||
</Button>
|
||||
</VStack>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="User Identification Event">
|
||||
<VStack space="md" style={styles.formSection}>
|
||||
<ThemedText>
|
||||
Send user identification event, including user ID, email and other
|
||||
information
|
||||
</ThemedText>
|
||||
<Button onPress={sendIdentifyEvent} isDisabled={loading}>
|
||||
<ButtonText>Send User Identification Event</ButtonText>
|
||||
</Button>
|
||||
</VStack>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Test Results">
|
||||
<VStack space="md" style={styles.formSection}>
|
||||
<HStack className="items-center justify-between">
|
||||
<ThemedText type="subtitle">Event Sending Records</ThemedText>
|
||||
<Button size="sm" variant="outline" onPress={clearResults}>
|
||||
<ButtonText>Clear</ButtonText>
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<ThemedText>No records</ThemedText>
|
||||
) : (
|
||||
results.map((result, index) => (
|
||||
<ThemedView
|
||||
key={index}
|
||||
style={[
|
||||
styles.resultItem,
|
||||
{ borderColor: result.success ? '#4CAF50' : '#F44336' },
|
||||
]}
|
||||
>
|
||||
<ThemedText>{result.timestamp}</ThemedText>
|
||||
<ThemedText
|
||||
style={{ color: result.success ? '#4CAF50' : '#F44336' }}
|
||||
>
|
||||
{result.message}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
</Collapsible>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
marginTop: 12,
|
||||
},
|
||||
formSection: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
jsonInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
minHeight: 100,
|
||||
fontFamily: 'SpaceMono',
|
||||
fontSize: 14,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
resultItem: {
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { StyleSheet, Image, Platform } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/Collapsible';
|
||||
import { ExternalLink } from '@/components/ExternalLink';
|
||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Explore</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} />
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Custom fonts">
|
||||
<ThemedText>
|
||||
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '}
|
||||
<ThemedText style={{ fontFamily: 'SpaceMono' }}>
|
||||
custom fonts such as this one.
|
||||
</ThemedText>
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful <ThemedText type="defaultSemiBold">react-native-reanimated</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Image, StyleSheet, Platform } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/HelloWave';
|
||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12'
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
<ThemedText>
|
||||
Tap the Explore tab to learn more about what's included in this starter app.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
When you're ready, run{' '}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import React from 'react';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<Link href="/" style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
} from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack, useGlobalSearchParams, usePathname } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider';
|
||||
import { updateCurrentApplicationScreen } from 'tianji-react-native';
|
||||
|
||||
import 'react-native-reanimated';
|
||||
import '../global.css';
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
updateCurrentApplicationScreen(pathname, params);
|
||||
}, [pathname, params]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<GluestackUIProvider>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</GluestackUIProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
export function Collapsible({
|
||||
children,
|
||||
title,
|
||||
}: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [animation] = useState(new Animated.Value(0));
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
const toggleOpen = () => {
|
||||
setIsOpen(!isOpen);
|
||||
Animated.timing(animation, {
|
||||
toValue: isOpen ? 0 : 1,
|
||||
duration: 300,
|
||||
easing: Easing.bezier(0.4, 0, 0.2, 1),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const rotateInterpolate = animation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '90deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={toggleOpen}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ rotate: rotateInterpolate }] }}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
padding: 12,
|
||||
},
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
content: {
|
||||
marginTop: 8,
|
||||
marginLeft: 26,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
});
|
||||