Initial commit

This commit is contained in:
2025-07-10 21:59:56 -04:00
commit 4dec00d283
35 changed files with 10272 additions and 0 deletions

7
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View 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
View File

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

17
src/App.tsx Normal file
View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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;
};

View 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
View 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
View 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
View 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
View 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 [];
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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'
}
},
})