chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:54 +08:00
commit 38e12526f7
1648 changed files with 490801 additions and 0 deletions
+148
View File
@@ -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.
+12
View File
@@ -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>"
+56
View File
@@ -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"
}
+44
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
apps
example
docker
node_modules
packages/*
!packages/client-sdk
!packages/client-sdk/**
website
+20
View File
@@ -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=
+38
View File
@@ -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 }}
+44
View File
@@ -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
+26
View File
@@ -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 }}
+64
View File
@@ -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
+50
View File
@@ -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"
+32
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
package-manager-strict=true
package-manager-strict-version=false
+1
View File
@@ -0,0 +1 @@
src/client/routeTree.gen.ts
+12
View File
@@ -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"]
}
+50
View File
@@ -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"
}
]
}
}
}
}
+19
View File
@@ -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`.
+4038
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -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"]
+201
View File
@@ -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.
+103
View File
@@ -0,0 +1,103 @@
[![CI](https://github.com/msgbyte/tianji/actions/workflows/ci.yaml/badge.svg)](https://github.com/msgbyte/tianji/actions/workflows/ci.yaml)
[![Build Reporter Release](https://github.com/msgbyte/tianji/actions/workflows/reporter-release.yml/badge.svg)](https://github.com/msgbyte/tianji/actions/workflows/reporter-release.yml)
[![Docker Build CI](https://github.com/msgbyte/tianji/actions/workflows/ci-docker.yaml/badge.svg)](https://github.com/msgbyte/tianji/actions/workflows/ci-docker.yaml)
![Docker Pulls](https://img.shields.io/docker/pulls/moonrailgun/tianji)
![Docker Image Size](https://img.shields.io/docker/image-size/moonrailgun/tianji)
![Tianji Visitor](https://tianji.moonrailgun.com/telemetry/clnzoxcy10001vy2ohi4obbi0/cltjxvcwm02wdut4e106maek7/badge.svg?url=http://github.com/msgbyte/tianji)
# 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
![](./website/static/img/preview/1.png)
![](./website/static/img/preview/2.png)
![](./website/static/img/preview/3.png)
![](./website/static/img/preview/4.png)
![](./website/static/img/preview/5.png)
![](./website/static/img/preview/6.png)
## 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
[![Deploy on Hostinger](https://assets.hostinger.com/vps/deploy.svg)](https://www.hostinger.com/vps/docker-hosting?compose_url=https://github.com/msgbyte/tianji/)
[![Deploy on Sealos](https://cdn.jsdelivr.net/gh/labring-actions/templates@main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-template%3FtemplateName%3Dtianji)
[![Deploy to RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=270)
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/msgbyte/tianji)
[![Run on ClawCloud](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?referralCode=TNW6NVWTLHPQ&openapp=system-fastdeploy%3FtemplateName%3Dtianji)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`msgbyte/tianji`
- 原始仓库:https://github.com/msgbyte/tianji
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+4
View File
@@ -0,0 +1,4 @@
published_reviews.json
publisher.json
config.json
lib
+3
View File
@@ -0,0 +1,3 @@
## appstore review to tianji survey
Quick start and fork from `https://github.com/TradeMe/ReviewMe`
+25
View File
@@ -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);
+30
View File
@@ -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:*"
}
}
+264
View File
@@ -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'];
};
+7
View File
@@ -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',
};
+5
View File
@@ -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);
}
);
});
});
};
+22
View File
@@ -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,
});
}
}
+166
View File
@@ -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',
];
+88
View File
@@ -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;
};
+22
View File
@@ -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
);
}
+67
View File
@@ -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';
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./lib",
"noEmit": false
},
"include": [
"./src/**/*"
]
}
+12
View File
@@ -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
+172
View File
@@ -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/
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}
+80
View File
@@ -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)
+24
View File
@@ -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:*"
}
}
+129
View File
@@ -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);
}
+58
View File
@@ -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>;
+103
View File
@@ -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(),
},
})),
}),
},
);
}
+25
View File
@@ -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!"`);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers"]
},
"include": ["./**/*.ts", "../worker-configuration.d.ts"],
"exclude": []
}
+46
View File
@@ -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"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
});
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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" }]
}
+136
View File
@@ -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.*
+26
View File
@@ -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"
}
}
]
}
+14
View File
@@ -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>"
}
}
}
}
+16
View File
@@ -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
}
}
]
}
+52
View File
@@ -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)
+43
View File
@@ -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"
}
}
+17
View File
@@ -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);
});
+492
View File
@@ -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;
}
+51
View File
@@ -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,
};
}
+16
View File
@@ -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"]
}
+34
View File
@@ -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:
+34
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
charts
+23
View File
@@ -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/
+6
View File
@@ -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"
+42
View File
@@ -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
+26
View File
@@ -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
+62
View File
@@ -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 }}
+15
View File
@@ -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 }}
+71
View File
@@ -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 }}
+32
View File
@@ -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 }}
+61
View File
@@ -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 }}
+15
View File
@@ -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
+121
View File
@@ -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: {}
+56
View File
@@ -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: /
+322
View File
@@ -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
+38
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
node-linker=hoisted
+50
View File
@@ -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.
+41
View File
@@ -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
}
}
}
+59
View File
@@ -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,
},
});
+109
View File
@@ -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,
},
});
+74
View File
@@ -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',
},
});
+32
View File
@@ -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,
},
});
+54
View File
@@ -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>
);
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+9
View File
@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
+79
View File
@@ -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,
},
});

Some files were not shown because too many files have changed in this diff Show More