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