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:
Claude
2025-12-06 09:53:32 +00:00
parent 1456995462
commit 45b15c7fd5
18 changed files with 3818 additions and 0 deletions
+44
View File
@@ -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)
]);
+245
View File
@@ -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']);
+107
View File
@@ -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']);
+71
View File
@@ -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
]);
}
+633
View File
@@ -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);
}
}
+181
View File
@@ -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')">&times;</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')">&times;</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')">&times;</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>
+516
View File
@@ -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');
});
}
});
+131
View File
@@ -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
]);
}