Add GitHub Sync - Automated repository synchronization tool
Complete implementation of automated GitHub repository synchronization: - Webhook-based auto-sync from GitHub - Multi-repository support with branch selection - Web dashboard for management - Manual sync and rollback functionality - Comprehensive logging and monitoring Located in /gitpusher/ subdirectory as standalone application.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* Log API
|
||||
* Retrieves log entries
|
||||
*/
|
||||
|
||||
require_once '../../src/ConfigManager.php';
|
||||
require_once '../../src/Logger.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$configManager = new ConfigManager();
|
||||
$logger = new Logger($configManager);
|
||||
|
||||
// Get query parameters
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
|
||||
$offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0;
|
||||
$repoId = $_GET['repo_id'] ?? null;
|
||||
$type = $_GET['type'] ?? null;
|
||||
|
||||
// Get logs based on filters
|
||||
if ($repoId) {
|
||||
$logs = $logger->getByRepository($repoId, $limit);
|
||||
} elseif ($type) {
|
||||
$logs = $logger->getByType($type, $limit);
|
||||
} else {
|
||||
$logs = $logger->getAll($limit, $offset);
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
$stats = $logger->getStats();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'logs' => $logs,
|
||||
'stats' => $stats,
|
||||
'count' => count($logs)
|
||||
]);
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
/**
|
||||
* Repository Management API
|
||||
* Handles CRUD operations for repositories
|
||||
*/
|
||||
|
||||
require_once '../../src/ConfigManager.php';
|
||||
require_once '../../src/Logger.php';
|
||||
require_once '../../src/GitHandler.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$configManager = new ConfigManager();
|
||||
$logger = new Logger($configManager);
|
||||
$gitHandler = new GitHandler($logger, $configManager);
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// GET - List all repositories or get single repository
|
||||
if ($method === 'GET') {
|
||||
if (isset($_GET['id'])) {
|
||||
$repo = $configManager->getRepository($_GET['id']);
|
||||
|
||||
if (!$repo) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Repository not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add current git status if path exists
|
||||
if (file_exists($repo['target_path'])) {
|
||||
$status = $gitHandler->getStatus($repo['target_path']);
|
||||
$repo['git_status'] = $status;
|
||||
$repo['current_branch'] = $gitHandler->getCurrentBranch($repo['target_path']);
|
||||
$repo['current_commit'] = $gitHandler->getCurrentCommit($repo['target_path']);
|
||||
}
|
||||
|
||||
echo json_encode($repo);
|
||||
} else {
|
||||
$repos = $configManager->getRepositories();
|
||||
|
||||
// Add status for each repo
|
||||
foreach ($repos as &$repo) {
|
||||
if (file_exists($repo['target_path'])) {
|
||||
$repo['exists'] = true;
|
||||
$repo['current_branch'] = $gitHandler->getCurrentBranch($repo['target_path']);
|
||||
} else {
|
||||
$repo['exists'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['repositories' => $repos]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST - Add new repository
|
||||
if ($method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Validate required fields
|
||||
$required = ['name', 'repo_url', 'target_path', 'branch'];
|
||||
foreach ($required as $field) {
|
||||
if (empty($input[$field])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => "Field '$field' is required"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate target path
|
||||
$targetPath = rtrim($input['target_path'], '/');
|
||||
|
||||
// Check if target path already exists
|
||||
if (file_exists($targetPath)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Target path already exists']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate repository URL format
|
||||
if (!filter_var($input['repo_url'], FILTER_VALIDATE_URL)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid repository URL']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Generate webhook secret
|
||||
$webhookSecret = $configManager->generateWebhookSecret();
|
||||
|
||||
// Prepare repository data
|
||||
$repoData = [
|
||||
'name' => $input['name'],
|
||||
'repo_url' => $input['repo_url'],
|
||||
'target_path' => $targetPath,
|
||||
'branch' => $input['branch'],
|
||||
'auto_sync' => $input['auto_sync'] ?? true,
|
||||
'status' => 'cloning'
|
||||
];
|
||||
|
||||
// Add repository to config
|
||||
$repoId = $configManager->addRepository($repoData);
|
||||
|
||||
if (!$repoId) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to add repository']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Save webhook secret
|
||||
$configManager->setWebhookSecret($repoId, $webhookSecret);
|
||||
|
||||
// Clone repository
|
||||
$result = $gitHandler->cloneRepository(
|
||||
$repoId,
|
||||
$input['repo_url'],
|
||||
$targetPath,
|
||||
$input['branch']
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
$configManager->updateRepository($repoId, [
|
||||
'status' => 'synced',
|
||||
'last_sync' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$repo = $configManager->getRepository($repoId);
|
||||
$repo['webhook_secret'] = $webhookSecret;
|
||||
$repo['webhook_url'] = (isset($_SERVER['HTTPS']) ? 'https' : 'http') .
|
||||
'://' . $_SERVER['HTTP_HOST'] .
|
||||
dirname(dirname($_SERVER['REQUEST_URI'])) . '/webhook.php';
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'repository' => $repo
|
||||
]);
|
||||
} else {
|
||||
$configManager->updateRepository($repoId, ['status' => 'error']);
|
||||
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['message'],
|
||||
'details' => $result['error'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
// PUT - Update repository
|
||||
if ($method === 'PUT') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (empty($input['id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository ID is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$repo = $configManager->getRepository($input['id']);
|
||||
|
||||
if (!$repo) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Repository not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Prepare updates (only allow certain fields to be updated)
|
||||
$allowedFields = ['name', 'branch', 'auto_sync'];
|
||||
$updates = [];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (isset($input[$field])) {
|
||||
$updates[$field] = $input[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'No valid fields to update']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$success = $configManager->updateRepository($input['id'], $updates);
|
||||
|
||||
if ($success) {
|
||||
$repo = $configManager->getRepository($input['id']);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'repository' => $repo
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to update repository']);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
// DELETE - Delete repository
|
||||
if ($method === 'DELETE') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (empty($input['id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository ID is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$repo = $configManager->getRepository($input['id']);
|
||||
|
||||
if (!$repo) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Repository not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delete repository from config
|
||||
$success = $configManager->deleteRepository($input['id']);
|
||||
|
||||
if ($success) {
|
||||
$logger->info($input['id'], "Repository removed from configuration");
|
||||
|
||||
// Optionally delete files if requested
|
||||
if (!empty($input['delete_files']) && file_exists($repo['target_path'])) {
|
||||
exec('rm -rf ' . escapeshellarg($repo['target_path']));
|
||||
$logger->info($input['id'], "Repository files deleted from disk");
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Repository deleted'
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Failed to delete repository']);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
// Method not allowed
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* Rollback API
|
||||
* Reverts repository to a specific commit
|
||||
*/
|
||||
|
||||
require_once '../../src/ConfigManager.php';
|
||||
require_once '../../src/Logger.php';
|
||||
require_once '../../src/GitHandler.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$configManager = new ConfigManager();
|
||||
$logger = new Logger($configManager);
|
||||
$gitHandler = new GitHandler($logger, $configManager);
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// GET - Get commit history
|
||||
if ($method === 'GET') {
|
||||
if (empty($_GET['repo_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository ID is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$repo = $configManager->getRepository($_GET['repo_id']);
|
||||
|
||||
if (!$repo) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Repository not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!file_exists($repo['target_path'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository path does not exist']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 20;
|
||||
$commits = $gitHandler->getCommitHistory($repo['target_path'], $limit);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'commits' => $commits
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST - Perform rollback
|
||||
if ($method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (empty($input['repo_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository ID is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($input['commit_hash'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Commit hash is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$repo = $configManager->getRepository($input['repo_id']);
|
||||
|
||||
if (!$repo) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Repository not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!file_exists($repo['target_path'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository path does not exist']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Perform revert
|
||||
$result = $gitHandler->revert(
|
||||
$repo['id'],
|
||||
$repo['target_path'],
|
||||
$input['commit_hash']
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => $result['message'],
|
||||
'output' => $result['output']
|
||||
]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $result['message'],
|
||||
'error' => $result['error'] ?? null
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Method not allowed
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
/**
|
||||
* Manual Sync API
|
||||
* Triggers manual sync for a repository
|
||||
*/
|
||||
|
||||
require_once '../../src/ConfigManager.php';
|
||||
require_once '../../src/Logger.php';
|
||||
require_once '../../src/GitHandler.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (empty($input['repo_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository ID is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$configManager = new ConfigManager();
|
||||
$logger = new Logger($configManager);
|
||||
$gitHandler = new GitHandler($logger, $configManager);
|
||||
|
||||
$repo = $configManager->getRepository($input['repo_id']);
|
||||
|
||||
if (!$repo) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Repository not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if repository path exists
|
||||
if (!file_exists($repo['target_path'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository path does not exist. Please clone first.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Perform sync
|
||||
$logger->info($repo['id'], "Manual sync triggered");
|
||||
|
||||
$result = $gitHandler->pull(
|
||||
$repo['id'],
|
||||
$repo['target_path'],
|
||||
$repo['branch']
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => $result['message'],
|
||||
'files_changed' => $result['files_changed'] ?? 0,
|
||||
'output' => $result['output']
|
||||
]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $result['message'],
|
||||
'conflict' => $result['conflict'] ?? false,
|
||||
'error' => $result['error'] ?? null,
|
||||
'output' => $result['output'] ?? null
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #0366d6;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
--error-color: #dc3545;
|
||||
--info-color: #17a2b8;
|
||||
--bg-color: #f6f8fa;
|
||||
--card-bg: #ffffff;
|
||||
--text-primary: #24292e;
|
||||
--text-secondary: #586069;
|
||||
--border-color: #e1e4e8;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
--shadow-hover: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(135deg, var(--primary-color), #0550ae);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5em;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
background: var(--card-bg);
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.5em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0550ae;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--text-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #444d56;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning-color);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Repository List */
|
||||
.repos-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.repo-card {
|
||||
background: var(--bg-color);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.repo-card:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.repo-card.status-synced {
|
||||
border-left-color: var(--success-color);
|
||||
}
|
||||
|
||||
.repo-card.status-error {
|
||||
border-left-color: var(--error-color);
|
||||
}
|
||||
|
||||
.repo-card.status-conflict {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.repo-card.status-cloning {
|
||||
border-left-color: var(--info-color);
|
||||
}
|
||||
|
||||
.repo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.repo-url {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.repo-status {
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.repo-status.synced {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.repo-status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.repo-status.conflict {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.repo-status.cloning {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.repo-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.repo-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Logs List */
|
||||
.logs-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 12px;
|
||||
border-left: 3px solid var(--info-color);
|
||||
background: var(--bg-color);
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 15px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.log-entry.success {
|
||||
border-left-color: var(--success-color);
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
border-left-color: var(--error-color);
|
||||
}
|
||||
|
||||
.log-entry.warning {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
border-left-color: var(--info-color);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.log-details {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2em;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="url"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 25px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.input-with-copy {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-with-copy input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Commits List */
|
||||
.commits-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.commit-item {
|
||||
padding: 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.commit-item:hover {
|
||||
background: var(--bg-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.commit-hash {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.commit-meta {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.alert {
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.alert-info ol {
|
||||
margin: 10px 0 0 20px;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
#toastContainer {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left: 4px solid var(--success-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid var(--error-color);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left: 4px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-left: 4px solid var(--info-color);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading::before {
|
||||
content: "⏳ ";
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state::before {
|
||||
content: "📁";
|
||||
font-size: 4em;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.repo-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.repo-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.repo-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GitHub Sync - Dashboard</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔄 GitHub Sync Dashboard</h1>
|
||||
<p class="subtitle">Automatische Repository-Synchronisation</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="totalRepos">0</div>
|
||||
<div class="stat-label">Repositories</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="syncedRepos">0</div>
|
||||
<div class="stat-label">Synchronisiert</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="errorRepos">0</div>
|
||||
<div class="stat-label">Fehler</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="totalLogs">0</div>
|
||||
<div class="stat-label">Log-Einträge (24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Repositories</h2>
|
||||
<button class="btn btn-primary" onclick="showAddRepoModal()">+ Repository hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<div id="reposList" class="repos-list">
|
||||
<div class="loading">Lade Repositories...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Letzte Ereignisse</h2>
|
||||
<button class="btn btn-secondary" onclick="refreshLogs()">🔄 Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div id="logsList" class="logs-list">
|
||||
<div class="loading">Lade Logs...</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Add Repository -->
|
||||
<div id="addRepoModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Repository hinzufügen</h2>
|
||||
<button class="close-btn" onclick="closeModal('addRepoModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addRepoForm" onsubmit="addRepository(event)">
|
||||
<div class="form-group">
|
||||
<label for="repoName">Name</label>
|
||||
<input type="text" id="repoName" name="name" required placeholder="Mein Projekt">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="repoUrl">GitHub Repository URL</label>
|
||||
<input type="url" id="repoUrl" name="repo_url" required
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
onblur="fetchBranches()">
|
||||
<small>Die HTTPS Clone URL des Repositories</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="repoBranch">Branch</label>
|
||||
<select id="repoBranch" name="branch" required>
|
||||
<option value="main">main</option>
|
||||
<option value="master">master</option>
|
||||
</select>
|
||||
<small id="branchLoading" style="display:none;">Lade Branches...</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="targetPath">Ziel-Pfad auf Server</label>
|
||||
<input type="text" id="targetPath" name="target_path" required
|
||||
placeholder="/var/www/mein-projekt">
|
||||
<small>Absoluter Pfad, wo das Repository geklont werden soll</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="auto_sync" checked>
|
||||
Auto-Sync aktivieren (reagiert auf Webhooks)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('addRepoModal')">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Repository hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Rollback -->
|
||||
<div id="rollbackModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Rollback durchführen</h2>
|
||||
<button class="close-btn" onclick="closeModal('rollbackModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Wähle einen Commit aus, zu dem du zurückkehren möchtest:</p>
|
||||
<div id="commitsList" class="commits-list">
|
||||
<div class="loading">Lade Commits...</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('rollbackModal')">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Webhook Info -->
|
||||
<div id="webhookModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Webhook-Konfiguration</h2>
|
||||
<button class="close-btn" onclick="closeModal('webhookModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Payload URL</label>
|
||||
<div class="input-with-copy">
|
||||
<input type="text" id="webhookUrl" readonly>
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard('webhookUrl')">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Secret</label>
|
||||
<div class="input-with-copy">
|
||||
<input type="text" id="webhookSecret" readonly>
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard('webhookSecret')">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Content type</label>
|
||||
<input type="text" value="application/json" readonly>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>Einrichtung:</strong>
|
||||
<ol>
|
||||
<li>Gehe zu deinem GitHub Repository</li>
|
||||
<li>Settings → Webhooks → Add webhook</li>
|
||||
<li>Füge die obige Payload URL ein</li>
|
||||
<li>Füge das Secret ein</li>
|
||||
<li>Wähle "application/json" als Content type</li>
|
||||
<li>Wähle "Just the push event"</li>
|
||||
<li>Klicke auf "Add webhook"</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div id="toastContainer"></div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* GitHub Sync Dashboard - Frontend JavaScript
|
||||
*/
|
||||
|
||||
// State
|
||||
let repositories = [];
|
||||
let logs = [];
|
||||
let stats = {};
|
||||
let currentRollbackRepoId = null;
|
||||
|
||||
// Initialize app when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadDashboard();
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(loadDashboard, 30000);
|
||||
});
|
||||
|
||||
/**
|
||||
* Load complete dashboard
|
||||
*/
|
||||
async function loadDashboard() {
|
||||
await Promise.all([
|
||||
loadRepositories(),
|
||||
loadLogs()
|
||||
]);
|
||||
updateStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load repositories
|
||||
*/
|
||||
async function loadRepositories() {
|
||||
try {
|
||||
const response = await fetch('api/repos.php');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.repositories) {
|
||||
repositories = data.repositories;
|
||||
renderRepositories();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading repositories:', error);
|
||||
showToast('Fehler beim Laden der Repositories', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render repositories list
|
||||
*/
|
||||
function renderRepositories() {
|
||||
const container = document.getElementById('reposList');
|
||||
|
||||
if (repositories.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">Keine Repositories konfiguriert. Füge dein erstes Repository hinzu!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = repositories.map(repo => `
|
||||
<div class="repo-card status-${repo.status || 'pending'}">
|
||||
<div class="repo-header">
|
||||
<div>
|
||||
<div class="repo-name">${escapeHtml(repo.name)}</div>
|
||||
<div class="repo-url">${escapeHtml(repo.repo_url)}</div>
|
||||
</div>
|
||||
<span class="repo-status ${repo.status || 'pending'}">${getStatusText(repo.status)}</span>
|
||||
</div>
|
||||
|
||||
<div class="repo-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Branch</span>
|
||||
<span class="info-value">${escapeHtml(repo.branch)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Ziel-Pfad</span>
|
||||
<span class="info-value">${escapeHtml(repo.target_path)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Letzte Sync</span>
|
||||
<span class="info-value">${repo.last_sync || 'Noch nie'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Auto-Sync</span>
|
||||
<span class="info-value">${repo.auto_sync ? '✅ Aktiv' : '❌ Inaktiv'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-actions">
|
||||
<button class="btn btn-success btn-sm" onclick="syncRepository('${repo.id}')">
|
||||
🔄 Manueller Sync
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showRollbackModal('${repo.id}')">
|
||||
⏪ Rollback
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showWebhookInfo('${repo.id}')">
|
||||
🔗 Webhook Info
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteRepository('${repo.id}', '${escapeHtml(repo.name)}')">
|
||||
🗑️ Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load logs
|
||||
*/
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const response = await fetch('api/log.php?limit=50');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
logs = data.logs;
|
||||
stats = data.stats;
|
||||
renderLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
showToast('Fehler beim Laden der Logs', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render logs list
|
||||
*/
|
||||
function renderLogs() {
|
||||
const container = document.getElementById('logsList');
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">Noch keine Log-Einträge vorhanden.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = logs.map(log => {
|
||||
const repo = repositories.find(r => r.id === log.repo_id);
|
||||
const repoName = repo ? repo.name : log.repo_id;
|
||||
|
||||
return `
|
||||
<div class="log-entry ${log.type}">
|
||||
<div class="log-timestamp">${log.timestamp}</div>
|
||||
<div class="log-content">
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(repoName)}</strong> - ${escapeHtml(log.message)}
|
||||
</div>
|
||||
${log.details && Object.keys(log.details).length > 0 ? `
|
||||
<div class="log-details">${formatLogDetails(log.details)}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update statistics
|
||||
*/
|
||||
function updateStats() {
|
||||
const totalRepos = repositories.length;
|
||||
const syncedRepos = repositories.filter(r => r.status === 'synced').length;
|
||||
const errorRepos = repositories.filter(r => r.status === 'error' || r.status === 'conflict').length;
|
||||
|
||||
document.getElementById('totalRepos').textContent = totalRepos;
|
||||
document.getElementById('syncedRepos').textContent = syncedRepos;
|
||||
document.getElementById('errorRepos').textContent = errorRepos;
|
||||
document.getElementById('totalLogs').textContent = stats.last_24h || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add repository modal
|
||||
*/
|
||||
function showAddRepoModal() {
|
||||
document.getElementById('addRepoModal').classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add repository
|
||||
*/
|
||||
async function addRepository(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
repo_url: formData.get('repo_url'),
|
||||
branch: formData.get('branch'),
|
||||
target_path: formData.get('target_path'),
|
||||
auto_sync: formData.get('auto_sync') === 'on'
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('api/repos.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Repository erfolgreich hinzugefügt!', 'success');
|
||||
closeModal('addRepoModal');
|
||||
form.reset();
|
||||
|
||||
// Show webhook info
|
||||
if (result.repository.webhook_secret) {
|
||||
showWebhookInfoData(result.repository.webhook_url, result.repository.webhook_secret);
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
} else {
|
||||
showToast(result.error || 'Fehler beim Hinzufügen des Repositories', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding repository:', error);
|
||||
showToast('Fehler beim Hinzufügen des Repositories', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync repository manually
|
||||
*/
|
||||
async function syncRepository(repoId) {
|
||||
const repo = repositories.find(r => r.id === repoId);
|
||||
|
||||
if (!repo) return;
|
||||
|
||||
showToast(`Synchronisiere ${repo.name}...`, 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch('api/sync.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ repo_id: repoId })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(`${repo.name} erfolgreich synchronisiert! ${result.files_changed} Datei(en) geändert.`, 'success');
|
||||
loadDashboard();
|
||||
} else {
|
||||
if (result.conflict) {
|
||||
showToast(`Merge-Konflikt in ${repo.name}! Manuelle Lösung erforderlich.`, 'warning');
|
||||
} else {
|
||||
showToast(result.message || 'Fehler beim Synchronisieren', 'error');
|
||||
}
|
||||
loadDashboard();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing repository:', error);
|
||||
showToast('Fehler beim Synchronisieren', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show rollback modal
|
||||
*/
|
||||
async function showRollbackModal(repoId) {
|
||||
currentRollbackRepoId = repoId;
|
||||
const modal = document.getElementById('rollbackModal');
|
||||
const commitsList = document.getElementById('commitsList');
|
||||
|
||||
modal.classList.add('active');
|
||||
commitsList.innerHTML = '<div class="loading">Lade Commits...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`api/rollback.php?repo_id=${repoId}&limit=20`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.commits.length > 0) {
|
||||
commitsList.innerHTML = result.commits.map(commit => `
|
||||
<div class="commit-item" onclick="performRollback('${commit.hash}')">
|
||||
<div class="commit-hash">${commit.hash_short}</div>
|
||||
<div class="commit-message">${escapeHtml(commit.message)}</div>
|
||||
<div class="commit-meta">
|
||||
${escapeHtml(commit.author_name)} - ${commit.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
commitsList.innerHTML = '<div class="empty-state">Keine Commits gefunden.</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading commits:', error);
|
||||
commitsList.innerHTML = '<div class="empty-state">Fehler beim Laden der Commits.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform rollback
|
||||
*/
|
||||
async function performRollback(commitHash) {
|
||||
if (!currentRollbackRepoId) return;
|
||||
|
||||
const repo = repositories.find(r => r.id === currentRollbackRepoId);
|
||||
|
||||
if (!confirm(`Möchtest du wirklich einen Rollback zu Commit ${commitHash.substring(0, 7)} durchführen?\n\nDies erstellt einen neuen Revert-Commit.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(`Führe Rollback durch...`, 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch('api/rollback.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repo_id: currentRollbackRepoId,
|
||||
commit_hash: commitHash
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Rollback erfolgreich durchgeführt!', 'success');
|
||||
closeModal('rollbackModal');
|
||||
loadDashboard();
|
||||
} else {
|
||||
showToast(result.message || 'Fehler beim Rollback', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error performing rollback:', error);
|
||||
showToast('Fehler beim Rollback', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show webhook info modal
|
||||
*/
|
||||
async function showWebhookInfo(repoId) {
|
||||
const repo = repositories.find(r => r.id === repoId);
|
||||
|
||||
if (!repo) return;
|
||||
|
||||
// Fetch webhook secret from config
|
||||
try {
|
||||
const response = await fetch(`api/repos.php?id=${repoId}`);
|
||||
const data = await response.json();
|
||||
|
||||
const webhookUrl = window.location.origin + window.location.pathname.replace('index.php', '') + 'webhook.php';
|
||||
const webhookSecret = '(Secret gespeichert auf Server)';
|
||||
|
||||
showWebhookInfoData(webhookUrl, webhookSecret);
|
||||
} catch (error) {
|
||||
console.error('Error loading webhook info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show webhook info with data
|
||||
*/
|
||||
function showWebhookInfoData(url, secret) {
|
||||
document.getElementById('webhookUrl').value = url;
|
||||
document.getElementById('webhookSecret').value = secret;
|
||||
document.getElementById('webhookModal').classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete repository
|
||||
*/
|
||||
async function deleteRepository(repoId, repoName) {
|
||||
const deleteFiles = confirm(`Repository "${repoName}" aus Konfiguration entfernen?\n\nKlicke OK, um auch die Dateien vom Server zu löschen.\nKlicke Abbrechen, um nur die Konfiguration zu entfernen.`);
|
||||
|
||||
if (deleteFiles === null) return; // User cancelled
|
||||
|
||||
try {
|
||||
const response = await fetch('api/repos.php', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: repoId,
|
||||
delete_files: deleteFiles
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(`Repository "${repoName}" wurde entfernt.`, 'success');
|
||||
loadDashboard();
|
||||
} else {
|
||||
showToast(result.error || 'Fehler beim Löschen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting repository:', error);
|
||||
showToast('Fehler beim Löschen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch branches from GitHub
|
||||
*/
|
||||
async function fetchBranches() {
|
||||
const repoUrl = document.getElementById('repoUrl').value;
|
||||
|
||||
if (!repoUrl) return;
|
||||
|
||||
const branchSelect = document.getElementById('repoBranch');
|
||||
const branchLoading = document.getElementById('branchLoading');
|
||||
|
||||
branchLoading.style.display = 'block';
|
||||
|
||||
try {
|
||||
// This would need to be implemented in the backend
|
||||
// For now, keep default branches
|
||||
branchLoading.style.display = 'none';
|
||||
} catch (error) {
|
||||
console.error('Error fetching branches:', error);
|
||||
branchLoading.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh logs
|
||||
*/
|
||||
function refreshLogs() {
|
||||
loadLogs();
|
||||
showToast('Logs aktualisiert', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
function copyToClipboard(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
element.select();
|
||||
document.execCommand('copy');
|
||||
showToast('In Zwischenablage kopiert!', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Escape HTML
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get status text
|
||||
*/
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
'synced': '✅ Synchronisiert',
|
||||
'cloning': '⏳ Wird geklont...',
|
||||
'error': '❌ Fehler',
|
||||
'conflict': '⚠️ Konflikt',
|
||||
'pending': '⏸️ Ausstehend'
|
||||
};
|
||||
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format log details
|
||||
*/
|
||||
function formatLogDetails(details) {
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target.classList.contains('modal')) {
|
||||
event.target.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal with Escape key
|
||||
window.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
document.querySelectorAll('.modal.active').forEach(modal => {
|
||||
modal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* GitHub Webhook Endpoint
|
||||
* Receives push events from GitHub and triggers sync
|
||||
*/
|
||||
|
||||
require_once '../src/ConfigManager.php';
|
||||
require_once '../src/Logger.php';
|
||||
require_once '../src/GitHandler.php';
|
||||
|
||||
// Set JSON response header
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Only allow POST requests
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get payload
|
||||
$payload = file_get_contents('php://input');
|
||||
$data = json_decode($payload, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid JSON payload']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Initialize classes
|
||||
$configManager = new ConfigManager();
|
||||
$logger = new Logger($configManager);
|
||||
$gitHandler = new GitHandler($logger, $configManager);
|
||||
|
||||
// Get repository URL from payload
|
||||
$repoUrl = $data['repository']['clone_url'] ?? null;
|
||||
|
||||
if (!$repoUrl) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Repository URL not found in payload']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Find matching repository in config
|
||||
$repos = $configManager->getRepositories();
|
||||
$matchedRepo = null;
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
if ($repo['repo_url'] === $repoUrl) {
|
||||
$matchedRepo = $repo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$matchedRepo) {
|
||||
http_response_code(404);
|
||||
$logger->warning('webhook', "Webhook received for unknown repository: $repoUrl");
|
||||
echo json_encode(['error' => 'Repository not configured']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify webhook signature if secret is configured
|
||||
$webhookSecret = $configManager->getWebhookSecret($matchedRepo['id']);
|
||||
|
||||
if ($webhookSecret) {
|
||||
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
|
||||
|
||||
if (empty($signature)) {
|
||||
http_response_code(401);
|
||||
$logger->error($matchedRepo['id'], "Webhook signature missing");
|
||||
echo json_encode(['error' => 'Signature required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $webhookSecret);
|
||||
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
http_response_code(401);
|
||||
$logger->error($matchedRepo['id'], "Invalid webhook signature");
|
||||
echo json_encode(['error' => 'Invalid signature']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the push is for the configured branch
|
||||
$ref = $data['ref'] ?? '';
|
||||
$pushedBranch = str_replace('refs/heads/', '', $ref);
|
||||
|
||||
if ($pushedBranch !== $matchedRepo['branch']) {
|
||||
$logger->info($matchedRepo['id'], "Ignoring push to branch '$pushedBranch' (configured: '{$matchedRepo['branch']}')");
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Ignored - different branch',
|
||||
'pushed_branch' => $pushedBranch,
|
||||
'configured_branch' => $matchedRepo['branch']
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Log webhook received
|
||||
$commits = $data['commits'] ?? [];
|
||||
$logger->info($matchedRepo['id'], "Webhook received: " . count($commits) . " commits pushed", [
|
||||
'pusher' => $data['pusher']['name'] ?? 'unknown',
|
||||
'branch' => $pushedBranch
|
||||
]);
|
||||
|
||||
// Perform git pull
|
||||
$result = $gitHandler->pull(
|
||||
$matchedRepo['id'],
|
||||
$matchedRepo['target_path'],
|
||||
$matchedRepo['branch']
|
||||
);
|
||||
|
||||
// Return result
|
||||
if ($result['success']) {
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => $result['message'],
|
||||
'files_changed' => $result['files_changed'] ?? 0
|
||||
]);
|
||||
} else {
|
||||
// Still return 200 to GitHub, but log the error
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $result['message'],
|
||||
'conflict' => $result['conflict'] ?? false
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user