Initial commit
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# GitHub Actions Radar Environment Variables
|
||||
|
||||
# GitHub Personal Access Token (optional - can be configured in the UI)
|
||||
# VITE_GITHUB_TOKEN=ghp_your_token_here
|
||||
|
||||
# Default repositories to monitor (optional)
|
||||
# VITE_DEFAULT_REPOS=owner/repo1,owner/repo2
|
||||
21
.eslintrc.js
Normal file
21
.eslintrc.js
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
},
|
||||
}
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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?
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Configuration
|
||||
config.json
|
||||
78
CACHING_EXPLANATION.md
Normal file
78
CACHING_EXPLANATION.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Backend Caching Implementation
|
||||
|
||||
## How It Works
|
||||
|
||||
The caching is implemented **entirely on the backend** in the Express server, ensuring that all clients benefit from shared cache and rate limiting.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Client A │ │ Client B │ │ Express Server │ │ GitHub API │
|
||||
└─────────────┘ └─────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │ │ │
|
||||
│ GET /api/workflow-runs │ │
|
||||
├──────────────────────────────────────→ │ │
|
||||
│ │ │ Check Cache │
|
||||
│ │ │ ┌─────────────────┐ │
|
||||
│ │ │ │ CACHE MISS │ │
|
||||
│ │ │ └─────────────────┘ │
|
||||
│ │ │ Make API Request │
|
||||
│ │ ├─────────────────────→ │
|
||||
│ │ │ ←───────────────────── │
|
||||
│ │ │ Store in Cache │
|
||||
│ ←──────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ GET /api/workflow-runs │
|
||||
│ ├──────────────────→ │ │
|
||||
│ │ │ Check Cache │
|
||||
│ │ │ ┌─────────────────┐ │
|
||||
│ │ │ │ CACHE HIT │ │
|
||||
│ │ │ └─────────────────┘ │
|
||||
│ │ ←──────────────────── │ (No API call) │
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Single GitHubService Instance**: One shared instance across all clients
|
||||
2. **In-Memory Cache**: Map-based caching with TTL expiration
|
||||
3. **Request Queue**: All API requests are queued and rate-limited
|
||||
4. **Rate Limit Tracking**: Shared rate limit state across all requests
|
||||
|
||||
### Cache Features
|
||||
|
||||
- **TTL-based Expiration**: 5 minutes for workflow runs and other data
|
||||
- **Automatic Cleanup**: Expired entries are automatically removed
|
||||
- **Cache Preservation**: Cache survives configuration changes
|
||||
- **Request Deduplication**: Multiple identical requests share the same cached result
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **Parallel Processing**: Multiple API requests processed concurrently
|
||||
- **Intelligent Rate Limiting**: Maximum 10 concurrent requests with 10 requests/second limit
|
||||
- **Proactive Waiting**: Automatically waits when approaching rate limits
|
||||
- **Shared Counters**: All clients share the same rate limit tracking
|
||||
|
||||
### Benefits
|
||||
|
||||
✅ **Reduced API Calls**: Cache hits eliminate redundant GitHub API requests
|
||||
✅ **Shared Rate Limits**: Multiple clients don't compound rate limit usage
|
||||
✅ **Better Performance**: Cached responses are served instantly
|
||||
✅ **Automatic Management**: No client-side cache logic needed
|
||||
✅ **Scalable**: Adding more clients doesn't increase API usage proportionally
|
||||
|
||||
### Monitoring
|
||||
|
||||
The backend provides detailed logging:
|
||||
- `💾 Cache HIT: owner/repo - runs` - Request served from cache
|
||||
- `🌐 Cache MISS: owner/repo - runs - Making API request` - New API request
|
||||
- `📊 API Rate Limit: remaining/limit remaining` - Current rate limit status
|
||||
- `💾 Cached response for 300s` - Data cached with TTL
|
||||
|
||||
### API Endpoints for Cache Management
|
||||
|
||||
- `GET /api/rate-limit` - View current GitHub API rate limit status
|
||||
- `GET /api/cache/stats` - View cache size and entries
|
||||
- `DELETE /api/cache` - Manually clear the cache
|
||||
|
||||
This ensures efficient API usage while providing transparency and control over the caching behavior.
|
||||
100
CLAUDE.md
Normal file
100
CLAUDE.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Common Commands
|
||||
- `npm run dev` - Start development server (runs both frontend and backend concurrently)
|
||||
- `npm run server` - Start backend server only
|
||||
- `npm run build` - Build for production (TypeScript compilation + Vite build)
|
||||
- `npm run lint` - Run ESLint on TypeScript files
|
||||
- `npm run test` - Run tests with Vitest
|
||||
- `npm run preview` - Preview production build
|
||||
|
||||
### Development Workflow
|
||||
- Use `npm run dev` for full-stack development
|
||||
- Backend runs on port 3001, frontend on port 3000
|
||||
- Frontend proxies API calls to backend via `/api` routes
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Full-Stack Structure
|
||||
This is a GitHub Actions monitoring dashboard with a React frontend and Express backend that communicates with GitHub's API.
|
||||
|
||||
**Frontend (React + TypeScript + Vite)**
|
||||
- Modern React with TypeScript and Tailwind CSS
|
||||
- Context providers for theme and settings management
|
||||
- Component-based architecture with clean separation of concerns
|
||||
- API communication handled through dedicated service classes
|
||||
|
||||
**Backend (Express + TypeScript)**
|
||||
- RESTful API server with CORS enabled
|
||||
- GitHub API integration with token-based authentication
|
||||
- Live configuration reloading with file watching
|
||||
- Graceful shutdown handling
|
||||
|
||||
### Key Components
|
||||
|
||||
**Frontend Structure:**
|
||||
- `src/App.tsx` - Main app with context providers
|
||||
- `src/components/Dashboard.tsx` - Main dashboard component
|
||||
- `src/contexts/` - Theme and settings context providers
|
||||
- `src/services/api.ts` - Frontend API client using Axios
|
||||
- `src/types/github.ts` - TypeScript interfaces for GitHub API
|
||||
|
||||
**Backend Structure:**
|
||||
- `server/index.ts` - Express server with API routes
|
||||
- `server/config.ts` - Configuration management with live reloading
|
||||
- `server/github.ts` - GitHub API service integration
|
||||
|
||||
### Configuration System
|
||||
- Primary config in `config.json` (created from `config.example.json`)
|
||||
- Live configuration reloading via file watching
|
||||
- Environment variable fallbacks for deployment
|
||||
- Validation for required GitHub tokens and repository configs
|
||||
- Optional cache timeout configuration (defaults to 5 minutes)
|
||||
|
||||
### API Endpoints
|
||||
- `GET /api/health` - Health check
|
||||
- `GET /api/config` - Repository configuration
|
||||
- `GET /api/workflow-runs` - Latest workflow runs from all repos
|
||||
- `GET /api/repository/:owner/:repo/workflow-runs` - Repository-specific runs
|
||||
- `GET /api/rate-limit` - GitHub API rate limit status
|
||||
- `GET /api/cache/stats` - Cache statistics
|
||||
- `DELETE /api/cache` - Clear cache
|
||||
|
||||
### GitHub Integration
|
||||
- Uses GitHub Personal Access Tokens for authentication
|
||||
- Requires `repo` and `actions:read` permissions
|
||||
- Fetches workflow runs from multiple repositories
|
||||
- Supports both public and private repositories
|
||||
|
||||
### Rate Limiting and Caching
|
||||
- **Parallel Processing**: API requests are made in parallel with intelligent rate limiting
|
||||
- **Smart Caching**: Responses are cached with configurable TTL (defaults to 5 minutes)
|
||||
- **Rate Limit Monitoring**: Tracks and respects GitHub's rate limits with automatic waiting
|
||||
- **Controlled Concurrency**: Maximum 10 concurrent requests with 10 requests/second limit
|
||||
- **Cache Management**: Provides API endpoints to view cache statistics and clear cache when needed
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Configuration Setup
|
||||
1. Copy `config.example.json` to `config.json`
|
||||
2. Add GitHub Personal Access Token with required permissions
|
||||
3. Configure repositories to monitor
|
||||
4. Server automatically reloads when config changes
|
||||
|
||||
### TypeScript Configuration
|
||||
- Strict TypeScript enabled with comprehensive type checking
|
||||
- Vite bundler with React plugin
|
||||
- Separate tsconfig for Node.js server code
|
||||
|
||||
### Testing
|
||||
- Vitest for unit testing
|
||||
- Run tests with `npm run test`
|
||||
|
||||
### Build Process
|
||||
- TypeScript compilation followed by Vite build
|
||||
- Production build outputs to `dist/`
|
||||
- Server code compiled separately
|
||||
165
README.md
Normal file
165
README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# GitHub Actions Radar
|
||||
|
||||
A web application that displays GitHub Actions workflow runs across multiple repositories and organizations in a unified dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
- 📊 **Unified Dashboard**: View workflow runs from multiple repositories in one place
|
||||
- 🔍 **Filtering**: Filter by status (success, failure, in progress) and repository
|
||||
- 📈 **Statistics**: Overview of total runs, success rate, and current activity
|
||||
- 🔄 **Real-time Updates**: Refresh workflow run data with a single click
|
||||
- 🎨 **Clean UI**: Modern, responsive interface with status indicators
|
||||
- ⚙️ **Easy Configuration**: Simple setup for GitHub tokens and repositories
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v16 or higher)
|
||||
- npm or yarn
|
||||
- GitHub Personal Access Token
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd radar
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Open your browser to `http://localhost:3000`
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Create configuration file**: Copy `config.example.json` to `config.json`
|
||||
2. **GitHub Token Setup**:
|
||||
- Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
|
||||
- Click "Generate new token (classic)"
|
||||
- Give it a descriptive name like "GitHub Actions Radar"
|
||||
- Select the following permissions:
|
||||
- `repo` (Full control of private repositories)
|
||||
- `actions:read` (Read access to actions and workflows)
|
||||
- Click "Generate token"
|
||||
- Copy the token and paste it in `config.json`
|
||||
3. **Add repositories**: List the repositories you want to monitor in the `repositories` array
|
||||
4. **Configure cache (optional)**: Set cache timeout in minutes (defaults to 5 minutes if not specified)
|
||||
|
||||
Example `config.json`:
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"token": "ghp_your_github_token_here",
|
||||
"repositories": [
|
||||
{
|
||||
"owner": "facebook",
|
||||
"name": "react"
|
||||
},
|
||||
{
|
||||
"owner": "your-org",
|
||||
"name": "your-repo"
|
||||
}
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"port": 3001,
|
||||
"host": "0.0.0.0"
|
||||
},
|
||||
"cache": {
|
||||
"timeoutMinutes": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Dashboard Features
|
||||
|
||||
- **Status Cards**: Quick overview of total runs, successes, failures, and in-progress workflows
|
||||
- **Filters**: Use the dropdown filters to focus on specific statuses or repositories
|
||||
- **Workflow Cards**: Each card shows:
|
||||
- Repository name and run number
|
||||
- Branch and commit information
|
||||
- Actor (who triggered the run)
|
||||
- Time since the run started
|
||||
- Direct link to GitHub
|
||||
|
||||
### Refresh Data
|
||||
|
||||
Click the "Refresh" button to fetch the latest workflow runs from all configured repositories.
|
||||
|
||||
### Update Configuration
|
||||
|
||||
To update your configuration:
|
||||
1. Edit the `config.json` file
|
||||
2. Restart the application (the server will automatically reload the configuration)
|
||||
|
||||
## Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server (both frontend and backend)
|
||||
- `npm run server` - Start backend server only
|
||||
- `npm run build` - Build for production
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run preview` - Preview production build
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── Dashboard.tsx # Main dashboard component
|
||||
│ └── CompactWorkflowCard.tsx # Compact workflow run display
|
||||
├── services/
|
||||
│ └── api.ts # Backend API client
|
||||
├── types/
|
||||
│ └── github.ts # TypeScript interfaces
|
||||
├── App.tsx # Main application component
|
||||
└── main.tsx # Application entry point
|
||||
|
||||
server/
|
||||
├── config.ts # Configuration loading
|
||||
├── github.ts # GitHub API integration
|
||||
└── index.ts # Express server
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **React** - Frontend framework
|
||||
- **TypeScript** - Type safety
|
||||
- **Tailwind CSS** - Styling
|
||||
- **Vite** - Build tool and dev server
|
||||
- **Express** - Backend server
|
||||
- **Axios** - HTTP client
|
||||
- **date-fns** - Date formatting
|
||||
- **Lucide React** - Icons
|
||||
|
||||
## Security
|
||||
|
||||
- GitHub tokens are stored in server-side configuration file
|
||||
- Tokens are only used for API calls to GitHub
|
||||
- No data is sent to external servers
|
||||
- All communication is directly with GitHub's API
|
||||
- Configuration file is excluded from version control
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run tests and linting
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
72
config-examples.md
Normal file
72
config-examples.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Configuration Examples
|
||||
|
||||
## Default Cache (5 minutes)
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"token": "ghp_your_github_token_here",
|
||||
"repositories": [
|
||||
{
|
||||
"owner": "facebook",
|
||||
"name": "react"
|
||||
}
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"port": 3001,
|
||||
"host": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Short Cache (1 minute)
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"token": "ghp_your_github_token_here",
|
||||
"repositories": [
|
||||
{
|
||||
"owner": "facebook",
|
||||
"name": "react"
|
||||
}
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"port": 3001,
|
||||
"host": "0.0.0.0"
|
||||
},
|
||||
"cache": {
|
||||
"timeoutMinutes": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Long Cache (15 minutes)
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"token": "ghp_your_github_token_here",
|
||||
"repositories": [
|
||||
{
|
||||
"owner": "facebook",
|
||||
"name": "react"
|
||||
}
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"port": 3001,
|
||||
"host": "0.0.0.0"
|
||||
},
|
||||
"cache": {
|
||||
"timeoutMinutes": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cache Configuration Notes
|
||||
|
||||
- **timeoutMinutes**: Sets how long API responses are cached (in minutes)
|
||||
- **Default**: 5 minutes if not specified
|
||||
- **Range**: Any positive number (1 minute minimum recommended)
|
||||
- **Live Reload**: Changes to cache timeout require server restart to take effect
|
||||
- **Memory Usage**: Longer cache times = more memory usage but fewer API calls
|
||||
26
config.example.json
Normal file
26
config.example.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"github": {
|
||||
"token": "ghp_your_github_token_here",
|
||||
"repositories": [
|
||||
{
|
||||
"owner": "facebook",
|
||||
"name": "react"
|
||||
},
|
||||
{
|
||||
"owner": "microsoft",
|
||||
"name": "vscode"
|
||||
},
|
||||
{
|
||||
"owner": "your-org",
|
||||
"name": "your-repo"
|
||||
}
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"port": 3001,
|
||||
"host": "0.0.0.0"
|
||||
},
|
||||
"cache": {
|
||||
"timeoutMinutes": 5
|
||||
}
|
||||
}
|
||||
56
debug-cache.js
Normal file
56
debug-cache.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Debug script to test cache behavior in detail
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE = 'http://localhost:3001/api';
|
||||
|
||||
async function debugCache() {
|
||||
console.log('🔍 Starting cache debug session...\n');
|
||||
|
||||
console.log('📊 Clearing cache first:');
|
||||
try {
|
||||
await axios.delete(`${API_BASE}/cache`);
|
||||
console.log('✅ Cache cleared\n');
|
||||
} catch (error) {
|
||||
console.log(`❌ Error clearing cache: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Making first request - should make API calls:');
|
||||
const start1 = Date.now();
|
||||
try {
|
||||
const response1 = await axios.get(`${API_BASE}/workflow-runs`);
|
||||
console.log(`✅ First request: ${response1.data.length} runs (${Date.now() - start1}ms)\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ First request failed: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Waiting 2 seconds...\n');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
console.log('📊 Making second request - should hit cache:');
|
||||
const start2 = Date.now();
|
||||
try {
|
||||
const response2 = await axios.get(`${API_BASE}/workflow-runs`);
|
||||
console.log(`✅ Second request: ${response2.data.length} runs (${Date.now() - start2}ms)\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Second request failed: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Cache stats:');
|
||||
try {
|
||||
const stats = await axios.get(`${API_BASE}/cache/stats`);
|
||||
console.log(`💾 Cache entries: ${stats.data.size}`);
|
||||
console.log(`💾 Cache keys: ${stats.data.entries.join(', ')}\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Cache stats failed: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Rate limit info:');
|
||||
try {
|
||||
const rateLimit = await axios.get(`${API_BASE}/rate-limit`);
|
||||
console.log(`🔄 Rate limit: ${rateLimit.data.remaining}/${rateLimit.data.limit}`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Rate limit failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
debugCache().catch(console.error);
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GitHub Actions Radar</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6899
package-lock.json
generated
Normal file
6899
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "github-actions-radar",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run server\" \"vite\"",
|
||||
"server": "tsx server/index.ts",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"chokidar": "^3.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^1.0.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"tsx": "^4.6.2",
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
17
src/App.tsx
Normal file
17
src/App.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { SettingsProvider } from './contexts/SettingsContext';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<SettingsProvider>
|
||||
<div className="App">
|
||||
<Dashboard />
|
||||
</div>
|
||||
</SettingsProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
229
src/components/ApiStatusModal.tsx
Normal file
229
src/components/ApiStatusModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { X, Activity, Database, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { ApiService } from '../services/api';
|
||||
|
||||
interface ApiStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface RateLimitInfo {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
resetTime: number;
|
||||
resetTimeFormatted: string;
|
||||
timeUntilReset: number;
|
||||
used: number;
|
||||
}
|
||||
|
||||
interface CacheStats {
|
||||
size: number;
|
||||
entries: string[];
|
||||
}
|
||||
|
||||
export const ApiStatusModal: React.FC<ApiStatusModalProps> = ({ isOpen, onClose }) => {
|
||||
const [rateLimitInfo, setRateLimitInfo] = useState<RateLimitInfo | null>(null);
|
||||
const [cacheStats, setCacheStats] = useState<CacheStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
|
||||
const apiService = useMemo(() => new ApiService(), []);
|
||||
|
||||
const fetchApiStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [rateLimit, cache] = await Promise.all([
|
||||
apiService.getRateLimitInfo(),
|
||||
apiService.getCacheStats()
|
||||
]);
|
||||
|
||||
setRateLimitInfo(rateLimit);
|
||||
setCacheStats(cache);
|
||||
} catch (error) {
|
||||
console.error('Error fetching API status:', error);
|
||||
setError('Failed to fetch API status information');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiService]);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true);
|
||||
try {
|
||||
await apiService.clearCache();
|
||||
await fetchApiStatus(); // Refresh the stats
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setError('Failed to clear cache');
|
||||
} finally {
|
||||
setClearingCache(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchApiStatus();
|
||||
}
|
||||
}, [isOpen, fetchApiStatus]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getRateLimitColor = (remaining: number, limit: number) => {
|
||||
const percentage = (remaining / limit) * 100;
|
||||
if (percentage > 50) return 'text-ctp-green';
|
||||
if (percentage > 20) return 'text-ctp-yellow';
|
||||
return 'text-ctp-red';
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (milliseconds: number) => {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-ctp-base border border-ctp-surface0 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-ctp-surface0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="w-6 h-6 text-ctp-blue" />
|
||||
<h2 className="text-xl font-semibold text-ctp-text">API Status</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ctp-subtext1 hover:text-ctp-text transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="bg-ctp-red/10 border border-ctp-red/20 rounded-lg p-4 text-ctp-red">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-ctp-blue mx-auto mb-4" />
|
||||
<p className="text-ctp-subtext1">Loading API status...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Rate Limit Information */}
|
||||
{rateLimitInfo && (
|
||||
<div className="bg-ctp-mantle rounded-lg p-4 border border-ctp-surface0">
|
||||
<h3 className="text-lg font-semibold text-ctp-text mb-4 flex items-center">
|
||||
<Activity className="w-5 h-5 mr-2 text-ctp-blue" />
|
||||
GitHub API Rate Limit
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-ctp-subtext1">Remaining</p>
|
||||
<p className={`text-2xl font-bold ${getRateLimitColor(rateLimitInfo.remaining, rateLimitInfo.limit)}`}>
|
||||
{rateLimitInfo.remaining}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-ctp-subtext1">Total Limit</p>
|
||||
<p className="text-2xl font-bold text-ctp-text">
|
||||
{rateLimitInfo.limit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-ctp-subtext1">Used</p>
|
||||
<p className="text-2xl font-bold text-ctp-text">
|
||||
{rateLimitInfo.used}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-ctp-subtext1">Reset In</p>
|
||||
<p className="text-2xl font-bold text-ctp-text">
|
||||
{formatTimeRemaining(rateLimitInfo.timeUntilReset)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-ctp-subtext1 mb-2">
|
||||
<span>Usage</span>
|
||||
<span>{Math.round(((rateLimitInfo.limit - rateLimitInfo.remaining) / rateLimitInfo.limit) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-ctp-surface0 rounded-full h-2">
|
||||
<div
|
||||
className="bg-ctp-blue h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${((rateLimitInfo.limit - rateLimitInfo.remaining) / rateLimitInfo.limit) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache Information */}
|
||||
{cacheStats && (
|
||||
<div className="bg-ctp-mantle rounded-lg p-4 border border-ctp-surface0">
|
||||
<h3 className="text-lg font-semibold text-ctp-text mb-4 flex items-center">
|
||||
<Database className="w-5 h-5 mr-2 text-ctp-green" />
|
||||
Cache Status
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-ctp-subtext1">Cached Entries</p>
|
||||
<p className="text-2xl font-bold text-ctp-text">
|
||||
{cacheStats.size}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-ctp-red/10 hover:bg-ctp-red/20 border border-ctp-red/20 rounded-lg text-ctp-red transition-colors disabled:opacity-50"
|
||||
>
|
||||
{clearingCache ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
<span>Clear Cache</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refresh Button */}
|
||||
<div className="flex justify-center pt-4">
|
||||
<button
|
||||
onClick={fetchApiStatus}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-6 py-2 bg-ctp-blue hover:bg-ctp-blue/80 rounded-lg text-ctp-base transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh Status</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
167
src/components/CompactWorkflowCard.tsx
Normal file
167
src/components/CompactWorkflowCard.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { CheckCircle, XCircle, Clock, AlertCircle, ExternalLink, Pause, Play, HelpCircle, Ban, Timer } from 'lucide-react';
|
||||
import { WorkflowRun } from '../types/github';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
|
||||
interface CompactWorkflowCardProps {
|
||||
run: WorkflowRun;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string, conclusion: string | null) => {
|
||||
// Handle by status first
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return <Play className="w-5 h-5 text-ctp-blue" />;
|
||||
case 'queued':
|
||||
case 'requested':
|
||||
case 'pending':
|
||||
return <Clock className="w-5 h-5 text-ctp-overlay1" />;
|
||||
case 'waiting':
|
||||
return <Pause className="w-5 h-5 text-ctp-rosewater" />;
|
||||
case 'completed':
|
||||
// Handle by conclusion for completed runs
|
||||
switch (conclusion) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-ctp-green" />;
|
||||
case 'failure':
|
||||
return <XCircle className="w-5 h-5 text-ctp-red" />;
|
||||
case 'cancelled':
|
||||
return <Ban className="w-5 h-5 text-ctp-overlay1" />;
|
||||
case 'action_required':
|
||||
return <AlertCircle className="w-5 h-5 text-ctp-yellow" />;
|
||||
case 'neutral':
|
||||
return <HelpCircle className="w-5 h-5 text-ctp-overlay1" />;
|
||||
case 'skipped':
|
||||
return <AlertCircle className="w-5 h-5 text-ctp-overlay0" />;
|
||||
case 'stale':
|
||||
return <Clock className="w-5 h-5 text-ctp-overlay1" />;
|
||||
case 'timed_out':
|
||||
return <Timer className="w-5 h-5 text-ctp-yellow" />;
|
||||
default:
|
||||
return <HelpCircle className="w-5 h-5 text-ctp-overlay1" />;
|
||||
}
|
||||
default:
|
||||
return <HelpCircle className="w-5 h-5 text-ctp-overlay1" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string, conclusion: string | null) => {
|
||||
// Handle by status first
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return 'bg-ctp-mantle border-ctp-blue/30';
|
||||
case 'queued':
|
||||
case 'requested':
|
||||
case 'pending':
|
||||
return 'bg-ctp-mantle border-ctp-surface2';
|
||||
case 'waiting':
|
||||
return 'bg-ctp-mantle border-ctp-rosewater/30';
|
||||
case 'completed':
|
||||
// Handle by conclusion for completed runs
|
||||
switch (conclusion) {
|
||||
case 'success':
|
||||
return 'bg-ctp-mantle border-ctp-green/30';
|
||||
case 'failure':
|
||||
return 'bg-ctp-mantle border-ctp-red/30';
|
||||
case 'cancelled':
|
||||
return 'bg-ctp-mantle border-ctp-surface2';
|
||||
case 'action_required':
|
||||
return 'bg-ctp-mantle border-ctp-yellow/30';
|
||||
case 'neutral':
|
||||
return 'bg-ctp-mantle border-ctp-surface2';
|
||||
case 'skipped':
|
||||
return 'bg-ctp-mantle border-ctp-surface1';
|
||||
case 'stale':
|
||||
return 'bg-ctp-mantle border-ctp-surface2';
|
||||
case 'timed_out':
|
||||
return 'bg-ctp-mantle border-ctp-yellow/30';
|
||||
default:
|
||||
return 'bg-ctp-mantle border-ctp-surface2';
|
||||
}
|
||||
default:
|
||||
return 'bg-ctp-mantle border-ctp-surface2';
|
||||
}
|
||||
};
|
||||
|
||||
export const CompactWorkflowCard: React.FC<CompactWorkflowCardProps> = ({ run, onClick }) => {
|
||||
const { settings } = useSettings();
|
||||
const isCurrentUserFailure = settings.githubUsername &&
|
||||
run.actor.login === settings.githubUsername &&
|
||||
run.status === 'completed' &&
|
||||
run.conclusion === 'failure';
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Don't trigger onClick if clicking on the external link
|
||||
if ((e.target as HTMLElement).closest('a')) {
|
||||
return;
|
||||
}
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
const getCardClasses = () => {
|
||||
const baseClasses = `${getStatusColor(run.status, run.conclusion)} border rounded-lg p-4 hover:shadow-md transition-all duration-200 ${onClick ? 'cursor-pointer' : ''}`;
|
||||
|
||||
if (isCurrentUserFailure) {
|
||||
return `${baseClasses} ring-2 ring-ctp-red/50 shadow-ctp-red/20 shadow-lg`;
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={getCardClasses()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={run.repository.owner.avatar_url}
|
||||
alt={run.repository.owner.login}
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
<h3 className="font-semibold text-ctp-text text-sm truncate">
|
||||
{run.repository.name}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href={run.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ctp-blue hover:text-ctp-lavender flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(run.status, run.conclusion)}
|
||||
<span className="text-sm text-ctp-subtext0">
|
||||
#{run.run_number}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-ctp-subtext1">
|
||||
{formatDistanceToNow(new Date(run.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-ctp-subtext1">
|
||||
<span className="font-medium">{run.actor.login}</span>
|
||||
{run.head_commit && (
|
||||
<span className="ml-2 font-mono bg-ctp-surface0 text-ctp-text px-1 py-0.5 rounded">
|
||||
{run.head_commit.id.substring(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.head_commit && (
|
||||
<div className="mt-2 text-xs text-ctp-subtext0 truncate" title={run.head_commit.message}>
|
||||
{run.head_commit.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
src/components/ConfigurationModal.tsx
Normal file
155
src/components/ConfigurationModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Plus, Trash2, Github } from 'lucide-react';
|
||||
import { Repository } from '../types/github';
|
||||
|
||||
interface ConfigurationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
token: string;
|
||||
repositories: Repository[];
|
||||
onSave: (token: string, repositories: Repository[]) => void;
|
||||
}
|
||||
|
||||
export const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
token,
|
||||
repositories,
|
||||
onSave
|
||||
}) => {
|
||||
const [localToken, setLocalToken] = useState(token);
|
||||
const [localRepositories, setLocalRepositories] = useState<Repository[]>(repositories);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const addRepository = () => {
|
||||
setLocalRepositories([...localRepositories, { owner: '', name: '' }]);
|
||||
};
|
||||
|
||||
const removeRepository = (index: number) => {
|
||||
setLocalRepositories(localRepositories.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateRepository = (index: number, field: keyof Repository, value: string) => {
|
||||
const updated = localRepositories.map((repo, i) =>
|
||||
i === index ? { ...repo, [field]: value } : repo
|
||||
);
|
||||
setLocalRepositories(updated);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const validRepos = localRepositories.filter(repo => repo.owner && repo.name);
|
||||
onSave(localToken, validRepos);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Configuration</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
GitHub Personal Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={localToken}
|
||||
onChange={(e) => setLocalToken(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="ghp_..."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Create a token at{' '}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
GitHub Settings
|
||||
</a>
|
||||
{' '}with 'repo' and 'actions:read' permissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Repositories
|
||||
</label>
|
||||
<button
|
||||
onClick={addRepository}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Repository</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{localRepositories.map((repo, index) => (
|
||||
<div key={index} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<Github className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={repo.owner}
|
||||
onChange={(e) => updateRepository(index, 'owner', e.target.value)}
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="owner"
|
||||
/>
|
||||
<span className="text-gray-400">/</span>
|
||||
<input
|
||||
type="text"
|
||||
value={repo.name}
|
||||
onChange={(e) => updateRepository(index, 'name', e.target.value)}
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="repository"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeRepository(index)}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{localRepositories.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Github className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No repositories configured</p>
|
||||
<p className="text-sm">Click "Add Repository" to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 p-6 border-t bg-gray-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
283
src/components/Dashboard.tsx
Normal file
283
src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { RefreshCw, AlertCircle, Settings, Activity } from 'lucide-react';
|
||||
import { WorkflowRun } from '../types/github';
|
||||
import { ApiService } from '../services/api';
|
||||
import { CompactWorkflowCard } from './CompactWorkflowCard';
|
||||
import { WorkflowRunsModal } from './WorkflowRunsModal';
|
||||
import { ThemeSwitcher } from './ThemeSwitcher';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { ApiStatusModal } from './ApiStatusModal';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { NotificationService } from '../services/notifications';
|
||||
|
||||
interface DashboardProps {}
|
||||
|
||||
export const Dashboard: React.FC<DashboardProps> = () => {
|
||||
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRun[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [repositoryCount, setRepositoryCount] = useState(0);
|
||||
const [selectedRepository, setSelectedRepository] = useState<{owner: string, name: string} | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isApiStatusOpen, setIsApiStatusOpen] = useState(false);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const previousWorkflowRuns = useRef<WorkflowRun[]>([]);
|
||||
const notificationService = useMemo(() => NotificationService.getInstance(), []);
|
||||
|
||||
const apiService = useMemo(() => new ApiService(), []);
|
||||
|
||||
const fetchWorkflowRuns = useCallback(async (isAutoRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const runs = await apiService.getWorkflowRuns();
|
||||
|
||||
// Check for notifications if this is an auto-refresh and notifications are enabled
|
||||
if (isAutoRefresh && settings.notifications.enabled && previousWorkflowRuns.current.length > 0) {
|
||||
const previousRuns = previousWorkflowRuns.current;
|
||||
const currentRuns = runs;
|
||||
|
||||
// Find new failures
|
||||
if (settings.notifications.showFailures) {
|
||||
const newFailures = currentRuns.filter(currentRun =>
|
||||
currentRun.conclusion === 'failure' &&
|
||||
!previousRuns.some(prevRun =>
|
||||
prevRun.id === currentRun.id &&
|
||||
prevRun.conclusion === 'failure'
|
||||
)
|
||||
);
|
||||
|
||||
if (newFailures.length > 0) {
|
||||
notificationService.showFailureNotification(newFailures);
|
||||
}
|
||||
}
|
||||
|
||||
// Find new recoveries (previously failed runs that are now successful)
|
||||
if (settings.notifications.showRecoveries) {
|
||||
const newRecoveries = currentRuns.filter(currentRun =>
|
||||
currentRun.conclusion === 'success' &&
|
||||
previousRuns.some(prevRun =>
|
||||
prevRun.repository.full_name === currentRun.repository.full_name &&
|
||||
prevRun.conclusion === 'failure'
|
||||
) &&
|
||||
!previousRuns.some(prevRun =>
|
||||
prevRun.id === currentRun.id &&
|
||||
prevRun.conclusion === 'success'
|
||||
)
|
||||
);
|
||||
|
||||
if (newRecoveries.length > 0) {
|
||||
notificationService.showSuccessNotification(newRecoveries);
|
||||
}
|
||||
}
|
||||
|
||||
// Find new waiting runs
|
||||
if (settings.notifications.showWaiting) {
|
||||
const newWaitingRuns = currentRuns.filter(currentRun =>
|
||||
currentRun.status === 'waiting' &&
|
||||
!previousRuns.some(prevRun =>
|
||||
prevRun.id === currentRun.id &&
|
||||
prevRun.status === 'waiting'
|
||||
)
|
||||
);
|
||||
|
||||
if (newWaitingRuns.length > 0) {
|
||||
notificationService.showWaitingNotification(newWaitingRuns);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousWorkflowRuns.current = runs;
|
||||
setWorkflowRuns(runs);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflow runs:', error);
|
||||
setError('Failed to fetch workflow runs. Please check your configuration.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiService, settings.notifications, notificationService]);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await apiService.getConfig();
|
||||
setRepositoryCount(config.repositories.length);
|
||||
} catch (error) {
|
||||
console.error('Error fetching config:', error);
|
||||
}
|
||||
}, [apiService]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchWorkflowRuns();
|
||||
}, [fetchConfig, fetchWorkflowRuns]);
|
||||
|
||||
// Request notification permission when notifications are enabled
|
||||
useEffect(() => {
|
||||
if (settings.notifications.enabled) {
|
||||
notificationService.requestPermission();
|
||||
}
|
||||
}, [settings.notifications.enabled, notificationService]);
|
||||
|
||||
// Auto-refresh functionality
|
||||
useEffect(() => {
|
||||
if (!settings.autoRefreshInterval) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchWorkflowRuns(true);
|
||||
}, settings.autoRefreshInterval * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [settings.autoRefreshInterval, fetchWorkflowRuns]);
|
||||
|
||||
|
||||
const statusCounts = {
|
||||
total: workflowRuns.length,
|
||||
success: workflowRuns.filter(r => r.conclusion === 'success').length,
|
||||
failure: workflowRuns.filter(r => r.conclusion === 'failure').length,
|
||||
active: workflowRuns.filter(r =>
|
||||
r.status === 'in_progress' ||
|
||||
r.status === 'queued'
|
||||
).length,
|
||||
waiting: workflowRuns.filter(r => r.status === 'queued').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ctp-base">
|
||||
<div className="bg-ctp-mantle shadow-sm border-b border-ctp-surface0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold text-ctp-text">GitHub Actions Radar</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
|
||||
<p className="text-sm text-ctp-subtext0">Latest main branch runs</p>
|
||||
{settings.showLastUpdateTime && lastUpdated && (
|
||||
<p className="text-xs text-ctp-subtext1">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{settings.githubUsername && (
|
||||
<p className="text-xs text-ctp-subtext1 truncate">
|
||||
Welcome, {settings.githubUsername}!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 md:space-x-4 ml-4">
|
||||
<ThemeSwitcher />
|
||||
<button
|
||||
onClick={() => setIsApiStatusOpen(true)}
|
||||
className="flex items-center space-x-2 px-2 md:px-3 py-2 bg-ctp-mantle border border-ctp-surface0 rounded-lg hover:bg-ctp-surface0 transition-colors"
|
||||
>
|
||||
<Activity className="w-4 h-4 text-ctp-text" />
|
||||
<span className="text-sm text-ctp-text hidden sm:inline">API Status</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="flex items-center space-x-2 px-2 md:px-3 py-2 bg-ctp-mantle border border-ctp-surface0 rounded-lg hover:bg-ctp-surface0 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4 text-ctp-text" />
|
||||
<span className="text-sm text-ctp-text hidden sm:inline">Settings</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchWorkflowRuns}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-3 md:px-4 py-2 bg-ctp-blue text-ctp-base rounded-lg hover:bg-ctp-lavender disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</button>
|
||||
<span className="text-sm text-ctp-subtext0 hidden md:inline">
|
||||
{repositoryCount} repositories
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||||
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
|
||||
<div className="text-2xl font-bold text-ctp-text">{statusCounts.total}</div>
|
||||
<div className="text-sm text-ctp-subtext0">Total Runs</div>
|
||||
</div>
|
||||
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
|
||||
<div className="text-2xl font-bold text-ctp-green">{statusCounts.success}</div>
|
||||
<div className="text-sm text-ctp-subtext0">Successful</div>
|
||||
</div>
|
||||
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
|
||||
<div className="text-2xl font-bold text-ctp-red">{statusCounts.failure}</div>
|
||||
<div className="text-sm text-ctp-subtext0">Failed</div>
|
||||
</div>
|
||||
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
|
||||
<div className="text-2xl font-bold text-ctp-blue">{statusCounts.active}</div>
|
||||
<div className="text-sm text-ctp-subtext0">Active</div>
|
||||
</div>
|
||||
<div className="bg-ctp-mantle rounded-lg shadow-sm border border-ctp-surface0 p-6">
|
||||
<div className="text-2xl font-bold text-ctp-rosewater">{statusCounts.waiting}</div>
|
||||
<div className="text-sm text-ctp-subtext0">Waiting</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="bg-ctp-mantle border border-ctp-red/30 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="w-5 h-5 text-ctp-red" />
|
||||
<p className="text-ctp-red">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{loading ? (
|
||||
<div className="col-span-full flex justify-center py-12">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-ctp-blue" />
|
||||
</div>
|
||||
) : workflowRuns.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<p className="text-ctp-subtext0">No workflow runs found.</p>
|
||||
<p className="text-sm text-ctp-subtext1 mt-2">Check your config.json file</p>
|
||||
</div>
|
||||
) : (
|
||||
workflowRuns.map(run => (
|
||||
<CompactWorkflowCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
onClick={() => {
|
||||
setSelectedRepository({
|
||||
owner: run.repository.owner.login,
|
||||
name: run.repository.name
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorkflowRunsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedRepository(null);
|
||||
}}
|
||||
repository={selectedRepository}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
/>
|
||||
|
||||
<ApiStatusModal
|
||||
isOpen={isApiStatusOpen}
|
||||
onClose={() => setIsApiStatusOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
271
src/components/SettingsModal.tsx
Normal file
271
src/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, User, Save, RotateCcw, Clock, Eye, Bell } from 'lucide-react';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { NotificationService } from '../services/notifications';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => {
|
||||
const { settings, updateSettings, resetSettings } = useSettings();
|
||||
const [localSettings, setLocalSettings] = useState(settings);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const notificationService = NotificationService.getInstance();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalSettings(settings);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [isOpen, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const settingsChanged = Object.keys(localSettings).some(
|
||||
key => localSettings[key as keyof typeof localSettings] !== settings[key as keyof typeof settings]
|
||||
);
|
||||
setHasChanges(settingsChanged);
|
||||
}, [localSettings, settings]);
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings(localSettings);
|
||||
setHasChanges(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirm('Are you sure you want to reset all settings to defaults?')) {
|
||||
resetSettings();
|
||||
setLocalSettings({
|
||||
githubUsername: '',
|
||||
autoRefreshInterval: undefined,
|
||||
showLastUpdateTime: true,
|
||||
notifications: {
|
||||
enabled: false,
|
||||
showFailures: true,
|
||||
showRecoveries: true,
|
||||
showWaiting: true,
|
||||
},
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
const hasPermission = await notificationService.requestPermission();
|
||||
if (hasPermission) {
|
||||
// Create a mock failed workflow run for testing
|
||||
const mockFailedRun = {
|
||||
id: 'test-' + Date.now(),
|
||||
repository: { full_name: 'test/repository' },
|
||||
display_title: 'Test Notification',
|
||||
conclusion: 'failure' as const,
|
||||
status: 'completed' as const,
|
||||
actor: { login: 'test-user' }
|
||||
};
|
||||
notificationService.showFailureNotification([mockFailedRun]);
|
||||
} else {
|
||||
alert('Notification permission denied. Please enable notifications in your browser settings.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
if (hasChanges) {
|
||||
if (confirm('You have unsaved changes. Are you sure you want to close?')) {
|
||||
onClose();
|
||||
}
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-ctp-base rounded-lg shadow-xl max-w-md w-full">
|
||||
<div className="flex items-center justify-between p-6 border-b border-ctp-surface0">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-ctp-text">Settings</h2>
|
||||
<p className="text-sm text-ctp-subtext0">Customize your GitHub Actions Radar</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ctp-subtext0 hover:text-ctp-text transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* GitHub Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ctp-text mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>GitHub Username</span>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localSettings.githubUsername}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, githubUsername: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-ctp-mantle border border-ctp-surface0 rounded-lg text-ctp-text placeholder-ctp-subtext1 focus:ring-2 focus:ring-ctp-blue focus:border-transparent transition-colors"
|
||||
placeholder="Enter your GitHub username"
|
||||
/>
|
||||
<p className="text-xs text-ctp-subtext1 mt-1">
|
||||
Optional: Used for personalizing your experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Refresh Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ctp-text mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Auto Refresh (seconds)</span>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="30"
|
||||
max="3600"
|
||||
value={localSettings.autoRefreshInterval || ''}
|
||||
onChange={(e) => setLocalSettings({
|
||||
...localSettings,
|
||||
autoRefreshInterval: e.target.value ? parseInt(e.target.value) : undefined
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-ctp-mantle border border-ctp-surface0 rounded-lg text-ctp-text placeholder-ctp-subtext1 focus:ring-2 focus:ring-ctp-blue focus:border-transparent transition-colors"
|
||||
placeholder="Leave empty to disable"
|
||||
/>
|
||||
<p className="text-xs text-ctp-subtext1 mt-1">
|
||||
Automatically refresh workflow runs every N seconds (30-3600)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Show Last Update Time */}
|
||||
<div>
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.showLastUpdateTime}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, showLastUpdateTime: e.target.checked })}
|
||||
className="w-4 h-4 text-ctp-blue bg-ctp-mantle border-ctp-surface0 rounded focus:ring-ctp-blue focus:ring-2"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Eye className="w-4 h-4 text-ctp-text" />
|
||||
<span className="text-sm font-medium text-ctp-text">Show last update time</span>
|
||||
</div>
|
||||
</label>
|
||||
<p className="text-xs text-ctp-subtext1 mt-1 ml-7">
|
||||
Display when the workflow data was last refreshed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div>
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.notifications.enabled}
|
||||
onChange={(e) => setLocalSettings({
|
||||
...localSettings,
|
||||
notifications: { ...localSettings.notifications, enabled: e.target.checked }
|
||||
})}
|
||||
className="w-4 h-4 text-ctp-blue bg-ctp-mantle border-ctp-surface0 rounded focus:ring-ctp-blue focus:ring-2"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bell className="w-4 h-4 text-ctp-text" />
|
||||
<span className="text-sm font-medium text-ctp-text">Enable notifications</span>
|
||||
</div>
|
||||
</label>
|
||||
<p className="text-xs text-ctp-subtext1 mt-1 ml-7">
|
||||
Show browser notifications for build status changes
|
||||
</p>
|
||||
|
||||
{/* Notification sub-options */}
|
||||
{localSettings.notifications.enabled && (
|
||||
<div className="mt-3 ml-7 space-y-2">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.notifications.showFailures}
|
||||
onChange={(e) => setLocalSettings({
|
||||
...localSettings,
|
||||
notifications: { ...localSettings.notifications, showFailures: e.target.checked }
|
||||
})}
|
||||
className="w-3 h-3 text-ctp-red bg-ctp-mantle border-ctp-surface0 rounded focus:ring-ctp-red focus:ring-2"
|
||||
/>
|
||||
<span className="text-xs text-ctp-text">Notify on build failures</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.notifications.showRecoveries}
|
||||
onChange={(e) => setLocalSettings({
|
||||
...localSettings,
|
||||
notifications: { ...localSettings.notifications, showRecoveries: e.target.checked }
|
||||
})}
|
||||
className="w-3 h-3 text-ctp-green bg-ctp-mantle border-ctp-surface0 rounded focus:ring-ctp-green focus:ring-2"
|
||||
/>
|
||||
<span className="text-xs text-ctp-text">Notify on build recoveries</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.notifications.showWaiting}
|
||||
onChange={(e) => setLocalSettings({
|
||||
...localSettings,
|
||||
notifications: { ...localSettings.notifications, showWaiting: e.target.checked }
|
||||
})}
|
||||
className="w-3 h-3 text-ctp-yellow bg-ctp-mantle border-ctp-surface0 rounded focus:ring-ctp-yellow focus:ring-2"
|
||||
/>
|
||||
<span className="text-xs text-ctp-text">Notify on builds waiting approval</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleTestNotification}
|
||||
className="text-xs text-ctp-blue hover:text-ctp-lavender transition-colors underline"
|
||||
>
|
||||
Test notification
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between space-x-3 p-6 border-t border-ctp-surface0 bg-ctp-mantle rounded-b-lg">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center space-x-2 px-4 py-2 text-ctp-subtext0 bg-ctp-surface0 rounded-lg hover:bg-ctp-surface1 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-ctp-text bg-ctp-surface0 rounded-lg hover:bg-ctp-surface1 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-ctp-blue text-ctp-base rounded-lg hover:bg-ctp-lavender disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
src/components/ThemeSwitcher.tsx
Normal file
84
src/components/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Palette, Check, ChevronDown } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export const ThemeSwitcher: React.FC = () => {
|
||||
const { currentTheme, setTheme, themesByCategory } = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const categoryNames = {
|
||||
catppuccin: 'Catppuccin',
|
||||
neovim: 'NeoVim',
|
||||
vscode: 'VSCode',
|
||||
emacs: 'Emacs',
|
||||
classic: 'Classic'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center space-x-2 px-3 py-2 bg-ctp-mantle border border-ctp-surface0 rounded-lg hover:bg-ctp-surface0 transition-colors"
|
||||
>
|
||||
<Palette className="w-4 h-4 text-ctp-text" />
|
||||
<span className="text-sm text-ctp-text">{currentTheme.displayName}</span>
|
||||
<ChevronDown className={`w-4 h-4 text-ctp-subtext0 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-64 max-h-96 overflow-y-auto bg-ctp-mantle border border-ctp-surface0 rounded-lg shadow-lg z-20">
|
||||
<div className="py-1">
|
||||
{Object.entries(themesByCategory).map(([category, themes]) => (
|
||||
<div key={category}>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-ctp-subtext0 uppercase tracking-wider border-b border-ctp-surface0">
|
||||
{categoryNames[category as keyof typeof categoryNames] || category}
|
||||
</div>
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
setTheme(theme.name);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-left hover:bg-ctp-surface0 transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex space-x-1">
|
||||
<div
|
||||
className="w-2 h-4 rounded-sm"
|
||||
style={{ backgroundColor: theme.colors.red }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 h-4 rounded-sm"
|
||||
style={{ backgroundColor: theme.colors.green }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 h-4 rounded-sm"
|
||||
style={{ backgroundColor: theme.colors.blue }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 h-4 rounded-sm"
|
||||
style={{ backgroundColor: theme.colors.yellow }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-ctp-text">{theme.displayName}</span>
|
||||
</div>
|
||||
{currentTheme.name === theme.name && (
|
||||
<Check className="w-4 h-4 text-ctp-green" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
116
src/components/WorkflowRunCard.tsx
Normal file
116
src/components/WorkflowRunCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { CheckCircle, XCircle, Clock, AlertCircle, GitBranch, User, ExternalLink } from 'lucide-react';
|
||||
import { WorkflowRun } from '../types/github';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
|
||||
interface WorkflowRunCardProps {
|
||||
run: WorkflowRun;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string, conclusion: string | null) => {
|
||||
if (status === 'in_progress') {
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
switch (conclusion) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'failure':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case 'cancelled':
|
||||
return <AlertCircle className="w-4 h-4 text-gray-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string, conclusion: string | null) => {
|
||||
if (status === 'in_progress') return 'border-l-yellow-500';
|
||||
if (status === 'completed') {
|
||||
switch (conclusion) {
|
||||
case 'success': return 'border-l-green-500';
|
||||
case 'failure': return 'border-l-red-500';
|
||||
case 'cancelled': return 'border-l-gray-500';
|
||||
default: return 'border-l-yellow-500';
|
||||
}
|
||||
}
|
||||
return 'border-l-gray-500';
|
||||
};
|
||||
|
||||
export const WorkflowRunCard: React.FC<WorkflowRunCardProps> = ({ run }) => {
|
||||
const { settings } = useSettings();
|
||||
const isCurrentUserFailure = settings.githubUsername &&
|
||||
run.actor.login === settings.githubUsername &&
|
||||
run.status === 'completed' &&
|
||||
run.conclusion === 'failure';
|
||||
|
||||
const getCardClasses = () => {
|
||||
const baseClasses = `bg-white rounded-lg shadow-md p-4 border-l-4 ${getStatusColor(run.status, run.conclusion)} hover:shadow-lg transition-all duration-200`;
|
||||
|
||||
if (isCurrentUserFailure) {
|
||||
return `${baseClasses} ring-2 ring-red-500/30 shadow-red-500/20 shadow-lg`;
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={getCardClasses()}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
{getStatusIcon(run.status, run.conclusion)}
|
||||
<h3 className="font-semibold text-gray-900">{run.display_title}</h3>
|
||||
<a
|
||||
href={run.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">{run.repository.full_name}</span> •
|
||||
<span className="ml-1">#{run.run_number}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span>{run.head_branch}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{run.actor.login}</span>
|
||||
</div>
|
||||
<span>{formatDistanceToNow(new Date(run.created_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
|
||||
{run.head_commit && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<span className="font-mono text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{run.head_commit.id.substring(0, 7)}
|
||||
</span>
|
||||
<span className="ml-2">{run.head_commit.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={run.repository.owner.avatar_url}
|
||||
alt={run.repository.owner.login}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
194
src/components/WorkflowRunsModal.tsx
Normal file
194
src/components/WorkflowRunsModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { X, CheckCircle, XCircle, Clock, AlertCircle, Play, Pause, HelpCircle, Ban, Timer, GitBranch, User, ExternalLink } from 'lucide-react';
|
||||
import { WorkflowRun } from '../types/github';
|
||||
import { ApiService } from '../services/api';
|
||||
|
||||
interface WorkflowRunsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
repository: {
|
||||
owner: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string, conclusion: string | null) => {
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return <Play className="w-4 h-4 text-ctp-blue" />;
|
||||
case 'queued':
|
||||
case 'requested':
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-ctp-overlay1" />;
|
||||
case 'waiting':
|
||||
return <Pause className="w-4 h-4 text-ctp-rosewater" />;
|
||||
case 'completed':
|
||||
switch (conclusion) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-4 h-4 text-ctp-green" />;
|
||||
case 'failure':
|
||||
return <XCircle className="w-4 h-4 text-ctp-red" />;
|
||||
case 'cancelled':
|
||||
return <Ban className="w-4 h-4 text-ctp-overlay1" />;
|
||||
case 'action_required':
|
||||
return <AlertCircle className="w-4 h-4 text-ctp-yellow" />;
|
||||
case 'neutral':
|
||||
return <HelpCircle className="w-4 h-4 text-ctp-overlay1" />;
|
||||
case 'skipped':
|
||||
return <AlertCircle className="w-4 h-4 text-ctp-overlay0" />;
|
||||
case 'stale':
|
||||
return <Clock className="w-4 h-4 text-ctp-overlay1" />;
|
||||
case 'timed_out':
|
||||
return <Timer className="w-4 h-4 text-ctp-yellow" />;
|
||||
default:
|
||||
return <HelpCircle className="w-4 h-4 text-ctp-overlay1" />;
|
||||
}
|
||||
default:
|
||||
return <HelpCircle className="w-4 h-4 text-ctp-overlay1" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string, conclusion: string | null) => {
|
||||
if (status === 'completed' && conclusion) {
|
||||
return conclusion.charAt(0).toUpperCase() + conclusion.slice(1);
|
||||
}
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
};
|
||||
|
||||
export const WorkflowRunsModal: React.FC<WorkflowRunsModalProps> = ({ isOpen, onClose, repository }) => {
|
||||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const apiService = useMemo(() => new ApiService(), []);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWorkflowRuns = useCallback(async () => {
|
||||
if (!repository) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiService.getRepositoryWorkflowRuns(repository.owner, repository.name);
|
||||
setRuns(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflow runs:', error);
|
||||
setError('Failed to fetch workflow runs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [repository, apiService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && repository) {
|
||||
fetchWorkflowRuns();
|
||||
}
|
||||
}, [isOpen, repository, fetchWorkflowRuns]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-ctp-base rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-ctp-surface0">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-ctp-text">
|
||||
{repository?.owner}/{repository?.name}
|
||||
</h2>
|
||||
<p className="text-sm text-ctp-subtext0">Recent workflow runs</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ctp-subtext0 hover:text-ctp-text transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Clock className="w-8 h-8 animate-spin text-ctp-blue" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-ctp-red">{error}</p>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-ctp-subtext0">No workflow runs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="bg-ctp-mantle border border-ctp-surface0 rounded-lg p-4 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(run.status, run.conclusion)}
|
||||
<div>
|
||||
<h3 className="font-medium text-ctp-text">{run.display_title}</h3>
|
||||
<p className="text-sm text-ctp-subtext0">
|
||||
{getStatusText(run.status, run.conclusion)} • #{run.run_number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={run.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ctp-blue hover:text-ctp-lavender transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<GitBranch className="w-4 h-4 text-ctp-overlay1" />
|
||||
<span className="text-ctp-subtext0">Branch:</span>
|
||||
<span className="text-ctp-text font-mono">{run.head_branch}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4 text-ctp-overlay1" />
|
||||
<span className="text-ctp-subtext0">Author:</span>
|
||||
<span className="text-ctp-text">{run.actor.login}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm">
|
||||
<p className="text-ctp-subtext0 mb-1">Commit:</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
{run.head_commit && (
|
||||
<>
|
||||
<span className="font-mono text-xs bg-ctp-surface0 text-ctp-text px-2 py-1 rounded">
|
||||
{run.head_commit.id.substring(0, 7)}
|
||||
</span>
|
||||
<span className="text-ctp-text truncate">{run.head_commit.message}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-ctp-subtext1">
|
||||
{formatDistanceToNow(new Date(run.created_at), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
src/contexts/SettingsContext.tsx
Normal file
81
src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
export interface UserSettings {
|
||||
githubUsername: string;
|
||||
autoRefreshInterval?: number;
|
||||
showLastUpdateTime: boolean;
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
showFailures: boolean;
|
||||
showRecoveries: boolean;
|
||||
showWaiting: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SettingsContextType {
|
||||
settings: UserSettings;
|
||||
updateSettings: (newSettings: Partial<UserSettings>) => void;
|
||||
resetSettings: () => void;
|
||||
}
|
||||
|
||||
const defaultSettings: UserSettings = {
|
||||
githubUsername: '',
|
||||
autoRefreshInterval: undefined,
|
||||
showLastUpdateTime: true,
|
||||
notifications: {
|
||||
enabled: false,
|
||||
showFailures: true,
|
||||
showRecoveries: true,
|
||||
showWaiting: true,
|
||||
},
|
||||
};
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'github-actions-radar-settings';
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
|
||||
const [settings, setSettings] = useState<UserSettings>(defaultSettings);
|
||||
|
||||
useEffect(() => {
|
||||
// Load settings from localStorage
|
||||
const savedSettings = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(savedSettings);
|
||||
setSettings({ ...defaultSettings, ...parsedSettings });
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSettings = (newSettings: Partial<UserSettings>) => {
|
||||
const updatedSettings = { ...settings, ...newSettings };
|
||||
setSettings(updatedSettings);
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(updatedSettings));
|
||||
};
|
||||
|
||||
const resetSettings = () => {
|
||||
setSettings(defaultSettings);
|
||||
localStorage.removeItem(SETTINGS_STORAGE_KEY);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, updateSettings, resetSettings }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSettings must be used within a SettingsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
586
src/contexts/ThemeContext.tsx
Normal file
586
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
export interface Theme {
|
||||
name: string;
|
||||
displayName: string;
|
||||
category: 'catppuccin' | 'neovim' | 'vscode' | 'emacs' | 'classic';
|
||||
colors: {
|
||||
base: string;
|
||||
mantle: string;
|
||||
crust: string;
|
||||
surface0: string;
|
||||
surface1: string;
|
||||
surface2: string;
|
||||
overlay0: string;
|
||||
overlay1: string;
|
||||
overlay2: string;
|
||||
subtext0: string;
|
||||
subtext1: string;
|
||||
text: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
pink: string;
|
||||
teal: string;
|
||||
lavender: string;
|
||||
rosewater: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const themes: Record<string, Theme> = {
|
||||
latte: {
|
||||
name: 'latte',
|
||||
displayName: 'Catppuccin Latte',
|
||||
category: 'catppuccin',
|
||||
colors: {
|
||||
base: '#eff1f5',
|
||||
mantle: '#e6e9ef',
|
||||
crust: '#dce0e8',
|
||||
surface0: '#ccd0da',
|
||||
surface1: '#bcc0cc',
|
||||
surface2: '#acb0be',
|
||||
overlay0: '#9ca0b0',
|
||||
overlay1: '#8c8fa1',
|
||||
overlay2: '#7c7f93',
|
||||
subtext0: '#6c6f85',
|
||||
subtext1: '#5c5f77',
|
||||
text: '#4c4f69',
|
||||
red: '#d20f39',
|
||||
green: '#40a02b',
|
||||
yellow: '#df8e1d',
|
||||
blue: '#1e66f5',
|
||||
pink: '#ea76cb',
|
||||
teal: '#179299',
|
||||
lavender: '#7287fd',
|
||||
rosewater: '#dc8a78',
|
||||
}
|
||||
},
|
||||
frappe: {
|
||||
name: 'frappe',
|
||||
displayName: 'Catppuccin Frappé',
|
||||
category: 'catppuccin',
|
||||
colors: {
|
||||
base: '#303446',
|
||||
mantle: '#292c3c',
|
||||
crust: '#232634',
|
||||
surface0: '#414559',
|
||||
surface1: '#51576d',
|
||||
surface2: '#626880',
|
||||
overlay0: '#737994',
|
||||
overlay1: '#838ba7',
|
||||
overlay2: '#949cbb',
|
||||
subtext0: '#a5adce',
|
||||
subtext1: '#b5bfe2',
|
||||
text: '#c6d0f5',
|
||||
red: '#e78284',
|
||||
green: '#a6d189',
|
||||
yellow: '#e5c890',
|
||||
blue: '#8caaee',
|
||||
pink: '#f4b8e4',
|
||||
teal: '#81c8be',
|
||||
lavender: '#babbf1',
|
||||
rosewater: '#f2d5cf',
|
||||
}
|
||||
},
|
||||
macchiato: {
|
||||
name: 'macchiato',
|
||||
displayName: 'Catppuccin Macchiato',
|
||||
category: 'catppuccin',
|
||||
colors: {
|
||||
base: '#24273a',
|
||||
mantle: '#1e2030',
|
||||
crust: '#181926',
|
||||
surface0: '#363a4f',
|
||||
surface1: '#494d64',
|
||||
surface2: '#5b6078',
|
||||
overlay0: '#6e738d',
|
||||
overlay1: '#8087a2',
|
||||
overlay2: '#939ab7',
|
||||
subtext0: '#a5adcb',
|
||||
subtext1: '#b8c0e0',
|
||||
text: '#cad3f5',
|
||||
red: '#ed8796',
|
||||
green: '#a6da95',
|
||||
yellow: '#eed49f',
|
||||
blue: '#8aadf4',
|
||||
pink: '#f5bde6',
|
||||
teal: '#8bd5ca',
|
||||
lavender: '#b7bdf8',
|
||||
rosewater: '#f4dbd6',
|
||||
}
|
||||
},
|
||||
mocha: {
|
||||
name: 'mocha',
|
||||
displayName: 'Catppuccin Mocha',
|
||||
category: 'catppuccin',
|
||||
colors: {
|
||||
base: '#1e1e2e',
|
||||
mantle: '#181825',
|
||||
crust: '#11111b',
|
||||
surface0: '#313244',
|
||||
surface1: '#45475a',
|
||||
surface2: '#585b70',
|
||||
overlay0: '#6c7086',
|
||||
overlay1: '#7f849c',
|
||||
overlay2: '#9399b2',
|
||||
subtext0: '#a6adc8',
|
||||
subtext1: '#bac2de',
|
||||
text: '#cdd6f4',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
pink: '#f5c2e7',
|
||||
teal: '#94e2d5',
|
||||
lavender: '#b4befe',
|
||||
rosewater: '#f5e0dc',
|
||||
}
|
||||
},
|
||||
// NeoVim Themes
|
||||
tokyonight: {
|
||||
name: 'tokyonight',
|
||||
displayName: 'Tokyo Night',
|
||||
category: 'neovim',
|
||||
colors: {
|
||||
base: '#1a1b26',
|
||||
mantle: '#16161e',
|
||||
crust: '#0d0e14',
|
||||
surface0: '#24283b',
|
||||
surface1: '#414868',
|
||||
surface2: '#565f89',
|
||||
overlay0: '#6b7089',
|
||||
overlay1: '#7982a9',
|
||||
overlay2: '#828bb8',
|
||||
subtext0: '#9699a3',
|
||||
subtext1: '#a9b1d6',
|
||||
text: '#c0caf5',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
pink: '#bb9af7',
|
||||
teal: '#73daca',
|
||||
lavender: '#b4befe',
|
||||
rosewater: '#f7768e',
|
||||
}
|
||||
},
|
||||
gruvbox: {
|
||||
name: 'gruvbox',
|
||||
displayName: 'Gruvbox Dark',
|
||||
category: 'neovim',
|
||||
colors: {
|
||||
base: '#282828',
|
||||
mantle: '#1d2021',
|
||||
crust: '#1d2021',
|
||||
surface0: '#3c3836',
|
||||
surface1: '#504945',
|
||||
surface2: '#665c54',
|
||||
overlay0: '#7c6f64',
|
||||
overlay1: '#928374',
|
||||
overlay2: '#a89984',
|
||||
subtext0: '#bdae93',
|
||||
subtext1: '#d5c4a1',
|
||||
text: '#ebdbb2',
|
||||
red: '#fb4934',
|
||||
green: '#b8bb26',
|
||||
yellow: '#fabd2f',
|
||||
blue: '#83a598',
|
||||
pink: '#d3869b',
|
||||
teal: '#8ec07c',
|
||||
lavender: '#b16286',
|
||||
rosewater: '#fe8019',
|
||||
}
|
||||
},
|
||||
nord: {
|
||||
name: 'nord',
|
||||
displayName: 'Nord',
|
||||
category: 'neovim',
|
||||
colors: {
|
||||
base: '#2e3440',
|
||||
mantle: '#242933',
|
||||
crust: '#1e222a',
|
||||
surface0: '#3b4252',
|
||||
surface1: '#434c5e',
|
||||
surface2: '#4c566a',
|
||||
overlay0: '#5e81ac',
|
||||
overlay1: '#81a1c1',
|
||||
overlay2: '#88c0d0',
|
||||
subtext0: '#8fbcbb',
|
||||
subtext1: '#d8dee9',
|
||||
text: '#eceff4',
|
||||
red: '#bf616a',
|
||||
green: '#a3be8c',
|
||||
yellow: '#ebcb8b',
|
||||
blue: '#5e81ac',
|
||||
pink: '#b48ead',
|
||||
teal: '#88c0d0',
|
||||
lavender: '#b48ead',
|
||||
rosewater: '#d08770',
|
||||
}
|
||||
},
|
||||
onedark: {
|
||||
name: 'onedark',
|
||||
displayName: 'One Dark',
|
||||
category: 'neovim',
|
||||
colors: {
|
||||
base: '#282c34',
|
||||
mantle: '#21252b',
|
||||
crust: '#1c1f24',
|
||||
surface0: '#3e4451',
|
||||
surface1: '#5c6370',
|
||||
surface2: '#868ea0',
|
||||
overlay0: '#979eab',
|
||||
overlay1: '#aab2bf',
|
||||
overlay2: '#b6bdca',
|
||||
subtext0: '#abb2bf',
|
||||
subtext1: '#c8ccd4',
|
||||
text: '#e6efff',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
pink: '#c678dd',
|
||||
teal: '#56b6c2',
|
||||
lavender: '#c678dd',
|
||||
rosewater: '#d19a66',
|
||||
}
|
||||
},
|
||||
// VSCode Themes
|
||||
dracula: {
|
||||
name: 'dracula',
|
||||
displayName: 'Dracula',
|
||||
category: 'vscode',
|
||||
colors: {
|
||||
base: '#282a36',
|
||||
mantle: '#21222c',
|
||||
crust: '#191a21',
|
||||
surface0: '#44475a',
|
||||
surface1: '#6272a4',
|
||||
surface2: '#f8f8f2',
|
||||
overlay0: '#bd93f9',
|
||||
overlay1: '#ffb86c',
|
||||
overlay2: '#ff79c6',
|
||||
subtext0: '#f8f8f2',
|
||||
subtext1: '#f8f8f2',
|
||||
text: '#f8f8f2',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#8be9fd',
|
||||
pink: '#ff79c6',
|
||||
teal: '#8be9fd',
|
||||
lavender: '#bd93f9',
|
||||
rosewater: '#ffb86c',
|
||||
}
|
||||
},
|
||||
monokai: {
|
||||
name: 'monokai',
|
||||
displayName: 'Monokai',
|
||||
category: 'vscode',
|
||||
colors: {
|
||||
base: '#272822',
|
||||
mantle: '#1e1f1c',
|
||||
crust: '#161613',
|
||||
surface0: '#383830',
|
||||
surface1: '#49483e',
|
||||
surface2: '#75715e',
|
||||
overlay0: '#a59f85',
|
||||
overlay1: '#f8f8f2',
|
||||
overlay2: '#f8f8f2',
|
||||
subtext0: '#f8f8f2',
|
||||
subtext1: '#f8f8f2',
|
||||
text: '#f8f8f2',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#e6db74',
|
||||
blue: '#66d9ef',
|
||||
pink: '#ae81ff',
|
||||
teal: '#66d9ef',
|
||||
lavender: '#ae81ff',
|
||||
rosewater: '#fd971f',
|
||||
}
|
||||
},
|
||||
solarized_dark: {
|
||||
name: 'solarized_dark',
|
||||
displayName: 'Solarized Dark',
|
||||
category: 'classic',
|
||||
colors: {
|
||||
base: '#002b36',
|
||||
mantle: '#073642',
|
||||
crust: '#002833',
|
||||
surface0: '#073642',
|
||||
surface1: '#586e75',
|
||||
surface2: '#657b83',
|
||||
overlay0: '#839496',
|
||||
overlay1: '#93a1a1',
|
||||
overlay2: '#eee8d5',
|
||||
subtext0: '#93a1a1',
|
||||
subtext1: '#eee8d5',
|
||||
text: '#fdf6e3',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
pink: '#d33682',
|
||||
teal: '#2aa198',
|
||||
lavender: '#6c71c4',
|
||||
rosewater: '#cb4b16',
|
||||
}
|
||||
},
|
||||
material: {
|
||||
name: 'material',
|
||||
displayName: 'Material Theme',
|
||||
category: 'vscode',
|
||||
colors: {
|
||||
base: '#263238',
|
||||
mantle: '#1e272c',
|
||||
crust: '#1a2327',
|
||||
surface0: '#37474f',
|
||||
surface1: '#455a64',
|
||||
surface2: '#546e7a',
|
||||
overlay0: '#607d8b',
|
||||
overlay1: '#90a4ae',
|
||||
overlay2: '#b0bec5',
|
||||
subtext0: '#cfd8dc',
|
||||
subtext1: '#eceff1',
|
||||
text: '#ffffff',
|
||||
red: '#f07178',
|
||||
green: '#c3e88d',
|
||||
yellow: '#ffcb6b',
|
||||
blue: '#82b1ff',
|
||||
pink: '#c792ea',
|
||||
teal: '#89ddff',
|
||||
lavender: '#bb80b3',
|
||||
rosewater: '#f78c6c',
|
||||
}
|
||||
},
|
||||
github_dark: {
|
||||
name: 'github_dark',
|
||||
displayName: 'GitHub Dark',
|
||||
category: 'vscode',
|
||||
colors: {
|
||||
base: '#0d1117',
|
||||
mantle: '#161b22',
|
||||
crust: '#010409',
|
||||
surface0: '#21262d',
|
||||
surface1: '#30363d',
|
||||
surface2: '#484f58',
|
||||
overlay0: '#6e7681',
|
||||
overlay1: '#8b949e',
|
||||
overlay2: '#b1bac4',
|
||||
subtext0: '#c9d1d9',
|
||||
subtext1: '#f0f6fc',
|
||||
text: '#f0f6fc',
|
||||
red: '#f85149',
|
||||
green: '#7ee787',
|
||||
yellow: '#f2cc60',
|
||||
blue: '#58a6ff',
|
||||
pink: '#bc8cff',
|
||||
teal: '#39c5cf',
|
||||
lavender: '#a5a5f5',
|
||||
rosewater: '#ffa198',
|
||||
}
|
||||
},
|
||||
// Emacs Themes
|
||||
doom_one: {
|
||||
name: 'doom_one',
|
||||
displayName: 'Doom One',
|
||||
category: 'emacs',
|
||||
colors: {
|
||||
base: '#282c34',
|
||||
mantle: '#1c1f24',
|
||||
crust: '#1b1d23',
|
||||
surface0: '#23272e',
|
||||
surface1: '#3f444a',
|
||||
surface2: '#5b6268',
|
||||
overlay0: '#73797e',
|
||||
overlay1: '#9ca0a4',
|
||||
overlay2: '#b1b1b1',
|
||||
subtext0: '#bbc2cf',
|
||||
subtext1: '#dfdfdf',
|
||||
text: '#bbc2cf',
|
||||
red: '#ff6c6b',
|
||||
green: '#98be65',
|
||||
yellow: '#ecbe7b',
|
||||
blue: '#51afef',
|
||||
pink: '#c678dd',
|
||||
teal: '#4db5bd',
|
||||
lavender: '#a9a1e1',
|
||||
rosewater: '#da8548',
|
||||
}
|
||||
},
|
||||
spacemacs: {
|
||||
name: 'spacemacs',
|
||||
displayName: 'Spacemacs Dark',
|
||||
category: 'emacs',
|
||||
colors: {
|
||||
base: '#292b2e',
|
||||
mantle: '#212026',
|
||||
crust: '#1c1e26',
|
||||
surface0: '#34323e',
|
||||
surface1: '#444155',
|
||||
surface2: '#5d4e75',
|
||||
overlay0: '#827591',
|
||||
overlay1: '#a7a7a7',
|
||||
overlay2: '#b2b2b2',
|
||||
subtext0: '#b2b2b2',
|
||||
subtext1: '#e3dedd',
|
||||
text: '#b2b2b2',
|
||||
red: '#f2241f',
|
||||
green: '#67b11d',
|
||||
yellow: '#b1951d',
|
||||
blue: '#4f97d7',
|
||||
pink: '#a31db1',
|
||||
teal: '#2d9574',
|
||||
lavender: '#bc6ec5',
|
||||
rosewater: '#dc752f',
|
||||
}
|
||||
},
|
||||
zenburn: {
|
||||
name: 'zenburn',
|
||||
displayName: 'Zenburn',
|
||||
category: 'emacs',
|
||||
colors: {
|
||||
base: '#3f3f3f',
|
||||
mantle: '#2b2b2b',
|
||||
crust: '#1e1e1e',
|
||||
surface0: '#4f4f4f',
|
||||
surface1: '#5f5f5f',
|
||||
surface2: '#6f6f6f',
|
||||
overlay0: '#7f7f7f',
|
||||
overlay1: '#8f8f8f',
|
||||
overlay2: '#9f9f9f',
|
||||
subtext0: '#9fc59f',
|
||||
subtext1: '#dcdccc',
|
||||
text: '#dcdccc',
|
||||
red: '#cc9393',
|
||||
green: '#7f9f7f',
|
||||
yellow: '#f0dfaf',
|
||||
blue: '#8cd0d3',
|
||||
pink: '#dc8cc3',
|
||||
teal: '#93e0e3',
|
||||
lavender: '#dfaf8f',
|
||||
rosewater: '#dfaf8f',
|
||||
}
|
||||
},
|
||||
// Additional Popular Themes
|
||||
ayu_dark: {
|
||||
name: 'ayu_dark',
|
||||
displayName: 'Ayu Dark',
|
||||
category: 'neovim',
|
||||
colors: {
|
||||
base: '#0a0e14',
|
||||
mantle: '#01060e',
|
||||
crust: '#000000',
|
||||
surface0: '#15191f',
|
||||
surface1: '#1f2328',
|
||||
surface2: '#2d3640',
|
||||
overlay0: '#424955',
|
||||
overlay1: '#565b66',
|
||||
overlay2: '#6c7380',
|
||||
subtext0: '#8a9199',
|
||||
subtext1: '#b3b1ad',
|
||||
text: '#e6e1cf',
|
||||
red: '#f07178',
|
||||
green: '#bae67e',
|
||||
yellow: '#ffd580',
|
||||
blue: '#73d0ff',
|
||||
pink: '#d4bfff',
|
||||
teal: '#95e6cb',
|
||||
lavender: '#dfbfff',
|
||||
rosewater: '#ff8f40',
|
||||
}
|
||||
},
|
||||
palenight: {
|
||||
name: 'palenight',
|
||||
displayName: 'Palenight',
|
||||
category: 'vscode',
|
||||
colors: {
|
||||
base: '#292d3e',
|
||||
mantle: '#1e2030',
|
||||
crust: '#1a1e2e',
|
||||
surface0: '#32374d',
|
||||
surface1: '#444267',
|
||||
surface2: '#676e95',
|
||||
overlay0: '#8796b0',
|
||||
overlay1: '#959dcb',
|
||||
overlay2: '#c3cee3',
|
||||
subtext0: '#a6accd',
|
||||
subtext1: '#c3cee3',
|
||||
text: '#eeffff',
|
||||
red: '#f07178',
|
||||
green: '#c3e88d',
|
||||
yellow: '#ffcb6b',
|
||||
blue: '#82b1ff',
|
||||
pink: '#c792ea',
|
||||
teal: '#89ddff',
|
||||
lavender: '#b2ccd6',
|
||||
rosewater: '#f78c6c',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface ThemeContextType {
|
||||
currentTheme: Theme;
|
||||
setTheme: (themeName: string) => void;
|
||||
availableThemes: Theme[];
|
||||
themesByCategory: Record<string, Theme[]>;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<Theme>(themes.latte);
|
||||
|
||||
useEffect(() => {
|
||||
// Load theme from localStorage
|
||||
const savedTheme = localStorage.getItem('github-actions-radar-theme');
|
||||
if (savedTheme && themes[savedTheme]) {
|
||||
setCurrentTheme(themes[savedTheme]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Apply CSS custom properties
|
||||
const root = document.documentElement;
|
||||
Object.entries(currentTheme.colors).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--color-${key}`, value);
|
||||
});
|
||||
}, [currentTheme]);
|
||||
|
||||
const setTheme = (themeName: string) => {
|
||||
if (themes[themeName]) {
|
||||
setCurrentTheme(themes[themeName]);
|
||||
localStorage.setItem('github-actions-radar-theme', themeName);
|
||||
}
|
||||
};
|
||||
|
||||
const availableThemes = Object.values(themes);
|
||||
|
||||
const themesByCategory = availableThemes.reduce((acc, theme) => {
|
||||
if (!acc[theme.category]) {
|
||||
acc[theme.category] = [];
|
||||
}
|
||||
acc[theme.category].push(theme);
|
||||
return acc;
|
||||
}, {} as Record<string, Theme[]>);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ currentTheme, setTheme, availableThemes, themesByCategory }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
21
src/index.css
Normal file
21
src/index.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
78
src/services/api.ts
Normal file
78
src/services/api.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import axios from 'axios';
|
||||
import { WorkflowRun } from '../types/github';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export class ApiService {
|
||||
async getWorkflowRuns(): Promise<WorkflowRun[]> {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/workflow-runs`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflow runs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/config`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getHealth() {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/health`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching health:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getRepositoryWorkflowRuns(owner: string, repo: string, limit = 10): Promise<WorkflowRun[]> {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/repository/${owner}/${repo}/workflow-runs`, {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching workflow runs for ${owner}/${repo}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getRateLimitInfo() {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/rate-limit`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching rate limit info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCacheStats() {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/cache/stats`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching cache stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache() {
|
||||
try {
|
||||
const response = await axios.delete(`${API_BASE}/cache`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/services/github.ts
Normal file
87
src/services/github.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import axios from 'axios';
|
||||
import { WorkflowRun, Repository } from '../types/github';
|
||||
|
||||
const GITHUB_API_BASE = 'https://api.github.com';
|
||||
|
||||
export class GitHubService {
|
||||
private token: string;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
return {
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
};
|
||||
}
|
||||
|
||||
async getWorkflowRuns(repository: Repository, per_page = 30): Promise<WorkflowRun[]> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${GITHUB_API_BASE}/repos/${repository.owner}/${repository.name}/actions/runs`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
per_page,
|
||||
page: 1,
|
||||
branch: 'main'
|
||||
}
|
||||
}
|
||||
);
|
||||
return response.data.workflow_runs;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching workflow runs for ${repository.owner}/${repository.name}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestMainBranchRuns(repositories: Repository[]): Promise<WorkflowRun[]> {
|
||||
const promises = repositories.map(async repo => {
|
||||
const runs = await this.getWorkflowRuns(repo, 1);
|
||||
return runs.length > 0 ? runs[0] : null;
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter((run): run is WorkflowRun => run !== null);
|
||||
}
|
||||
|
||||
async getRepositoryWorkflows(repository: Repository) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${GITHUB_API_BASE}/repos/${repository.owner}/${repository.name}/actions/workflows`,
|
||||
{
|
||||
headers: this.getHeaders()
|
||||
}
|
||||
);
|
||||
return response.data.workflows;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching workflows for ${repository.owner}/${repository.name}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getOrganizationRepositories(org: string, per_page = 100) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${GITHUB_API_BASE}/orgs/${org}/repos`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
params: {
|
||||
per_page,
|
||||
type: 'all',
|
||||
sort: 'updated'
|
||||
}
|
||||
}
|
||||
);
|
||||
return response.data.map((repo: any) => ({
|
||||
owner: repo.owner.login,
|
||||
name: repo.name
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching repositories for organization ${org}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/services/notifications.ts
Normal file
156
src/services/notifications.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { WorkflowRun } from '../types/github';
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
private hasPermission = false;
|
||||
|
||||
private constructor() {
|
||||
this.checkPermission();
|
||||
}
|
||||
|
||||
static getInstance(): NotificationService {
|
||||
if (!NotificationService.instance) {
|
||||
NotificationService.instance = new NotificationService();
|
||||
}
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
private checkPermission(): void {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('This browser does not support desktop notification');
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasPermission = Notification.permission === 'granted';
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('This browser does not support desktop notification');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
this.hasPermission = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'denied') {
|
||||
const permission = await Notification.requestPermission();
|
||||
this.hasPermission = permission === 'granted';
|
||||
return this.hasPermission;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
canShowNotifications(): boolean {
|
||||
return this.hasPermission && 'Notification' in window;
|
||||
}
|
||||
|
||||
showFailureNotification(failedRuns: WorkflowRun[]): void {
|
||||
if (!this.canShowNotifications()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = failedRuns.length;
|
||||
const title = count === 1
|
||||
? 'Build Failed'
|
||||
: `${count} Builds Failed`;
|
||||
|
||||
const body = count === 1
|
||||
? `${failedRuns[0].repository.full_name} - ${failedRuns[0].display_title}`
|
||||
: `Multiple builds have failed across ${new Set(failedRuns.map(r => r.repository.full_name)).size} repositories`;
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
tag: 'build-failure',
|
||||
renotify: true,
|
||||
requireInteraction: false,
|
||||
silent: false
|
||||
});
|
||||
|
||||
// Auto-close after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 5000);
|
||||
|
||||
// Optional: Click to focus window
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
|
||||
showSuccessNotification(successRuns: WorkflowRun[]): void {
|
||||
if (!this.canShowNotifications()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = successRuns.length;
|
||||
const title = count === 1
|
||||
? 'Build Recovered'
|
||||
: `${count} Builds Recovered`;
|
||||
|
||||
const body = count === 1
|
||||
? `${successRuns[0].repository.full_name} - ${successRuns[0].display_title}`
|
||||
: `Multiple builds have recovered across ${new Set(successRuns.map(r => r.repository.full_name)).size} repositories`;
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
tag: 'build-success',
|
||||
renotify: true,
|
||||
requireInteraction: false,
|
||||
silent: false
|
||||
});
|
||||
|
||||
// Auto-close after 3 seconds
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 3000);
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
|
||||
showWaitingNotification(waitingRuns: WorkflowRun[]): void {
|
||||
if (!this.canShowNotifications()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = waitingRuns.length;
|
||||
const title = count === 1
|
||||
? 'Build Waiting'
|
||||
: `${count} Builds Waiting`;
|
||||
|
||||
const body = count === 1
|
||||
? `${waitingRuns[0].repository.full_name} - ${waitingRuns[0].display_title}`
|
||||
: `Multiple builds are now waiting across ${new Set(waitingRuns.map(r => r.repository.full_name)).size} repositories`;
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
tag: 'build-waiting',
|
||||
renotify: true,
|
||||
requireInteraction: false,
|
||||
silent: false
|
||||
});
|
||||
|
||||
// Auto-close after 4 seconds
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 4000);
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/types/github.ts
Normal file
47
src/types/github.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface WorkflowRun {
|
||||
id: number;
|
||||
name: string;
|
||||
display_title: string;
|
||||
status: 'queued' | 'in_progress' | 'completed';
|
||||
conclusion: 'success' | 'failure' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | null;
|
||||
workflow_id: number;
|
||||
head_branch: string;
|
||||
head_sha: string;
|
||||
run_number: number;
|
||||
event: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
repository: {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
owner: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
};
|
||||
head_commit: {
|
||||
id: string;
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
actor: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
owner: string;
|
||||
name: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface GitHubConfig {
|
||||
token: string;
|
||||
repositories: Repository[];
|
||||
}
|
||||
35
tailwind.config.js
Normal file
35
tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Dynamic theme colors using CSS custom properties
|
||||
'ctp-base': 'var(--color-base)',
|
||||
'ctp-mantle': 'var(--color-mantle)',
|
||||
'ctp-crust': 'var(--color-crust)',
|
||||
'ctp-surface0': 'var(--color-surface0)',
|
||||
'ctp-surface1': 'var(--color-surface1)',
|
||||
'ctp-surface2': 'var(--color-surface2)',
|
||||
'ctp-overlay0': 'var(--color-overlay0)',
|
||||
'ctp-overlay1': 'var(--color-overlay1)',
|
||||
'ctp-overlay2': 'var(--color-overlay2)',
|
||||
'ctp-subtext0': 'var(--color-subtext0)',
|
||||
'ctp-subtext1': 'var(--color-subtext1)',
|
||||
'ctp-text': 'var(--color-text)',
|
||||
'ctp-red': 'var(--color-red)',
|
||||
'ctp-green': 'var(--color-green)',
|
||||
'ctp-yellow': 'var(--color-yellow)',
|
||||
'ctp-blue': 'var(--color-blue)',
|
||||
'ctp-pink': 'var(--color-pink)',
|
||||
'ctp-teal': 'var(--color-teal)',
|
||||
'ctp-lavender': 'var(--color-lavender)',
|
||||
'ctp-rosewater': 'var(--color-rosewater)',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
87
test-cache.js
Normal file
87
test-cache.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// Simple test to demonstrate backend caching
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE = 'http://localhost:3001/api';
|
||||
|
||||
async function testCaching() {
|
||||
console.log('🧪 Testing backend caching behavior...\n');
|
||||
|
||||
console.log('📊 Getting initial rate limit:');
|
||||
try {
|
||||
const initialRateLimit = await axios.get(`${API_BASE}/rate-limit`);
|
||||
console.log(`🔄 Initial rate limit: ${initialRateLimit.data.remaining}/${initialRateLimit.data.limit} remaining\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Initial rate limit error: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 First request - should hit GitHub API:');
|
||||
const start1 = Date.now();
|
||||
try {
|
||||
const response1 = await axios.get(`${API_BASE}/workflow-runs`);
|
||||
console.log(`✅ Response 1: ${response1.data.length} workflow runs (${Date.now() - start1}ms)`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Error 1: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('📊 Rate limit after first request:');
|
||||
try {
|
||||
const rateLimit1 = await axios.get(`${API_BASE}/rate-limit`);
|
||||
console.log(`🔄 Rate limit: ${rateLimit1.data.remaining}/${rateLimit1.data.limit} remaining\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Rate limit error: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Second request - should hit cache:');
|
||||
const start2 = Date.now();
|
||||
try {
|
||||
const response2 = await axios.get(`${API_BASE}/workflow-runs`);
|
||||
console.log(`✅ Response 2: ${response2.data.length} workflow runs (${Date.now() - start2}ms)`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Error 2: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('📊 Rate limit after second request:');
|
||||
try {
|
||||
const rateLimit2 = await axios.get(`${API_BASE}/rate-limit`);
|
||||
console.log(`🔄 Rate limit: ${rateLimit2.data.remaining}/${rateLimit2.data.limit} remaining\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Rate limit error: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Third request - should also hit cache:');
|
||||
const start3 = Date.now();
|
||||
try {
|
||||
const response3 = await axios.get(`${API_BASE}/workflow-runs`);
|
||||
console.log(`✅ Response 3: ${response3.data.length} workflow runs (${Date.now() - start3}ms)`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Error 3: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('📊 Final rate limit:');
|
||||
try {
|
||||
const finalRateLimit = await axios.get(`${API_BASE}/rate-limit`);
|
||||
console.log(`🔄 Final rate limit: ${finalRateLimit.data.remaining}/${finalRateLimit.data.limit} remaining\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Final rate limit error: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Getting cache stats:');
|
||||
try {
|
||||
const cacheStats = await axios.get(`${API_BASE}/cache/stats`);
|
||||
console.log(`💾 Cache size: ${cacheStats.data.size} entries`);
|
||||
console.log(`💾 Cache keys: ${cacheStats.data.entries.slice(0, 3).join(', ')}${cacheStats.data.entries.length > 3 ? '...' : ''}\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Cache stats error: ${error.message}\n`);
|
||||
}
|
||||
|
||||
console.log('📊 Getting rate limit info:');
|
||||
try {
|
||||
const rateLimitInfo = await axios.get(`${API_BASE}/rate-limit`);
|
||||
console.log(`🔄 Rate limit: ${rateLimitInfo.data.remaining}/${rateLimitInfo.data.limit} remaining`);
|
||||
console.log(`🔄 Reset in: ${Math.round(rateLimitInfo.data.timeUntilReset / 1000)}s`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Rate limit error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
testCaching().catch(console.error);
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
allowedHosts: ['radar.roo.lol'],
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001'
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user