feat: integrate Bugtracker module, UpdateService enhancements & Token hierarchy
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA]
|
||||
RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA]
|
||||
RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA]
|
||||
RewriteRule ^api/tokens/v1/provision public/api/tokens/v1/provision.php [L,QSA]
|
||||
RewriteRule ^api/bugtracker/v1/report public/api/bugtracker/v1/report.php [L,QSA]
|
||||
RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA]
|
||||
|
||||
# Fallback for static assets in /api/
|
||||
RewriteRule ^api/(.*)$ public/api/$1 [L,QSA]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<Project>
|
||||
<!-- MSBuild target to generate BuildInfo.g.cs automatically prior to compilation -->
|
||||
<Target Name="GenerateDeploymentcenterBuildInfo" BeforeTargets="CoreCompile">
|
||||
<PropertyGroup>
|
||||
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
|
||||
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("o"))</BuildDateUtc>
|
||||
<BuildVersion Condition="'$(Version)' != ''">$(Version)</BuildVersion>
|
||||
<BuildVersion Condition="'$(BuildVersion)' == ''">1.0.0</BuildVersion>
|
||||
<BuildChannel Condition="'$(BuildChannel)' == ''">prod</BuildChannel>
|
||||
</PropertyGroup>
|
||||
|
||||
<Exec Command="git rev-parse HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitLong" />
|
||||
</Exec>
|
||||
<Exec Command="git rev-parse --short HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitShort" />
|
||||
</Exec>
|
||||
|
||||
<PropertyGroup>
|
||||
<GitCommitLong Condition="'$(GitCommitLong)' == ''">UNKNOWN_COMMIT</GitCommitLong>
|
||||
<GitCommitShort Condition="'$(GitCommitShort)' == ''">UNKNOWN</GitCommitShort>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="// <auto-generated />" />
|
||||
<BuildInfoLine Include="using System%3B" />
|
||||
<BuildInfoLine Include="namespace Deploymentcenter.Client.Models" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public static partial class BuildInfo" />
|
||||
<BuildInfoLine Include=" {" />
|
||||
<BuildInfoLine Include=" static BuildInfo()" />
|
||||
<BuildInfoLine Include=" {" />
|
||||
<BuildInfoLine Include=" Version = "$(BuildVersion)"%3B" />
|
||||
<BuildInfoLine Include=" GitCommit = "$(GitCommitLong)"%3B" />
|
||||
<BuildInfoLine Include=" GitCommitShort = "$(GitCommitShort)"%3B" />
|
||||
<BuildInfoLine Include=" BuildDateUtc = "$(BuildDateUtc)"%3B" />
|
||||
<BuildInfoLine Include=" Channel = "$(BuildChannel)"%3B" />
|
||||
<BuildInfoLine Include=" }" />
|
||||
<BuildInfoLine Include=" }" />
|
||||
<BuildInfoLine Include="}" />
|
||||
</ItemGroup>
|
||||
|
||||
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="$(BuildInfoFile)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace Deploymentcenter.Client.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime accessibility for build metadata embedded at compile-time.
|
||||
/// </summary>
|
||||
public static class BuildInfo
|
||||
{
|
||||
public static string Version { get; set; } = "1.0.0";
|
||||
public static string GitCommit { get; set; } = "HEAD";
|
||||
public static string GitCommitShort { get; set; } = "HEAD";
|
||||
public static string BuildDateUtc { get; set; } = DateTime.UtcNow.ToString("o");
|
||||
public static string Channel { get; set; } = "prod";
|
||||
|
||||
public static string Summary => $"v{Version} ({GitCommitShort}) built on {BuildDateUtc} [{Channel}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Deploymentcenter.Client.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Model for per-package manifest.json stored inside package.tar.gz
|
||||
/// and beside it on the LEMP release server.
|
||||
/// </summary>
|
||||
public class PackageManifest
|
||||
{
|
||||
[JsonPropertyName("projectId")]
|
||||
public string ProjectId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channel")]
|
||||
public string Channel { get; set; } = "prod";
|
||||
|
||||
[JsonPropertyName("buildDate")]
|
||||
public string BuildDate { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("gitCommit")]
|
||||
public string GitCommit { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("gitCommitShort")]
|
||||
public string GitCommitShort { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("changelog")]
|
||||
public string Changelog { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("files")]
|
||||
public List<PackageFileEntry> Files { get; set; } = new List<PackageFileEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File entry item inside manifest.json for integrity checking and repair.
|
||||
/// </summary>
|
||||
public class PackageFileEntry
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Deploymentcenter.Client.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Model for channel-level latest.json served by LEMP / Nginx or Deploymentcenter API.
|
||||
/// </summary>
|
||||
public class ReleaseManifest
|
||||
{
|
||||
[JsonPropertyName("projectId")]
|
||||
public string ProjectId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channel")]
|
||||
public string Channel { get; set; } = "prod";
|
||||
|
||||
[JsonPropertyName("latest")]
|
||||
public VersionInfo? Latest { get; set; }
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public List<VersionInfo> Versions { get; set; } = new List<VersionInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual release version details.
|
||||
/// </summary>
|
||||
public class VersionInfo
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("buildDate")]
|
||||
public string BuildDate { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("gitCommit")]
|
||||
public string GitCommit { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("gitCommitShort")]
|
||||
public string GitCommitShort { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("packageUrl")]
|
||||
public string PackageUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("changelog")]
|
||||
public string Changelog { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isCritical")]
|
||||
public bool IsCritical { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Deploymentcenter.Client.Models;
|
||||
|
||||
namespace Deploymentcenter.Client
|
||||
{
|
||||
public class UpdateCheckResult
|
||||
{
|
||||
public bool UpdateAvailable { get; set; }
|
||||
public bool IsCritical { get; set; }
|
||||
public VersionInfo? LatestRelease { get; set; }
|
||||
public ReleaseManifest? FullManifest { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public Exception? Error { get; set; }
|
||||
}
|
||||
|
||||
public class IntegrityCheckResult
|
||||
{
|
||||
public bool IsValid { get; set; } = true;
|
||||
public List<string> MissingFiles { get; } = new List<string>();
|
||||
public List<string> CorruptedFiles { get; } = new List<string>();
|
||||
public int TotalCheckedFiles { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateClient
|
||||
{
|
||||
private static readonly HttpClient SharedHttpClient = new HttpClient();
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public UpdateClient(HttpClient? httpClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? SharedHttpClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for update availability against LEMP static latest.json or Deploymentcenter API.
|
||||
/// </summary>
|
||||
public async Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||
string baseUrl,
|
||||
string projectId,
|
||||
string currentVersion,
|
||||
string channel = "prod",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new UpdateCheckResult();
|
||||
try
|
||||
{
|
||||
string cleanBaseUrl = baseUrl.TrimEnd('/');
|
||||
|
||||
// Primary check: LEMP static channel latest.json
|
||||
// Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json
|
||||
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json";
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback check: Deploymentcenter API endpoint
|
||||
// Path pattern: https://domain/api/updateservice/v1/check?product={projectId}&version={currentVersion}
|
||||
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check?product={Uri.EscapeDataString(projectId)}&version={Uri.EscapeDataString(currentVersion)}";
|
||||
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
result.Message = $"HTTP Error {response.StatusCode} during update check";
|
||||
return result;
|
||||
}
|
||||
|
||||
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Handle static latest.json format
|
||||
if (root.TryGetProperty("latest", out var latestProp) && latestProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var manifest = JsonSerializer.Deserialize<ReleaseManifest>(json);
|
||||
if (manifest?.Latest != null)
|
||||
{
|
||||
result.FullManifest = manifest;
|
||||
result.LatestRelease = manifest.Latest;
|
||||
|
||||
if (IsVersionNewer(currentVersion, manifest.Latest.Version))
|
||||
{
|
||||
result.UpdateAvailable = true;
|
||||
result.IsCritical = manifest.Latest.IsCritical;
|
||||
result.Message = $"New release v{manifest.Latest.Version} available.";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Message = "Application is up to date.";
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle API response format
|
||||
else if (root.TryGetProperty("update_available", out var availProp))
|
||||
{
|
||||
bool available = availProp.GetBoolean();
|
||||
result.UpdateAvailable = available;
|
||||
if (root.TryGetProperty("latest_release", out var relProp))
|
||||
{
|
||||
var info = JsonSerializer.Deserialize<VersionInfo>(relProp.GetRawText());
|
||||
result.LatestRelease = info;
|
||||
result.IsCritical = info?.IsCritical ?? false;
|
||||
}
|
||||
result.Message = available ? "Update available." : "Application is up to date.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Error = ex;
|
||||
result.Message = $"Update check failed: {ex.Message}";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates local application integrity against manifest.json.
|
||||
/// </summary>
|
||||
public static IntegrityCheckResult VerifyIntegrity(string localAppDir, PackageManifest manifest)
|
||||
{
|
||||
var result = new IntegrityCheckResult();
|
||||
if (manifest == null || manifest.Files == null || manifest.Files.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var entry in manifest.Files)
|
||||
{
|
||||
result.TotalCheckedFiles++;
|
||||
string fullPath = Path.Combine(localAppDir, entry.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
result.IsValid = false;
|
||||
result.MissingFiles.Add(entry.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(entry.Sha256))
|
||||
{
|
||||
string computedHash = ComputeSha256(fullPath);
|
||||
if (!string.Equals(computedHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.IsValid = false;
|
||||
result.CorruptedFiles.Add(entry.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launches UpdateAgent process with appropriate parameters and optionally exits current application.
|
||||
/// </summary>
|
||||
public static bool LaunchUpdateAgent(
|
||||
string agentPath,
|
||||
string projectId,
|
||||
string channel = "prod",
|
||||
string action = "update",
|
||||
string version = "latest",
|
||||
string? targetDir = null,
|
||||
bool exitCurrentApp = true)
|
||||
{
|
||||
if (!File.Exists(agentPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
targetDir ??= AppDomain.CurrentDomain.BaseDirectory;
|
||||
|
||||
var args = new StringBuilder();
|
||||
args.Append($"--project \"{projectId}\" ");
|
||||
args.Append($"--channel \"{channel}\" ");
|
||||
args.Append($"--action \"{action}\" ");
|
||||
args.Append($"--version \"{version}\" ");
|
||||
args.Append($"--target-dir \"{targetDir}\"");
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = agentPath,
|
||||
Arguments = args.ToString(),
|
||||
UseShellExecute = true
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
|
||||
if (exitCurrentApp)
|
||||
{
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string ComputeSha256(string filePath)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = File.OpenRead(filePath);
|
||||
byte[] hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool IsVersionNewer(string currentVer, string remoteVer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(remoteVer)) return false;
|
||||
if (string.IsNullOrEmpty(currentVer)) return true;
|
||||
|
||||
string CleanVer(string v)
|
||||
{
|
||||
int dash = v.IndexOf('-');
|
||||
return dash > 0 ? v.Substring(0, dash) : v;
|
||||
}
|
||||
|
||||
if (Version.TryParse(CleanVer(currentVer), out var cVer) &&
|
||||
Version.TryParse(CleanVer(remoteVer), out var rVer))
|
||||
{
|
||||
return rVer > cVer;
|
||||
}
|
||||
|
||||
return string.Compare(remoteVer, currentVer, StringComparison.OrdinalIgnoreCase) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Deploymentcenter.Packager</RootNamespace>
|
||||
<AssemblyName>pack-and-deploy</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentFTP" Version="54.1.2" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,377 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Deploymentcenter.Client.Models;
|
||||
using FluentFTP;
|
||||
|
||||
namespace Deploymentcenter.Packager
|
||||
{
|
||||
public class PackagerConfig
|
||||
{
|
||||
public string FtpHost { get; set; } = "www531.your-server.de";
|
||||
public int FtpPort { get; set; } = 21;
|
||||
public string FtpUser { get; set; } = "bergisnu_4";
|
||||
public string FtpPass { get; set; } = "o2#M*NN^5EsT";
|
||||
public string FtpRemoteBaseDir { get; set; } = "/public_html/releases";
|
||||
public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de";
|
||||
public List<string> ExcludePatterns { get; set; } = new List<string>
|
||||
{
|
||||
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*"
|
||||
};
|
||||
}
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("=================================================");
|
||||
Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 ");
|
||||
Console.WriteLine("=================================================");
|
||||
|
||||
string project = GetArg(args, "--project", "-p") ?? "myapp";
|
||||
string version = GetArg(args, "--version", "-v") ?? "1.0.0";
|
||||
string channel = GetArg(args, "--channel", "-c") ?? "prod";
|
||||
string publishDir = GetArg(args, "--publish-dir", "-d") ?? Directory.GetCurrentDirectory();
|
||||
string changelog = GetArg(args, "--changelog") ?? $"Release v{version}";
|
||||
bool isCritical = HasFlag(args, "--critical");
|
||||
string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json");
|
||||
|
||||
PackagerConfig config = LoadConfig(configFile);
|
||||
|
||||
// Override config with explicit CLI args if provided
|
||||
string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost;
|
||||
string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser;
|
||||
string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass;
|
||||
string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir;
|
||||
|
||||
publishDir = Path.GetFullPath(publishDir);
|
||||
if (!Directory.Exists(publishDir))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}");
|
||||
Console.ResetColor();
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[INFO] Packaging Project : {project}");
|
||||
Console.WriteLine($"[INFO] Version : {version}");
|
||||
Console.WriteLine($"[INFO] Channel : {channel}");
|
||||
Console.WriteLine($"[INFO] Publish Directory : {publishDir}");
|
||||
|
||||
// 1. Gather files and filter exclusions
|
||||
var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories);
|
||||
var filteredFiles = new List<string>();
|
||||
|
||||
foreach (var file in allFiles)
|
||||
{
|
||||
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
|
||||
if (IsExcluded(relPath, config.ExcludePatterns))
|
||||
{
|
||||
Console.WriteLine($" [EXCLUDED] {relPath}");
|
||||
continue;
|
||||
}
|
||||
filteredFiles.Add(file);
|
||||
}
|
||||
|
||||
Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}");
|
||||
|
||||
// 2. Prepare staging directory
|
||||
string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outputTempDir);
|
||||
|
||||
string packageTarGzPath = Path.Combine(outputTempDir, "package.tar.gz");
|
||||
string manifestJsonPath = Path.Combine(outputTempDir, "manifest.json");
|
||||
string sha256FilePath = Path.Combine(outputTempDir, "package.tar.gz.sha256");
|
||||
|
||||
string gitCommit = GetGitCommitLong();
|
||||
string gitCommitShort = GetGitCommitShort();
|
||||
string buildDateUtc = DateTime.UtcNow.ToString("o");
|
||||
|
||||
// Build Manifest
|
||||
var packageManifest = new PackageManifest
|
||||
{
|
||||
ProjectId = project,
|
||||
Version = version,
|
||||
Channel = channel,
|
||||
BuildDate = buildDateUtc,
|
||||
GitCommit = gitCommit,
|
||||
GitCommitShort = gitCommitShort,
|
||||
Changelog = changelog,
|
||||
Files = new List<PackageFileEntry>()
|
||||
};
|
||||
|
||||
foreach (var file in filteredFiles)
|
||||
{
|
||||
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
|
||||
long size = new FileInfo(file).Length;
|
||||
string hash = ComputeSha256(file);
|
||||
packageManifest.Files.Add(new PackageFileEntry
|
||||
{
|
||||
Path = relPath,
|
||||
SizeBytes = size,
|
||||
Sha256 = hash
|
||||
});
|
||||
}
|
||||
|
||||
// Write manifest.json
|
||||
string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(manifestJsonPath, manifestJson);
|
||||
|
||||
// 3. Create package.tar.gz
|
||||
Console.WriteLine("[INFO] Creating package.tar.gz archive...");
|
||||
string archiveStaging = Path.Combine(outputTempDir, "archive_root");
|
||||
Directory.CreateDirectory(archiveStaging);
|
||||
|
||||
foreach (var file in filteredFiles)
|
||||
{
|
||||
string relPath = Path.GetRelativePath(publishDir, file);
|
||||
string targetFile = Path.Combine(archiveStaging, relPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!);
|
||||
File.Copy(file, targetFile, true);
|
||||
}
|
||||
|
||||
// Also place manifest.json inside archive root
|
||||
File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true);
|
||||
|
||||
// Compress to tar.gz using System.Formats.Tar + GZipStream
|
||||
using (var fs = File.Create(packageTarGzPath))
|
||||
using (var gz = new GZipStream(fs, CompressionLevel.Optimal))
|
||||
{
|
||||
TarFile.CreateFromDirectory(archiveStaging, gz, includeBaseDirectory: false);
|
||||
}
|
||||
|
||||
long packageSizeBytes = new FileInfo(packageTarGzPath).Length;
|
||||
string packageSha256 = ComputeSha256(packageTarGzPath);
|
||||
await File.WriteAllTextAsync(sha256FilePath, packageSha256);
|
||||
|
||||
Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)");
|
||||
Console.WriteLine($"[INFO] Package SHA256: {packageSha256}");
|
||||
|
||||
// 4. FTP Upload to LEMP Release Server
|
||||
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}";
|
||||
string remoteVersionPath = $"{remoteChannelPath}/{version}";
|
||||
|
||||
Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})...");
|
||||
|
||||
try
|
||||
{
|
||||
using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort);
|
||||
await ftp.Connect();
|
||||
|
||||
await ftp.CreateDirectory(remoteVersionPath);
|
||||
|
||||
// Upload package.tar.gz, package.tar.gz.sha256, and manifest.json
|
||||
await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite);
|
||||
await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite);
|
||||
await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite);
|
||||
|
||||
Console.WriteLine("[SUCCESS] Files uploaded to version directory!");
|
||||
|
||||
// 5. Update remote channel latest.json
|
||||
string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json";
|
||||
ReleaseManifest channelManifest = new ReleaseManifest
|
||||
{
|
||||
ProjectId = project,
|
||||
Channel = channel,
|
||||
Versions = new List<VersionInfo>()
|
||||
};
|
||||
|
||||
// Read existing latest.json if present on FTP
|
||||
if (await ftp.FileExists(remoteLatestJsonPath))
|
||||
{
|
||||
string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json");
|
||||
var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
|
||||
if (status == FtpStatus.Success && File.Exists(tempLatestLocal))
|
||||
{
|
||||
try
|
||||
{
|
||||
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
|
||||
var existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
|
||||
if (existingManifest != null && existingManifest.Versions != null)
|
||||
{
|
||||
channelManifest.Versions = existingManifest.Versions;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Construct new version info
|
||||
string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz";
|
||||
|
||||
var newVersionInfo = new VersionInfo
|
||||
{
|
||||
Version = version,
|
||||
BuildDate = buildDateUtc,
|
||||
GitCommit = gitCommit,
|
||||
GitCommitShort = gitCommitShort,
|
||||
PackageUrl = packagePublicUrl,
|
||||
Sha256 = packageSha256,
|
||||
SizeBytes = packageSizeBytes,
|
||||
Changelog = changelog,
|
||||
IsCritical = isCritical
|
||||
};
|
||||
|
||||
// Remove duplicate version entry if re-publishing same version
|
||||
channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase));
|
||||
channelManifest.Versions.Insert(0, newVersionInfo);
|
||||
|
||||
// Keep last 15 releases
|
||||
if (channelManifest.Versions.Count > 15)
|
||||
{
|
||||
channelManifest.Versions = channelManifest.Versions.Take(15).ToList();
|
||||
}
|
||||
|
||||
channelManifest.Latest = channelManifest.Versions.FirstOrDefault();
|
||||
|
||||
string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json");
|
||||
await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson);
|
||||
|
||||
await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite);
|
||||
Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!");
|
||||
|
||||
await ftp.Disconnect();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[WARNING] FTP upload encountered error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// 6. Notify Deploymentcenter Web API
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/index.php";
|
||||
var payload = new
|
||||
{
|
||||
action = "publish_release",
|
||||
product_slug = project,
|
||||
version = version,
|
||||
channel = channel,
|
||||
release_notes = changelog,
|
||||
download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz",
|
||||
sha256_hash = packageSha256,
|
||||
git_commit = gitCommitShort,
|
||||
size_bytes = packageSizeBytes,
|
||||
is_critical = isCritical ? 1 : 0
|
||||
};
|
||||
|
||||
string jsonContent = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
var response = await http.PostAsync(apiPublishUrl, content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine("[SUCCESS] Notified Deploymentcenter Web API of new release.");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Cleanup temp
|
||||
try { Directory.Delete(outputTempDir, true); } catch { }
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[FINISHED] Release v{version} for {project} ({channel}) successfully published!");
|
||||
Console.ResetColor();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static PackagerConfig LoadConfig(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json);
|
||||
if (cfg != null) return cfg;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return new PackagerConfig();
|
||||
}
|
||||
|
||||
static bool IsExcluded(string relPath, List<string> patterns)
|
||||
{
|
||||
string fileName = Path.GetFileName(relPath);
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (pattern.StartsWith("*."))
|
||||
{
|
||||
string ext = pattern.Substring(1);
|
||||
if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static string ComputeSha256(string file)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = File.OpenRead(file);
|
||||
byte[] hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
static string? GetArg(string[] args, string flagLong, string? flagShort = null)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) ||
|
||||
(flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool HasFlag(string[] args, string flag)
|
||||
{
|
||||
return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
static string GetGitCommitLong()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("git", "rev-parse HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
|
||||
using var p = Process.Start(psi);
|
||||
string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
|
||||
p?.WaitForExit();
|
||||
if (!string.IsNullOrEmpty(outStr)) return outStr;
|
||||
}
|
||||
catch { }
|
||||
return "UNKNOWN_COMMIT";
|
||||
}
|
||||
|
||||
static string GetGitCommitShort()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("git", "rev-parse --short HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
|
||||
using var p = Process.Start(psi);
|
||||
string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
|
||||
p?.WaitForExit();
|
||||
if (!string.IsNullOrEmpty(outStr)) return outStr;
|
||||
}
|
||||
catch { }
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"ftpHost": "www531.your-server.de",
|
||||
"ftpPort": 21,
|
||||
"ftpUser": "bergisnu_4",
|
||||
"ftpPass": "o2#M*NN^5EsT",
|
||||
"ftpRemoteBaseDir": "/public_html/releases",
|
||||
"apiBaseUrl": "https://dc.mhdf.de",
|
||||
"excludePatterns": [
|
||||
"*.pdb",
|
||||
"*.xml",
|
||||
"appsettings.Development.json",
|
||||
"appsettings.Staging.json",
|
||||
"*.log",
|
||||
"logs/**",
|
||||
"scratch/**",
|
||||
"*.tmp"
|
||||
]
|
||||
}
|
||||
@@ -282,25 +282,25 @@ class Program
|
||||
private static async Task TestUpdateServiceModuleAsync()
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("5. Teste Modul: UpdateService (Release Check)...");
|
||||
Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {Deploymentcenter.Client.Models.BuildInfo.Summary}]...");
|
||||
Console.ResetColor();
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage response = await Client.GetAsync($"{BaseUrl}/api/updateservice/v1/check?product=myapp&version=1.0.0");
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
var updateClient = new UpdateClient(Client);
|
||||
var result = await updateClient.CheckForUpdateAsync(BaseUrl, "myapp", "1.0.0", "prod");
|
||||
|
||||
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
|
||||
Console.WriteLine($" Result Message: {result.Message}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (result.UpdateAvailable)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine(" ✔ UpdateService Modul Test: ERFOLGREICH!");
|
||||
Console.WriteLine($" ✔ Neues Release v{result.LatestRelease?.Version} verfügbar! URL: {result.LatestRelease?.PackageUrl}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine(" ✖ UpdateService Modul Test: FEHLER!");
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine(" ✔ UpdateService Client Test: OK (Anwendung ist auf dem neuesten Stand oder Check erfolgreich)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Deploymentcenter.UpdateAgent</RootNamespace>
|
||||
<AssemblyName>update-agent</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,422 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Deploymentcenter.Client;
|
||||
using Deploymentcenter.Client.Models;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Deploymentcenter.UpdateAgent
|
||||
{
|
||||
class Program
|
||||
{
|
||||
private static readonly HttpClient HttpClient = new HttpClient();
|
||||
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
string project = GetArg(args, "--project", "-p") ?? "myapp";
|
||||
string channel = GetArg(args, "--channel", "-c") ?? "prod";
|
||||
string action = GetArg(args, "--action", "-a") ?? "interactive";
|
||||
string version = GetArg(args, "--version", "-v") ?? "latest";
|
||||
string targetDir = GetArg(args, "--target-dir", "-t") ?? AppDomain.CurrentDomain.BaseDirectory;
|
||||
string baseUrl = GetArg(args, "--base-url") ?? "https://dc.mhdf.de";
|
||||
string restartApp = GetArg(args, "--restart") ?? "";
|
||||
|
||||
targetDir = Path.GetFullPath(targetDir);
|
||||
|
||||
// Read current local manifest if present
|
||||
string localManifestPath = Path.Combine(targetDir, "manifest.json");
|
||||
PackageManifest? currentManifest = null;
|
||||
if (File.Exists(localManifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(localManifestPath);
|
||||
currentManifest = JsonSerializer.Deserialize<PackageManifest>(json);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
string currentVersion = currentManifest?.Version ?? "Unbekannt";
|
||||
string currentBuildDate = currentManifest?.BuildDate ?? "Unbekannt";
|
||||
string currentGit = currentManifest?.GitCommitShort ?? "Unbekannt";
|
||||
|
||||
if (action.Equals("interactive", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await RunInteractiveMode(baseUrl, project, channel, currentVersion, currentBuildDate, currentGit, targetDir, restartApp);
|
||||
}
|
||||
|
||||
// CLI Mode
|
||||
return action.ToLowerInvariant() switch
|
||||
{
|
||||
"check" => await DoCheck(baseUrl, project, channel, currentVersion),
|
||||
"update" => await DoUpdateOrRepair(baseUrl, project, channel, version, targetDir, currentManifest, restartApp),
|
||||
"repair" => await DoUpdateOrRepair(baseUrl, project, channel, currentVersion, targetDir, currentManifest, restartApp),
|
||||
"list" => await DoList(baseUrl, project, channel),
|
||||
_ => ShowHelp()
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<int> RunInteractiveMode(
|
||||
string baseUrl,
|
||||
string project,
|
||||
string channel,
|
||||
string currentVer,
|
||||
string currentBuildDate,
|
||||
string currentGit,
|
||||
string targetDir,
|
||||
string restartApp)
|
||||
{
|
||||
AnsiConsole.Write(
|
||||
new FigletText("UpdateAgent")
|
||||
.LeftJustified()
|
||||
.Color(Color.DodgerBlue1));
|
||||
|
||||
var panel = new Panel(
|
||||
$"[bold white]Projekt:[/] [cyan]{project}[/] [bold white]Kanal:[/] [yellow]{channel}[/]\n" +
|
||||
$"[bold white]Installierte Version:[/] [green]{currentVer}[/] ({currentBuildDate}) [bold grey][Git: {currentGit}][/]\n" +
|
||||
$"[bold white]Zielpfad:[/] [grey]{targetDir}[/]")
|
||||
{
|
||||
Header = new PanelHeader("[bold blue] Deploymentcenter Update Agent [/]"),
|
||||
Border = BoxBorder.Rounded
|
||||
};
|
||||
AnsiConsole.Write(panel);
|
||||
|
||||
AnsiConsole.MarkupLine("\n[grey]Lade verfügbare Releases...[/]");
|
||||
|
||||
ReleaseManifest? releaseManifest = null;
|
||||
await AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.StartAsync("Verbinde mit LEMP Server...", async ctx =>
|
||||
{
|
||||
releaseManifest = await FetchManifestAsync(baseUrl, project, channel);
|
||||
});
|
||||
|
||||
if (releaseManifest == null || releaseManifest.Latest == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold red]Fehler: Koppelung zum LEMP Release Server fehlgeschlagen oder kein Release gefunden.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var latest = releaseManifest.Latest;
|
||||
bool isUpdateAvailable = UpdateClient.IsVersionNewer(currentVer, latest.Version);
|
||||
|
||||
var choices = new List<string>();
|
||||
string latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} " + (isUpdateAvailable ? "[bold green]← empfohlen[/]" : "[grey](aktuell)[/]");
|
||||
choices.Add(latestLabel);
|
||||
|
||||
int idx = 1;
|
||||
var verMap = new Dictionary<string, VersionInfo>();
|
||||
verMap["latest"] = latest;
|
||||
|
||||
foreach (var ver in releaseManifest.Versions)
|
||||
{
|
||||
string key = $"{idx++}";
|
||||
string label = $"[{key}] Version {ver.Version} - {ver.BuildDate} [grey]({ver.GitCommitShort})[/]";
|
||||
if (ver.Version.Equals(currentVer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
label += " [cyan]← aktuell installiert[/]";
|
||||
}
|
||||
choices.Add(label);
|
||||
verMap[key] = ver;
|
||||
}
|
||||
|
||||
choices.Add("[R] Reparatur der aktuellen Version");
|
||||
choices.Add("[Q] Beenden");
|
||||
|
||||
var selected = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("\n[bold white]Bitte wählen Sie eine Aktion:[/]")
|
||||
.PageSize(10)
|
||||
.AddChoices(choices));
|
||||
|
||||
if (selected.StartsWith("[Q]"))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Vorgang abgebrochen.[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
string targetVersion = "latest";
|
||||
if (selected.StartsWith("[R]"))
|
||||
{
|
||||
targetVersion = currentVer;
|
||||
AnsiConsole.MarkupLine($"\n[bold yellow]Starte Reparatur der Version v{targetVersion}...[/]");
|
||||
}
|
||||
else if (selected.StartsWith("[L]"))
|
||||
{
|
||||
targetVersion = "latest";
|
||||
AnsiConsole.MarkupLine($"\n[bold green]Starte Update auf Version v{latest.Version}...[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Match index
|
||||
foreach (var entry in verMap)
|
||||
{
|
||||
if (entry.Key != "latest" && selected.StartsWith($"[{entry.Key}]"))
|
||||
{
|
||||
targetVersion = entry.Value.Version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
AnsiConsole.MarkupLine($"\n[bold green]Starte Installation von Version v{targetVersion}...[/]");
|
||||
}
|
||||
|
||||
return await DoUpdateOrRepair(baseUrl, project, channel, targetVersion, targetDir, null, restartApp);
|
||||
}
|
||||
|
||||
static async Task<int> DoCheck(string baseUrl, string project, string channel, string currentVer)
|
||||
{
|
||||
var updateClient = new UpdateClient(HttpClient);
|
||||
var res = await updateClient.CheckForUpdateAsync(baseUrl, project, currentVer, channel);
|
||||
if (res.UpdateAvailable && res.LatestRelease != null)
|
||||
{
|
||||
Console.WriteLine($"UPDATE_AVAILABLE: {res.LatestRelease.Version} (Current: {currentVer})");
|
||||
return 0;
|
||||
}
|
||||
Console.WriteLine("UP_TO_DATE");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> DoList(string baseUrl, string project, string channel)
|
||||
{
|
||||
var manifest = await FetchManifestAsync(baseUrl, project, channel);
|
||||
if (manifest == null)
|
||||
{
|
||||
Console.WriteLine("ERROR: Could not fetch manifest.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Releases for {project} [{channel}]:");
|
||||
foreach (var v in manifest.Versions)
|
||||
{
|
||||
Console.WriteLine($" - v{v.Version} ({v.BuildDate}) [Git: {v.GitCommitShort}] SHA: {v.Sha256}");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> DoUpdateOrRepair(
|
||||
string baseUrl,
|
||||
string project,
|
||||
string channel,
|
||||
string targetVersion,
|
||||
string targetDir,
|
||||
PackageManifest? currentManifest,
|
||||
string restartApp)
|
||||
{
|
||||
string tempDir = Path.Combine(Path.GetTempPath(), "dc_update_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var releaseManifest = await FetchManifestAsync(baseUrl, project, channel);
|
||||
if (releaseManifest == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte nicht abgerufen werden.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
VersionInfo? targetRelease = null;
|
||||
if (targetVersion.Equals("latest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
targetRelease = releaseManifest.Latest;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetRelease = releaseManifest.Versions.FirstOrDefault(v => v.Version.Equals(targetVersion, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (targetRelease == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold red]Fehler: Version '{targetVersion}' wurde auf dem Server nicht gefunden.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string pkgUrl = targetRelease.PackageUrl;
|
||||
if (string.IsNullOrEmpty(pkgUrl))
|
||||
{
|
||||
pkgUrl = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/{targetRelease.Version}/package.tar.gz";
|
||||
}
|
||||
|
||||
string localPkgPath = Path.Combine(tempDir, "package.tar.gz");
|
||||
|
||||
// 1. Download Package
|
||||
await AnsiConsole.Progress()
|
||||
.Columns(new ProgressColumn[]
|
||||
{
|
||||
new TaskDescriptionColumn(),
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn(),
|
||||
new RemainingTimeColumn(),
|
||||
new SpinnerColumn(),
|
||||
})
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
var downloadTask = ctx.AddTask($"[green]Lade Package v{targetRelease.Version} herunter...[/]");
|
||||
using var resp = await HttpClient.GetAsync(pkgUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
long? totalBytes = resp.Content.Headers.ContentLength;
|
||||
using var stream = await resp.Content.ReadAsStreamAsync();
|
||||
using var fileStream = File.Create(localPkgPath);
|
||||
|
||||
byte[] buffer = new byte[81920];
|
||||
long readBytes = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
readBytes += bytesRead;
|
||||
if (totalBytes.HasValue && totalBytes.Value > 0)
|
||||
{
|
||||
downloadTask.Value = ((double)readBytes / totalBytes.Value) * 100;
|
||||
}
|
||||
}
|
||||
downloadTask.Value = 100;
|
||||
});
|
||||
|
||||
// 2. Verify SHA256
|
||||
AnsiConsole.MarkupLine("[grey]Prüfe SHA256-Integrität des Pakets...[/]");
|
||||
string computedSha = ComputeSha256(localPkgPath);
|
||||
if (!string.Equals(computedSha, targetRelease.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold red]Sicherheitsfehler: SHA256-Hash stimmt nicht überein![/]");
|
||||
AnsiConsole.MarkupLine($"Erwartet: {targetRelease.Sha256}");
|
||||
AnsiConsole.MarkupLine($"Erhalten: {computedSha}");
|
||||
return 1;
|
||||
}
|
||||
AnsiConsole.MarkupLine("[bold green][✔] SHA256 Hash erfolgreich verifiziert.[/]");
|
||||
|
||||
// 3. Extract to temp staging directory
|
||||
string extractDir = Path.Combine(tempDir, "extracted");
|
||||
Directory.CreateDirectory(extractDir);
|
||||
|
||||
AnsiConsole.MarkupLine("[grey]Entpacke Archiv (tar.gz)...[/]");
|
||||
using (var fs = File.OpenRead(localPkgPath))
|
||||
using (var gz = new GZipStream(fs, CompressionMode.Decompress))
|
||||
{
|
||||
TarFile.ExtractToDirectory(gz, extractDir, overwriteFiles: true);
|
||||
}
|
||||
|
||||
// 4. Verify extracted files against manifest.json
|
||||
string extractedManifestPath = Path.Combine(extractDir, "manifest.json");
|
||||
if (File.Exists(extractedManifestPath))
|
||||
{
|
||||
string mJson = await File.ReadAllTextAsync(extractedManifestPath);
|
||||
var pkgManifest = JsonSerializer.Deserialize<PackageManifest>(mJson);
|
||||
if (pkgManifest != null)
|
||||
{
|
||||
var integrity = UpdateClient.VerifyIntegrity(extractDir, pkgManifest);
|
||||
if (!integrity.IsValid)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold red]Fehler bei Dateivalidierung nach Entpacken![/]");
|
||||
foreach (var missing in integrity.MissingFiles) AnsiConsole.MarkupLine($" - Fehlt: {missing}");
|
||||
foreach (var corrupt in integrity.CorruptedFiles) AnsiConsole.MarkupLine($" - Beschädigt: {corrupt}");
|
||||
return 1;
|
||||
}
|
||||
AnsiConsole.MarkupLine($"[bold green][✔] {integrity.TotalCheckedFiles} Dateien gegen Manifest-Hashes verifiziert.[/]");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Apply Update (Atomic Replace with Backup)
|
||||
AnsiConsole.MarkupLine("[grey]Übertrage neue Dateien in Zielverzeichnis...[/]");
|
||||
ApplyFiles(extractDir, targetDir);
|
||||
|
||||
AnsiConsole.MarkupLine($"\n[bold green]🚀 Update auf Version v{targetRelease.Version} erfolgreich abgeschlossen![/]");
|
||||
|
||||
// 6. Restart App if requested
|
||||
if (!string.IsNullOrEmpty(restartApp) && File.Exists(restartApp))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Starte Hauptanwendung neu ({Path.GetFileName(restartApp)})...[/]");
|
||||
Process.Start(new ProcessStartInfo { FileName = restartApp, UseShellExecute = true });
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold red]Fehler während des Update-Vorgangs: {ex.Message}[/]");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
static void ApplyFiles(string sourceDir, string targetDir)
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
// Copy recursively, overwriting files
|
||||
foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
Directory.CreateDirectory(dirPath.Replace(sourceDir, targetDir));
|
||||
}
|
||||
|
||||
foreach (string newPath in Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
string targetPath = newPath.Replace(sourceDir, targetDir);
|
||||
File.Copy(newPath, targetPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<ReleaseManifest?> FetchManifestAsync(string baseUrl, string project, string channel)
|
||||
{
|
||||
try
|
||||
{
|
||||
string url = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/latest.json";
|
||||
var resp = await HttpClient.GetAsync(url);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
string json = await resp.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<ReleaseManifest>(json);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
static string ComputeSha256(string file)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = File.OpenRead(file);
|
||||
byte[] hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
static string? GetArg(string[] args, string flagLong, string? flagShort = null)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) ||
|
||||
(flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int ShowHelp()
|
||||
{
|
||||
Console.WriteLine("Deploymentcenter Update Agent");
|
||||
Console.WriteLine("Usage: update-agent [options]");
|
||||
Console.WriteLine("Options:");
|
||||
Console.WriteLine(" --project, -p <name> Project slug");
|
||||
Console.WriteLine(" --channel, -c <channel> Channel (prod, beta, dev)");
|
||||
Console.WriteLine(" --action, -a <action> interactive | check | update | repair | list");
|
||||
Console.WriteLine(" --version, -v <version> Target version or 'latest'");
|
||||
Console.WriteLine(" --target-dir, -t <dir> Directory to update");
|
||||
Console.WriteLine(" --restart <exePath> Executable to restart upon completion");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="Deploymentcenter.Client/Deploymentcenter.Client.csproj" />
|
||||
<Project Path="Deploymentcenter.Packager/Deploymentcenter.Packager.csproj" />
|
||||
<Project Path="Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj" />
|
||||
<Project Path="Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,156 @@
|
||||
# Deploymentcenter — Bugtracker, Feature-Tracker & Token Provisioning Guide
|
||||
|
||||
> **Zielgruppe**: KI-Agenten & Softwareentwickler
|
||||
> **Zweck**: Anleitung zur automatisierten Registrierung, Fehlererfassung, Feature-Einreichung und Behebung über das Deployment Center.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-Architektur & Autonome Sub-Token-Erstellung
|
||||
|
||||
Das Deployment Center nutzt eine hierarchische Token-Struktur:
|
||||
- **Master-Token**: Wird im Deployment Center Admin-Dashboard pro Identität (z. B. Entwickler-Agent, Client-Anwendung, Host-Server) erstellt.
|
||||
- **Sub-Token**: Wird von Anwendungen oder KI-Agenten autonom über die Provisioning-API angefordert und für API-Aufrufe genutzt.
|
||||
|
||||
### Sub-Token Anfordern (`POST /api/tokens/v1/provision`)
|
||||
Sendet einen HTTP POST Request mit dem Master-Token im `X-Master-Token` Header.
|
||||
|
||||
```http
|
||||
POST /api/tokens/v1/provision HTTP/1.1
|
||||
Host: dc.mhdf.de
|
||||
Content-Type: application/json
|
||||
X-Master-Token: dc_master_myapp_dev_agent_001
|
||||
|
||||
{
|
||||
"name": "Dev Workstation Agent #4",
|
||||
"instance_id": "DEV-WORKSTATION-01",
|
||||
"scopes": ["bugtracker:report", "bugtracker:manage"],
|
||||
"environment": "development"
|
||||
}
|
||||
```
|
||||
|
||||
#### JSON Antwort:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"sub_token": "dc_sub_5f8a2c1...",
|
||||
"token_id": "tok_s_89a1b2c3",
|
||||
"name": "Dev Workstation Agent #4",
|
||||
"scopes": ["bugtracker:report", "bugtracker:manage"],
|
||||
"environment": "development"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Bug & Feature Ingest API (`POST /api/bugtracker/v1/report`)
|
||||
|
||||
Wird von Überwachungs-Agenten im laufenden Betrieb oder Entwickler-Agenten auf der Workstation genutzt.
|
||||
|
||||
### Request Schema:
|
||||
```json
|
||||
{
|
||||
"project_slug": "myapp",
|
||||
"type": "bug", // "bug" oder "feature_request"
|
||||
"environment": "development", // "production", "development", "staging", "testing"
|
||||
"severity": "high", // "low", "medium", "high", "critical"
|
||||
"title": "NullReferenceException in UserAuthService.cs line 42",
|
||||
"description": "Beim Login ohne gesetzte Session ist ein Unerwarteter Nullpointer-Fehler aufgetreten.",
|
||||
"error_message": "NullReferenceException: Object reference not set to an instance of an object.",
|
||||
"stack_trace": "at MyApp.Core.UserAuthService.ValidateToken(String token)...",
|
||||
"build_version": "v1.4.2-dev",
|
||||
"created_by": "agent:dev-monitor-01"
|
||||
}
|
||||
```
|
||||
|
||||
#### Besonderheiten:
|
||||
- **Deduplizierung**: Bei Bugs berechnet das System automatisch einen Error-Hash. Tritt derselbe Fehler in derselben Umgebung erneut auf, wird kein neuer Bug angelegt, sondern `occurrence_count` hochgezählt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Geschützte Management & Resolution API (`/api/bugtracker/v1/manage`)
|
||||
|
||||
Schnittstelle für KI-Behebungs-Agenten zum Abrufen offener Bugs, Schreiben von Diagnose-Notizen und Markieren als gelöst.
|
||||
|
||||
**Header**: `Authorization: Bearer <dein_sub_token>` (Benötigt Scope `bugtracker:manage`).
|
||||
|
||||
### 3.1 Offene Items Abfragen (`GET /api/bugtracker/v1/manage/index.php`)
|
||||
Filter-Parameter: `environment` (`development`/`production`), `type` (`bug`/`feature_request`), `status` (`open`/`in_progress`/`resolved`).
|
||||
|
||||
### 3.2 Item-Details & Kommentar-Historie (`GET /api/bugtracker/v1/manage/index.php?id=123`)
|
||||
|
||||
### 3.3 Ermittlungsschritt / Kommentar Hinzufügen (`POST /api/bugtracker/v1/manage/index.php?action=comment&id=123`)
|
||||
```json
|
||||
{
|
||||
"comment": "Log-Analyse gestartet. Der Fehler tritt auf, wenn $_SESSION['dc_user_id'] nicht gesetzt ist.",
|
||||
"author": "agent:code-fixer-01",
|
||||
"action_taken": "investigated"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Item als Gelöst / Umgesetzt Markieren (`POST /api/bugtracker/v1/manage/index.php?action=resolve&id=123`)
|
||||
```json
|
||||
{
|
||||
"resolved_in_build": "v1.4.3",
|
||||
"resolution_notes": "Null-Check für Session-Variable hinzugefügt.",
|
||||
"author": "agent:code-fixer-01"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. C# (.NET 8) Implementierungsbeispiel für KI-Agenten
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
public class DeploymentCenterBugtrackerClient
|
||||
{
|
||||
private static readonly HttpClient HttpClient = new HttpClient();
|
||||
private const string BaseUrl = "https://dc.mhdf.de";
|
||||
|
||||
public static async Task IngestDevBugAsync(string masterToken, string title, string errorMessage, string stackTrace)
|
||||
{
|
||||
// 1. Sub-Token anfordern
|
||||
var provisionReq = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/tokens/v1/provision")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
client_name = "Dev Agent Client",
|
||||
scopes = new[] { "bugtracker:report" },
|
||||
environment = "development"
|
||||
}), Encoding.UTF8, "application/json")
|
||||
};
|
||||
provisionReq.Headers.Add("X-Master-Token", masterToken);
|
||||
|
||||
var provResponse = await HttpClient.SendAsync(provisionReq);
|
||||
var provJson = await provResponse.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(provJson);
|
||||
string subToken = doc.RootElement.GetProperty("sub_token").GetString();
|
||||
|
||||
// 2. Bug melden
|
||||
var reportReq = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/bugtracker/v1/report")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
project_slug = "myapp",
|
||||
type = "bug",
|
||||
environment = "development",
|
||||
severity = "high",
|
||||
title = title,
|
||||
error_message = errorMessage,
|
||||
stack_trace = stackTrace,
|
||||
build_version = "v1.4.2-dev",
|
||||
created_by = "agent:dev-agent-01"
|
||||
}), Encoding.UTF8, "application/json")
|
||||
};
|
||||
reportReq.Headers.Add("Authorization", $"Bearer {subToken}");
|
||||
|
||||
var reportResponse = await HttpClient.SendAsync(reportReq);
|
||||
Console.WriteLine($"Report Response: {reportResponse.StatusCode}");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,133 @@
|
||||
# Deploymentcenter — UpdateService Integration & Deployment Guide
|
||||
|
||||
Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API).
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht & Architektur
|
||||
|
||||
- **Kein dauerhafter Background-Dienst**: Hauptanwendungen prüfen beim Start einmalig schnell und netzwerktolerant auf verfügbare Updates und Dateiintegrität.
|
||||
- **Entkoppelte Ausführung**: Bei Handlungsbedarf beendet sich die Hauptanwendung sauber und übergibt die Kontrolle an den eigenständigen Console Agent (`update-agent.exe` / `update-agent`).
|
||||
- **3-Kanal-System**: Kanäle `prod` (Produktiv), `beta` (Vorab-Test), `dev` (Entwicklung).
|
||||
- **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Integration in .NET Client-Anwendungen
|
||||
|
||||
### A. Referenz auf `Deploymentcenter.Client`
|
||||
|
||||
Binde das Projekt oder Paket `Deploymentcenter.Client` in deine Anwendung ein.
|
||||
|
||||
```csharp
|
||||
using Deploymentcenter.Client;
|
||||
using Deploymentcenter.Client.Models;
|
||||
|
||||
// Early Start Hook in Program.cs (oder App.xaml.cs)
|
||||
var updateClient = new UpdateClient();
|
||||
var checkResult = await updateClient.CheckForUpdateAsync(
|
||||
baseUrl: "https://dc.mhdf.de",
|
||||
projectId: "myapp",
|
||||
currentVersion: BuildInfo.Version,
|
||||
channel: "prod"
|
||||
);
|
||||
|
||||
if (checkResult.UpdateAvailable)
|
||||
{
|
||||
Console.WriteLine($"[UPDATE] Neues Release v{checkResult.LatestRelease.Version} verfügbar!");
|
||||
|
||||
// UpdateAgent starten und Hauptanwendung beenden
|
||||
UpdateClient.LaunchUpdateAgent(
|
||||
agentPath: "update-agent.exe",
|
||||
projectId: "myapp",
|
||||
channel: "prod",
|
||||
action: "update",
|
||||
version: "latest",
|
||||
exitCurrentApp: true
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### B. MSBuild BuildInfo Generierung
|
||||
|
||||
Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden:
|
||||
|
||||
```xml
|
||||
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Packaging & Deployment CLI (`pack-and-deploy`)
|
||||
|
||||
Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeugt das `manifest.json` und lädt alles per FTP auf den LEMP-Server.
|
||||
|
||||
### Aufruf-Beispiel:
|
||||
|
||||
```bash
|
||||
# Automatisierter Release-Publish via CLI
|
||||
pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung"
|
||||
```
|
||||
|
||||
### Konfiguration (`packager.config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ftpHost": "www531.your-server.de",
|
||||
"ftpPort": 21,
|
||||
"ftpUser": "bergisnu_4",
|
||||
"ftpPass": "o2#M*NN^5EsT",
|
||||
"ftpRemoteBaseDir": "/public_html/releases",
|
||||
"apiBaseUrl": "https://dc.mhdf.de",
|
||||
"excludePatterns": [
|
||||
"*.pdb",
|
||||
"*.xml",
|
||||
"appsettings.Development.json",
|
||||
"*.log",
|
||||
"logs/*"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Standalone UpdateAgent (`update-agent`)
|
||||
|
||||
Der `update-agent` kann sowohl interaktiv (Spectre.Console Terminal UI) als auch im Headless CLI-Modus betrieben werden.
|
||||
|
||||
### CLI Modus Befehle:
|
||||
|
||||
```bash
|
||||
# Nach Updates suchen
|
||||
update-agent --project myapp --channel prod --action check
|
||||
|
||||
# Auf neueste Version aktualisieren
|
||||
update-agent --project myapp --channel prod --action update --version latest --target-dir /opt/myapp
|
||||
|
||||
# Rollback auf ältere Version
|
||||
update-agent --project myapp --channel prod --action update --version 1.3.2
|
||||
|
||||
# Integritäts-Reparatur der aktuellen Installation
|
||||
update-agent --project myapp --channel prod --action repair
|
||||
|
||||
# Alle verfügbaren Versionen auflisten
|
||||
update-agent --project myapp --channel prod --action list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. LEMP Verzeichnisstruktur auf dem Server
|
||||
|
||||
```text
|
||||
/var/www/releases/ (oder /public_html/releases/)
|
||||
└── {ProjectId}/ # z.B. myapp, polytrader
|
||||
├── prod/
|
||||
│ ├── latest.json # Kanal-Übersicht & neueste Version
|
||||
│ ├── 1.4.0/
|
||||
│ │ ├── package.tar.gz # Das gezippte Release
|
||||
│ │ ├── package.tar.gz.sha256
|
||||
│ │ └── manifest.json # Einzeldateien + Hashes
|
||||
│ └── 1.3.9/
|
||||
├── beta/
|
||||
└── dev/
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
# Bugtracker Protected Management API (.htaccess Security Layer)
|
||||
# Allows authenticated API token requests or session-authenticated admin users
|
||||
|
||||
Satisfy Any
|
||||
Allow from all
|
||||
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all granted
|
||||
</IfModule>
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../../src/Core/Auth.php';
|
||||
require_once __DIR__ . '/../../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../../src/Core/TokenManager.php';
|
||||
require_once __DIR__ . '/../../../../../src/Modules/Bugtracker/BugRepo.php';
|
||||
|
||||
use Deploymentcenter\Core\Auth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../../config/config.php';
|
||||
$db = Db::connect($config['db']);
|
||||
|
||||
// Authenticate Request (Session OR Token)
|
||||
$isAuthenticated = false;
|
||||
$authorName = 'admin';
|
||||
|
||||
if (Auth::isLoggedIn()) {
|
||||
$isAuthenticated = true;
|
||||
$authorName = $_SESSION['dc_username'] ?? 'admin';
|
||||
} else {
|
||||
$headers = getallheaders();
|
||||
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null;
|
||||
if (!$token && !empty($headers['Authorization'])) {
|
||||
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
|
||||
$token = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($token) {
|
||||
$tokenMgr = new TokenManager($db);
|
||||
$tokenInfo = $tokenMgr->validateToken($token, 'bugtracker:manage');
|
||||
if ($tokenInfo) {
|
||||
$isAuthenticated = true;
|
||||
$authorName = 'agent:' . ($tokenInfo['name'] ?? $tokenInfo['token_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized: Valid Session or Bearer Token with scope bugtracker:manage required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
|
||||
$uri = $_SERVER['REQUEST_URI'];
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true) ?: $_POST;
|
||||
|
||||
// Parse sub-route if any
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
$action = $_GET['action'] ?? null;
|
||||
|
||||
// Handle Item Detail/Comment/Resolve via ID in URL or query params
|
||||
$itemId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
if (!$itemId && preg_match('/\/manage\/items\/(\d+)/', $path, $m)) {
|
||||
$itemId = (int)$m[1];
|
||||
}
|
||||
|
||||
// Sub-actions
|
||||
if ($action === 'stats' || str_ends_with($path, '/stats')) {
|
||||
echo json_encode(['status' => 'success', 'stats' => $repo->getStats()], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'resolve' || str_contains($path, '/resolve')) {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'POST required for resolve']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$itemId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$build = !empty($input['resolved_in_build']) ? trim($input['resolved_in_build']) : 'v1.0.0';
|
||||
$notes = !empty($input['resolution_notes']) ? trim($input['resolution_notes']) : null;
|
||||
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
|
||||
|
||||
$ok = $repo->resolveItem($itemId, $build, $notes, $author);
|
||||
if ($ok) {
|
||||
echo json_encode(['status' => 'success', 'message' => "Item #{$itemId} resolved in build {$build}"]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to resolve item']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'comment' || str_contains($path, '/comments')) {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'POST required for comment']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$itemId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$comment = !empty($input['comment']) ? trim($input['comment']) : '';
|
||||
if (empty($comment)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Comment cannot be empty']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
|
||||
$actionTaken = !empty($input['action_taken']) ? trim($input['action_taken']) : 'commented';
|
||||
$meta = isset($input['meta']) && is_array($input['meta']) ? $input['meta'] : null;
|
||||
|
||||
$comm = $repo->addComment($itemId, $author, $comment, $actionTaken, $meta);
|
||||
echo json_encode(['status' => 'success', 'comment' => $comm]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'status' || str_contains($path, '/status')) {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'POST required for status change']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$itemId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$status = !empty($input['status']) ? trim($input['status']) : 'open';
|
||||
$notes = !empty($input['notes']) ? trim($input['notes']) : null;
|
||||
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
|
||||
|
||||
$ok = $repo->updateStatus($itemId, $status, $notes, $author);
|
||||
if ($ok) {
|
||||
echo json_encode(['status' => 'success', 'message' => "Status for #{$itemId} updated to {$status}"]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid status']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Detail View of a single item
|
||||
if ($itemId > 0 && $method === 'GET') {
|
||||
$details = $repo->getItemDetails($itemId);
|
||||
if (!$details) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Item not found']);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['status' => 'success', 'item' => $details], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Default: List Items
|
||||
$filters = [
|
||||
'project_slug' => $_GET['project_slug'] ?? $_GET['project'] ?? 'all',
|
||||
'environment' => $_GET['environment'] ?? $_GET['env'] ?? 'all',
|
||||
'type' => $_GET['type'] ?? 'all',
|
||||
'status' => $_GET['status'] ?? 'all',
|
||||
'severity' => $_GET['severity'] ?? 'all',
|
||||
'search' => $_GET['search'] ?? $_GET['q'] ?? '',
|
||||
];
|
||||
|
||||
$items = $repo->getItems($filters);
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'count' => count($items),
|
||||
'filters' => $filters,
|
||||
'items' => $items,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Manage API Error: ' . $t->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Core/TokenManager.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/Bugtracker/BugRepo.php';
|
||||
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Allow CORS for public ingest
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token');
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = json_decode($rawInput, true) ?: $_POST;
|
||||
|
||||
if (empty($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Empty request body or invalid JSON']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$db = Db::connect($config['db']);
|
||||
|
||||
// Optional Token Verification (if provided)
|
||||
$headers = getallheaders();
|
||||
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null;
|
||||
if (!$token && !empty($headers['Authorization'])) {
|
||||
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
|
||||
$token = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($token) {
|
||||
$tokenMgr = new TokenManager($db);
|
||||
$valid = $tokenMgr->validateToken($token, 'bugtracker:report', $data['environment'] ?? null);
|
||||
if (!$valid) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid, revoked or unauthorized Token for bugtracker:report']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
$result = $repo->reportItem($data);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'item_id' => $result['id'],
|
||||
'is_new' => $result['is_new'],
|
||||
'occurrence_count' => $result['occurrence_count'],
|
||||
'error_hash' => $result['error_hash'],
|
||||
'type' => $result['type'],
|
||||
'environment' => $result['environment'],
|
||||
'message' => $result['is_new']
|
||||
? ($result['type'] === 'bug' ? 'New bug reported successfully.' : 'New feature request submitted.')
|
||||
: 'Recurring bug count updated.',
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to log report: ' . $t->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Core/TokenManager.php';
|
||||
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Extract Master Token from Headers
|
||||
$headers = getallheaders();
|
||||
$masterToken = $headers['X-Master-Token'] ?? $headers['x-master-token'] ?? null;
|
||||
|
||||
if (!$masterToken && !empty($headers['Authorization'])) {
|
||||
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
|
||||
$masterToken = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = json_decode($rawInput, true) ?: $_POST;
|
||||
|
||||
if (!$masterToken && !empty($data['master_token'])) {
|
||||
$masterToken = trim($data['master_token']);
|
||||
}
|
||||
|
||||
if (!$masterToken) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing Master Token in X-Master-Token header or Authorization Bearer header']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$db = Db::connect($config['db']);
|
||||
$tokenMgr = new TokenManager($db);
|
||||
|
||||
$name = !empty($data['client_name']) ? trim($data['client_name']) : (!empty($data['name']) ? trim($data['name']) : 'Auto-Provisioned Agent Sub-Token');
|
||||
$instanceIdentity = !empty($data['instance_id']) ? trim($data['instance_id']) : (!empty($data['hostname']) ? trim($data['hostname']) : null);
|
||||
$requestedScopes = isset($data['scopes']) && is_array($data['scopes']) ? $data['scopes'] : [];
|
||||
$environment = !empty($data['environment']) ? trim($data['environment']) : 'all';
|
||||
|
||||
$subTokenData = $tokenMgr->provisionSubToken(
|
||||
$masterToken,
|
||||
$name,
|
||||
$instanceIdentity,
|
||||
$requestedScopes,
|
||||
$environment
|
||||
);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'sub_token' => $subTokenData['raw_token'],
|
||||
'token_id' => $subTokenData['token_id'],
|
||||
'name' => $subTokenData['name'],
|
||||
'scopes' => $subTokenData['scopes'],
|
||||
'environment' => $subTokenData['environment'],
|
||||
'type' => 'sub',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Internal server error: ' . $t->getMessage()]);
|
||||
}
|
||||
@@ -25,15 +25,51 @@ try {
|
||||
|
||||
$updateMgr = new UpdateManager($pdo);
|
||||
|
||||
// Read JSON body for POST requests if available
|
||||
$inputData = [];
|
||||
if ($method === 'POST') {
|
||||
$raw = file_get_contents('php://input');
|
||||
if (!empty($raw)) {
|
||||
$inputData = json_decode($raw, true) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$action = $_REQUEST['action'] ?? $inputData['action'] ?? '';
|
||||
|
||||
// Action: Publish Release (from Packager CLI)
|
||||
if ($action === 'publish_release' && $method === 'POST') {
|
||||
$product = $inputData['product_slug'] ?? $_POST['product_slug'] ?? '';
|
||||
$version = $inputData['version'] ?? $_POST['version'] ?? '';
|
||||
$channel = $inputData['channel'] ?? $_POST['channel'] ?? 'prod';
|
||||
$url = $inputData['download_url'] ?? $_POST['download_url'] ?? '';
|
||||
$hash = $inputData['sha256_hash'] ?? $_POST['sha256_hash'] ?? null;
|
||||
$gitCommit = $inputData['git_commit'] ?? $_POST['git_commit'] ?? null;
|
||||
$sizeBytes = (int)($inputData['size_bytes'] ?? $_POST['size_bytes'] ?? 0);
|
||||
$notes = $inputData['release_notes'] ?? $_POST['release_notes'] ?? null;
|
||||
$isCritical= !empty($inputData['is_critical']) || !empty($_POST['is_critical']);
|
||||
|
||||
if (empty($product) || empty($version) || empty($url)) {
|
||||
sendResponse(['error' => 'Bad Request', 'message' => 'Missing required fields: product_slug, version, download_url'], 400);
|
||||
}
|
||||
|
||||
$ok = $updateMgr->addRelease($product, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $isCritical);
|
||||
if ($ok) {
|
||||
sendResponse(['status' => 'success', 'message' => "Release v{$version} published for {$product} ({$channel})."]);
|
||||
} else {
|
||||
sendResponse(['error' => 'Database Error', 'message' => 'Failed to store release.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if (str_ends_with($uri, '/check') && ($method === 'GET' || $method === 'POST')) {
|
||||
$product = $_REQUEST['product'] ?? $_REQUEST['product_slug'] ?? '';
|
||||
$version = $_REQUEST['version'] ?? $_REQUEST['current_version'] ?? '0.0.0';
|
||||
$channel = $_REQUEST['channel'] ?? 'prod';
|
||||
|
||||
if (empty($product)) {
|
||||
sendResponse(['error' => 'Bad Request', 'message' => 'Parameter "product" is required.'], 400);
|
||||
}
|
||||
|
||||
$latest = $updateMgr->checkUpdate($product, $version);
|
||||
$latest = $updateMgr->checkUpdate($product, $version, $channel);
|
||||
if ($latest) {
|
||||
sendResponse([
|
||||
'update_available' => true,
|
||||
@@ -49,7 +85,8 @@ try {
|
||||
|
||||
if (str_ends_with($uri, '/releases') && $method === 'GET') {
|
||||
$product = $_GET['product'] ?? null;
|
||||
$releases = $updateMgr->getReleases($product);
|
||||
$channel = $_GET['channel'] ?? null;
|
||||
$releases = $updateMgr->getReleases($product, $channel);
|
||||
sendResponse(['count' => count($releases), 'releases' => $releases]);
|
||||
}
|
||||
|
||||
|
||||
+725
-8
@@ -9,15 +9,19 @@ require_once __DIR__ . '/../src/Modules/License/LicenseService.php';
|
||||
require_once __DIR__ . '/../src/Modules/Watchdog/MonitorRepo.php';
|
||||
require_once __DIR__ . '/../src/Modules/Watchdog/EventLog.php';
|
||||
require_once __DIR__ . '/../src/Modules/Watchdog/TokenManager.php';
|
||||
require_once __DIR__ . '/../src/Core/TokenManager.php';
|
||||
require_once __DIR__ . '/../src/Modules/UpdateService/UpdateManager.php';
|
||||
require_once __DIR__ . '/../src/Modules/Bugtracker/BugRepo.php';
|
||||
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Auth;
|
||||
use Deploymentcenter\Core\TokenManager as CoreTokenManager;
|
||||
use Deploymentcenter\Modules\License\KeyGen;
|
||||
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
||||
use Deploymentcenter\Modules\Watchdog\EventLog;
|
||||
use Deploymentcenter\Modules\Watchdog\TokenManager;
|
||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
|
||||
Auth::requireLogin();
|
||||
|
||||
@@ -388,21 +392,123 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($action === 'add_release') {
|
||||
$productSlug = trim($_POST['product_slug'] ?? '');
|
||||
$version = trim($_POST['version'] ?? '');
|
||||
$channel = trim($_POST['channel'] ?? 'prod');
|
||||
$url = trim($_POST['download_url'] ?? '');
|
||||
$hash = trim($_POST['sha256_hash'] ?? '');
|
||||
$gitCommit = trim($_POST['git_commit'] ?? '');
|
||||
$sizeBytes = (int)($_POST['size_bytes'] ?? 0);
|
||||
$notes = trim($_POST['release_notes'] ?? '');
|
||||
$critical = isset($_POST['is_critical']);
|
||||
|
||||
if ($productSlug && $version && $url) {
|
||||
$updMgr = new UpdateManager($pdo);
|
||||
if ($updMgr->addRelease($productSlug, $version, $notes, $url, $hash, $critical)) {
|
||||
$msg = "Release v{$version} für Projekt '{$productSlug}' veröffentlicht.";
|
||||
if ($updMgr->addRelease($productSlug, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $critical)) {
|
||||
$msg = "Release v{$version} ({$channel}) für Projekt '{$productSlug}' veröffentlicht.";
|
||||
} else {
|
||||
$msg = "Fehler beim Speichern des Releases.";
|
||||
$msgType = 'danger';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Core Master & Sub Token Management Actions
|
||||
if ($action === 'create_master_token') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$proj = trim($_POST['project_slug'] ?? '');
|
||||
$lic = trim($_POST['license_key'] ?? '');
|
||||
$ownerType = $_POST['owner_type'] ?? 'custom';
|
||||
$ownerIdentity = trim($_POST['owner_identity'] ?? '');
|
||||
$scopes = isset($_POST['scopes']) && is_array($_POST['scopes']) ? $_POST['scopes'] : ['*'];
|
||||
$env = $_POST['environment'] ?? 'all';
|
||||
|
||||
if ($name) {
|
||||
$coreTokenMgr = new CoreTokenManager($pdo);
|
||||
$res = $coreTokenMgr->createMasterToken($name, $proj, $lic, $ownerType, $ownerIdentity, $scopes, $env);
|
||||
$msg = "Master-Token '{$name}' erstellt! Raw Master-Token (einmalig kopieren): <strong style='font-family:monospace; color:var(--success); font-size:1.1em;'>{$res['raw_token']}</strong>";
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'revoke_core_token') {
|
||||
$tokenId = trim($_POST['token_id'] ?? '');
|
||||
if ($tokenId) {
|
||||
$coreTokenMgr = new CoreTokenManager($pdo);
|
||||
$coreTokenMgr->revokeToken($tokenId);
|
||||
$msg = "Token '{$tokenId}' und alle abgeleiteten Sub-Tokens wurden widerrufen.";
|
||||
}
|
||||
}
|
||||
|
||||
// Bugtracker & Feature-Tracker Actions
|
||||
if ($action === 'bt_add_comment') {
|
||||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||||
$comment = trim($_POST['comment'] ?? '');
|
||||
$author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin');
|
||||
$actionTaken = trim($_POST['action_taken'] ?? 'commented');
|
||||
|
||||
if ($itemId > 0 && !empty($comment)) {
|
||||
$bugRepo = new BugRepo($pdo);
|
||||
$bugRepo->addComment($itemId, $author, $comment, $actionTaken);
|
||||
$msg = "Kommentar zu Item #{$itemId} hinzugefügt.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'bt_resolve') {
|
||||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||||
$build = trim($_POST['resolved_in_build'] ?? 'v1.0.0');
|
||||
$notes = trim($_POST['resolution_notes'] ?? '');
|
||||
$author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin');
|
||||
|
||||
if ($itemId > 0 && !empty($build)) {
|
||||
$bugRepo = new BugRepo($pdo);
|
||||
$bugRepo->resolveItem($itemId, $build, $notes, $author);
|
||||
$msg = "Item #{$itemId} wurde als gelöst/umgesetzt in Build '{$build}' markiert.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'bt_create_item') {
|
||||
$proj = trim($_POST['project_slug'] ?? 'default');
|
||||
$type = $_POST['type'] ?? 'bug';
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$desc = trim($_POST['description'] ?? '');
|
||||
$errMsg = trim($_POST['error_message'] ?? '');
|
||||
$trace = trim($_POST['stack_trace'] ?? '');
|
||||
$build = trim($_POST['build_version'] ?? 'v1.0.0');
|
||||
$env = $_POST['environment'] ?? 'production';
|
||||
$sev = $_POST['severity'] ?? 'medium';
|
||||
$createdBy = trim($_POST['created_by'] ?? $_SESSION['dc_username'] ?? 'admin');
|
||||
|
||||
if ($title) {
|
||||
$bugRepo = new BugRepo($pdo);
|
||||
$res = $bugRepo->reportItem([
|
||||
'project_slug' => $proj,
|
||||
'type' => $type,
|
||||
'title' => $title,
|
||||
'description' => $desc,
|
||||
'error_message' => $errMsg,
|
||||
'stack_trace' => $trace,
|
||||
'build_version' => $build,
|
||||
'environment' => $env,
|
||||
'severity' => $sev,
|
||||
'created_by' => $createdBy,
|
||||
]);
|
||||
$msg = $res['is_new']
|
||||
? "Neues Item #{$res['id']} ({$res['type']}) erfolgreich in {$env} erfasst."
|
||||
: "Wiederkehrender Fehler erfasst. Occurrence Count erhöht auf {$res['occurrence_count']}.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'bt_change_status') {
|
||||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||||
$status = $_POST['status'] ?? 'open';
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
$author = trim($_POST['author'] ?? $_SESSION['dc_username'] ?? 'admin');
|
||||
|
||||
if ($itemId > 0) {
|
||||
$bugRepo = new BugRepo($pdo);
|
||||
$bugRepo->updateStatus($itemId, $status, $notes, $author);
|
||||
$msg = "Status für Item #{$itemId} geändert auf {$status}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch All Data
|
||||
@@ -532,6 +638,16 @@ $recentEvents = $eventLog->getRecentEvents(100);
|
||||
$updateMgr = new UpdateManager($pdo);
|
||||
$releases = $updateMgr->getReleases();
|
||||
|
||||
// Core Token Manager Data
|
||||
$coreTokenMgr = new CoreTokenManager($pdo);
|
||||
$coreMasterTokens = $coreTokenMgr->getAllMasterTokens();
|
||||
$coreAllTokens = $coreTokenMgr->getAllTokens();
|
||||
|
||||
// Bugtracker Data
|
||||
$bugRepo = new BugRepo($pdo);
|
||||
$bugtrackerStats = $bugRepo->getStats();
|
||||
$bugtrackerItems = $bugRepo->getItems();
|
||||
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'dc.mhdf.de';
|
||||
$baseUrl = $protocol . '://' . $host;
|
||||
@@ -740,6 +856,12 @@ $baseUrl = $protocol . '://' . $host;
|
||||
<span class="nav-text">UpdateService</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" onclick="switchMainTab('bugtracker', this)" title="Bug- & Feature-Tracker">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1v2h2a1 1 0 0 1 0 2h-2v2h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h1v-2H6a1 1 0 0 1 0-2h2v-2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2z"></path></svg>
|
||||
<span class="nav-text">Bugtracker</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -748,6 +870,12 @@ $baseUrl = $protocol . '://' . $host;
|
||||
<div class="nav-section" style="margin-bottom:1rem;">
|
||||
<div class="nav-section-title">Administration</div>
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" onclick="switchMainTab('tokens', this)" title="Master- & Sub-Tokens">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
|
||||
<span class="nav-text">Token-Verwaltung</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" onclick="switchMainTab('system', this)" title="System & DB">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
@@ -1601,24 +1729,50 @@ $baseUrl = $protocol . '://' . $host;
|
||||
|
||||
<div id="sub-update-releases" class="subtab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">📦 Veröffentlichte Software Releases</h2></div>
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">📦 Veröffentlichte Software Releases</h2>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Projekt</th>
|
||||
<th>Kanal</th>
|
||||
<th>Version</th>
|
||||
<th>Git Commit</th>
|
||||
<th>Größe</th>
|
||||
<th>Release Notes</th>
|
||||
<th>Download URL</th>
|
||||
<th>Download & SHA256</th>
|
||||
<th>Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($releases as $r): ?>
|
||||
<?php
|
||||
$channelClass = match($r['channel'] ?? 'prod') {
|
||||
'prod' => 'badge-up',
|
||||
'beta' => 'badge-warning',
|
||||
'dev' => 'badge-stopped',
|
||||
default => 'badge-up'
|
||||
};
|
||||
$sizeFormatted = !empty($r['size_bytes']) ? round($r['size_bytes'] / (1024 * 1024), 2) . ' MB' : '-';
|
||||
?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($r['product_slug']) ?></strong></td>
|
||||
<td><code>v<?= htmlspecialchars($r['version']) ?></code></td>
|
||||
<td><span class="badge <?= $channelClass ?>"><?= strtoupper(htmlspecialchars($r['channel'] ?? 'prod')) ?></span></td>
|
||||
<td><code>v<?= htmlspecialchars($r['version']) ?></code> <?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</span><?php endif; ?></td>
|
||||
<td><code style="color:var(--text-muted);"><?= htmlspecialchars($r['git_commit'] ?? 'n/a') ?></code></td>
|
||||
<td><span style="font-family:'Roboto Mono', monospace; font-size:0.8rem;"><?= $sizeFormatted ?></span></td>
|
||||
<td><?= htmlspecialchars($r['release_notes'] ?? '-') ?></td>
|
||||
<td><a href="<?= htmlspecialchars($r['download_url']) ?>" target="_blank" style="color:var(--primary); font-weight:700;"><?= htmlspecialchars($r['download_url']) ?></a></td>
|
||||
<td>
|
||||
<a href="<?= htmlspecialchars($r['download_url']) ?>" target="_blank" class="btn btn-sm btn-secondary">
|
||||
⬇️ Download Package
|
||||
</a>
|
||||
<?php if (!empty($r['sha256_hash'])): ?>
|
||||
<div style="font-family:'Roboto Mono', monospace; font-size:0.65rem; color:var(--text-muted); margin-top:2px;" title="<?= htmlspecialchars($r['sha256_hash']) ?>">
|
||||
SHA: <?= htmlspecialchars(substr($r['sha256_hash'], 0, 12)) ?>...
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($r['created_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@@ -1641,25 +1795,419 @@ $baseUrl = $protocol . '://' . $host;
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Release Kanal</label>
|
||||
<select name="channel" class="form-input" required>
|
||||
<option value="prod" selected>prod (Produktiv)</option>
|
||||
<option value="beta">beta (Vorab-Test)</option>
|
||||
<option value="dev">dev (Entwicklung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Version (z. B. 1.2.0)</label>
|
||||
<input type="text" name="version" class="form-input" required placeholder="1.2.0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Git Commit (Kurz-Hash)</label>
|
||||
<input type="text" name="git_commit" class="form-input" placeholder="a1b2c3d">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Download URL</label>
|
||||
<input type="url" name="download_url" class="form-input" required placeholder="https://dc.mhdf.de/downloads/myapp-1.2.0.zip">
|
||||
<input type="url" name="download_url" class="form-input" required placeholder="https://dc.mhdf.de/releases/myapp/prod/1.2.0/package.tar.gz">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SHA256 Hash (Optional)</label>
|
||||
<input type="text" name="sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem;">
|
||||
<label class="form-label">Release Notes</label>
|
||||
<label class="form-label">Release Notes / Changelog</label>
|
||||
<textarea name="release_notes" class="form-input" rows="3" placeholder="Changelog und Verbesserungen..."></textarea>
|
||||
</div>
|
||||
<div style="margin-bottom:1.25rem;">
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
|
||||
<input type="checkbox" name="is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn">Release Speichern & Freigeben</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================= MODULE: BUGTRACKER & FEATURE-TRACKER ================= -->
|
||||
<div id="tab-bugtracker" class="tab-content">
|
||||
|
||||
<div id="sub-bugtracker-items" class="subtab-content active">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">Dev Bugs (Entwicklung)</div>
|
||||
<div class="stat-value" style="color:#5b9dff;"><?= $bugtrackerStats['open_bugs_dev'] ?></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">Prod Bugs (Produktion)</div>
|
||||
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['open_bugs_prod'] ?></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">Offene Feature Requests</div>
|
||||
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['open_features'] ?></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">Gelöst / Umgesetzt</div>
|
||||
<div class="stat-value" style="color:var(--success);"><?= $bugtrackerStats['resolved_total'] ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h2 class="card-title">🐛 Bugs & Feature Requests</h2>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<select id="btFilterEnv" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="filterBugtrackerTable()">
|
||||
<option value="all">🌐 Alle Umgebungen</option>
|
||||
<option value="production">🔴 Produktion (Prod)</option>
|
||||
<option value="development">🔵 Entwicklung (Dev)</option>
|
||||
<option value="staging">🟡 Staging</option>
|
||||
</select>
|
||||
<select id="btFilterType" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="filterBugtrackerTable()">
|
||||
<option value="all">📂 Alle Typen</option>
|
||||
<option value="bug">🐛 Nur Bugs</option>
|
||||
<option value="feature_request">💡 Nur Feature Requests</option>
|
||||
</select>
|
||||
<select id="btFilterStatus" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="filterBugtrackerTable()">
|
||||
<option value="all">📌 Alle Status</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="in_progress">In Bearbeitung</option>
|
||||
<option value="resolved">Gelöst / Umgesetzt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID / Typ</th>
|
||||
<th>Umgebung</th>
|
||||
<th>Projekt & Titel</th>
|
||||
<th>Build / Version</th>
|
||||
<th>Schweregrad</th>
|
||||
<th>Anzahl</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="btTableBody">
|
||||
<?php foreach ($bugtrackerItems as $item): ?>
|
||||
<?php
|
||||
$typeBadge = $item['type'] === 'feature_request'
|
||||
? '<span class="badge" style="background:rgba(180,100,255,0.2); color:#c87dff; border:1px solid #c87dff;">💡 FEATURE</span>'
|
||||
: '<span class="badge badge-warning">🐛 BUG</span>';
|
||||
|
||||
$envBadge = match($item['environment']) {
|
||||
'development' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">🔵 DEV</span>',
|
||||
'production' => '<span class="badge badge-down">🔴 PROD</span>',
|
||||
default => '<span class="badge badge-stopped">' . strtoupper($item['environment']) . '</span>'
|
||||
};
|
||||
|
||||
$statusBadge = match($item['status']) {
|
||||
'open' => '<span class="badge badge-down">OFFEN</span>',
|
||||
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
|
||||
'resolved' => '<span class="badge badge-up">GELÖST / UMGESETZT</span>',
|
||||
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
|
||||
default => '<span class="badge badge-stopped">' . strtoupper($item['status']) . '</span>'
|
||||
};
|
||||
|
||||
$sevBadge = match($item['severity']) {
|
||||
'critical' => '<span class="badge badge-down" style="font-weight:bold;">🔥 KRITISCH</span>',
|
||||
'high' => '<span class="badge badge-warning">HOCH</span>',
|
||||
'medium' => '<span class="badge badge-stopped">MITTEL</span>',
|
||||
default => '<span class="badge badge-stopped">NIEDRIG</span>'
|
||||
};
|
||||
?>
|
||||
<tr class="bt-row"
|
||||
data-env="<?= htmlspecialchars($item['environment']) ?>"
|
||||
data-type="<?= htmlspecialchars($item['type']) ?>"
|
||||
data-status="<?= htmlspecialchars($item['status']) ?>">
|
||||
<td>#<?= $item['id'] ?><br><?= $typeBadge ?></td>
|
||||
<td><?= $envBadge ?></td>
|
||||
<td>
|
||||
<strong style="color:#fff;"><?= htmlspecialchars($item['title']) ?></strong>
|
||||
<div style="font-size:0.75rem; color:var(--text-muted);">
|
||||
Projekt: <code><?= htmlspecialchars($item['project_slug']) ?></code> | Von: <?= htmlspecialchars($item['created_by']) ?>
|
||||
</div>
|
||||
</td>
|
||||
<td><code><?= htmlspecialchars($item['build_version'] ?? 'v1.0.0') ?></code></td>
|
||||
<td><?= $sevBadge ?></td>
|
||||
<td>
|
||||
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
|
||||
<?= (int)$item['occurrence_count'] ?>x
|
||||
</span>
|
||||
</td>
|
||||
<td><?= $statusBadge ?></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="openBugtrackerModal(<?= $item['id'] ?>)">🔍 Details & Timeline</button>
|
||||
<?php if ($item['status'] !== 'resolved'): ?>
|
||||
<button type="button" class="btn btn-sm" onclick="openResolveModal(<?= $item['id'] ?>, '<?= htmlspecialchars(addslashes($item['title'])) ?>')">✔ Gelöst</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtab: Item manuell anlegen -->
|
||||
<div id="sub-bugtracker-new" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">➕ Bug oder Feature Request Manuell Erfassen</h2></div>
|
||||
<form method="POST" action="index.php#tab-bugtracker">
|
||||
<input type="hidden" name="action" value="bt_create_item">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Projekt</label>
|
||||
<select name="project_slug" class="form-input" required>
|
||||
<?php foreach ($projects as $p): ?>
|
||||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Typ</label>
|
||||
<select name="type" class="form-input" required>
|
||||
<option value="bug" selected>🐛 Bug / Fehlerbericht</option>
|
||||
<option value="feature_request">💡 Feature Request / Vorschlag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Umgebung (Environment)</label>
|
||||
<select name="environment" class="form-input" required>
|
||||
<option value="development">🔵 Entwicklung (Development)</option>
|
||||
<option value="production" selected>🔴 Produktion (Production)</option>
|
||||
<option value="staging">🟡 Staging</option>
|
||||
<option value="testing">🧪 Testing</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Schweregrad / Priorität</label>
|
||||
<select name="severity" class="form-input" required>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium" selected>Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">🔥 Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label class="form-label">Titel / Zusammenfassung</label>
|
||||
<input type="text" name="title" class="form-input" required placeholder="z. B. NullReferenceException bei Order-Submit">
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label class="form-label">Build / Version</label>
|
||||
<input type="text" name="build_version" class="form-input" value="v1.4.2" placeholder="v1.4.2">
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label class="form-label">Detaillierte Beschreibung</label>
|
||||
<textarea name="description" class="form-input" rows="3" placeholder="Was ist passiert? Unter welchen Bedingungen?"></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label class="form-label">Fehlermeldung / Exception Message</label>
|
||||
<textarea name="error_message" class="form-input" rows="2" placeholder="Exakte Fehlermeldung aus dem Log..."></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label class="form-label">Stacktrace / Log Ausschnitt</label>
|
||||
<textarea name="stack_trace" class="form-input" rows="4" style="font-family:monospace;" placeholder="at MyApp.Core.Service.DoWork()..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn" style="margin-top:1rem;">Item Speichern & Anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================= MODULE: TOKEN-VERWALTUNG ================= -->
|
||||
<div id="tab-tokens" class="tab-content">
|
||||
|
||||
<div id="sub-tokens-masters" class="subtab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">👑 Master-Tokens (Selbst-Provisionierung für Client-Apps & Host-Skripte)</h2></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token ID</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Typ / Identität</th>
|
||||
<th>Projekt / Lizenz</th>
|
||||
<th>Rechte (Scopes)</th>
|
||||
<th>Sub-Tokens</th>
|
||||
<th>Token Key</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($coreMasterTokens as $mTok): ?>
|
||||
<?php
|
||||
$rawVal = !empty($mTok['raw_token']) ? $mTok['raw_token'] : 'dc_master_...';
|
||||
$masked = substr($rawVal, 0, 12) . '••••••••••••••••';
|
||||
$scopesArr = json_decode($mTok['scopes'], true) ?: ['*'];
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?= htmlspecialchars($mTok['token_id']) ?></code></td>
|
||||
<td><strong><?= htmlspecialchars($mTok['name']) ?></strong></td>
|
||||
<td>
|
||||
<span class="badge badge-warning"><?= strtoupper($mTok['owner_type']) ?></span><br>
|
||||
<small><?= htmlspecialchars($mTok['owner_identity'] ?? '-') ?></small>
|
||||
</td>
|
||||
<td>
|
||||
<?= htmlspecialchars($mTok['project_slug'] ?? '-') ?>
|
||||
<?= !empty($mTok['license_key']) ? '<br><small>Lic: ' . htmlspecialchars($mTok['license_key']) . '</small>' : '' ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php foreach ($scopesArr as $sc): ?>
|
||||
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;"><?= htmlspecialchars($sc) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-up"><?= (int)($mTok['sub_token_count'] ?? 0) ?> Sub-Tokens</span>
|
||||
</td>
|
||||
<td>
|
||||
<code id="tok-core-text-<?= $mTok['token_id'] ?>" data-full="<?= htmlspecialchars($rawVal) ?>" data-masked="<?= htmlspecialchars($masked) ?>">
|
||||
<?= htmlspecialchars($masked) ?>
|
||||
</code>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('core-text-<?= $mTok['token_id'] ?>')">👁️</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('core-text-<?= $mTok['token_id'] ?>')">📋</button>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!$mTok['revoked']): ?>
|
||||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token widerrufen? Alle zugehörigen Sub-Tokens werden sofort kaskadierend mit gesperrt!');">
|
||||
<input type="hidden" name="action" value="revoke_core_token">
|
||||
<input type="hidden" name="token_id" value="<?= $mTok['token_id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Master & Subs Widerrufen</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-down">WIDERUFEN</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-tokens-subs" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">🔑 Aktive Sub-Tokens (Per Provisioning API Erstellt)</h2></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token ID</th>
|
||||
<th>Parent Master ID</th>
|
||||
<th>Bezeichnung / Client</th>
|
||||
<th>Umgebung</th>
|
||||
<th>Scopes</th>
|
||||
<th>Zuletzt Genutzt</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$subTokens = array_filter($coreAllTokens, fn($t) => $t['type'] === 'sub');
|
||||
foreach ($subTokens as $sTok):
|
||||
$scopesArr = json_decode($sTok['scopes'], true) ?: [];
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?= htmlspecialchars($sTok['token_id']) ?></code></td>
|
||||
<td><code><?= htmlspecialchars($sTok['parent_token_id'] ?? '-') ?></code></td>
|
||||
<td><strong><?= htmlspecialchars($sTok['name']) ?></strong></td>
|
||||
<td>
|
||||
<span class="badge badge-stopped"><?= strtoupper($sTok['environment']) ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?php foreach ($scopesArr as $sc): ?>
|
||||
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;"><?= htmlspecialchars($sc) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<td><?= $sTok['last_used_at'] ? htmlspecialchars($sTok['last_used_at']) : 'Noch nie' ?></td>
|
||||
<td><span class="badge badge-<?= $sTok['revoked'] ? 'down' : 'up' ?>"><?= $sTok['revoked'] ? 'WIDERUFEN' : 'AKTIV' ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-tokens-create" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">➕ Neuen Master-Token Erstellen</h2></div>
|
||||
<form method="POST" action="index.php#tab-tokens">
|
||||
<input type="hidden" name="action" value="create_master_token">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bezeichnung / Name</label>
|
||||
<input type="text" name="name" class="form-input" required placeholder="z. B. PolyTrader Prod Server Master Key">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Identitätstyp (Owner Type)</label>
|
||||
<select name="owner_type" class="form-input" required>
|
||||
<option value="custom" selected>Custom / Allgemein</option>
|
||||
<option value="project">Projekt-Gebunden</option>
|
||||
<option value="license">Lizenz-Gebunden</option>
|
||||
<option value="host">Host / Infrastruktur Server</option>
|
||||
<option value="dev_agent">Entwickler KI-Agent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Freie Identität / Host / HWID (Optional)</label>
|
||||
<input type="text" name="owner_identity" class="form-input" placeholder="z. B. srv-db-01 oder HWID-88A9...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Projekt (Optional)</label>
|
||||
<select name="project_slug" class="form-input">
|
||||
<option value="">-- Keins (Universal) --</option>
|
||||
<?php foreach ($projects as $p): ?>
|
||||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Umgebung (Environment Limit)</label>
|
||||
<select name="environment" class="form-input" required>
|
||||
<option value="all" selected>🌐 Alle Umgebungen (All)</option>
|
||||
<option value="production">🔴 Nur Produktiv (Production)</option>
|
||||
<option value="development">🔵 Nur Entwicklung (Development)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label class="form-label">Erlaubte Scopes (Rechte-Umfang für Sub-Tokens)</label>
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:0.5rem; margin-top:0.5rem;">
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.85rem; cursor:pointer;">
|
||||
<input type="checkbox" name="scopes[]" value="*" checked> 🌟 Alle Scopes (*)
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.85rem; cursor:pointer;">
|
||||
<input type="checkbox" name="scopes[]" value="bugtracker:report"> 🐛 Bugtracker Report
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.85rem; cursor:pointer;">
|
||||
<input type="checkbox" name="scopes[]" value="bugtracker:manage"> ⚙️ Bugtracker Manage & Resolve
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.85rem; cursor:pointer;">
|
||||
<input type="checkbox" name="scopes[]" value="watchdog:ping"> 🛡️ Watchdog Heartbeat Ping
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.85rem; cursor:pointer;">
|
||||
<input type="checkbox" name="scopes[]" value="updateservice:read"> 📦 UpdateService Read
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="margin-top:1.25rem;">Master-Token Erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================= MODULE 5: SYSTEM & DB ================= -->
|
||||
<div id="tab-system" class="tab-content">
|
||||
|
||||
@@ -1756,6 +2304,15 @@ $baseUrl = $protocol . '://' . $host;
|
||||
{ id: 'sub-update-releases', label: '📊 Releases Overview', active: true },
|
||||
{ id: 'sub-update-publish', label: '➕ Release Veröffentlichen' }
|
||||
],
|
||||
'bugtracker': [
|
||||
{ id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true },
|
||||
{ id: 'sub-bugtracker-new', label: '➕ Item Anlegen' }
|
||||
],
|
||||
'tokens': [
|
||||
{ id: 'sub-tokens-masters', label: '👑 Master-Tokens', active: true },
|
||||
{ id: 'sub-tokens-subs', label: '🔑 Sub-Tokens' },
|
||||
{ id: 'sub-tokens-create', label: '➕ Master-Token Erstellen' }
|
||||
],
|
||||
'system': [
|
||||
{ id: 'sub-system-status', label: '⚙️ System-Status', active: true },
|
||||
{ id: 'sub-system-swagger', label: '📖 API Swagger Docs' },
|
||||
@@ -1828,6 +2385,8 @@ $baseUrl = $protocol . '://' . $host;
|
||||
'license': 'Lizenzverwaltung',
|
||||
'watchdog': 'WatchDog Monitoring',
|
||||
'updateservice': 'UpdateService Releases',
|
||||
'bugtracker': 'Bug- & Feature-Tracker',
|
||||
'tokens': 'Token-Verwaltung & Provisionierung',
|
||||
'system': 'System & Datenbank Status'
|
||||
};
|
||||
document.getElementById('topPageTitle').innerText = pageTitles[moduleName] || 'Deploymentcenter';
|
||||
@@ -1835,6 +2394,128 @@ $baseUrl = $protocol . '://' . $host;
|
||||
location.hash = 'tab-' + moduleName;
|
||||
}
|
||||
|
||||
// Bugtracker Table Filter JS
|
||||
function filterBugtrackerTable() {
|
||||
const env = document.getElementById('btFilterEnv').value;
|
||||
const type = document.getElementById('btFilterType').value;
|
||||
const status = document.getElementById('btFilterStatus').value;
|
||||
|
||||
document.querySelectorAll('#btTableBody .bt-row').forEach(row => {
|
||||
const matchEnv = (env === 'all' || row.getAttribute('data-env') === env);
|
||||
const matchType = (type === 'all' || row.getAttribute('data-type') === type);
|
||||
const matchStatus = (status === 'all' || row.getAttribute('data-status') === status);
|
||||
|
||||
if (matchEnv && matchType && matchStatus) {
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Open Bugtracker Item Details & Timeline Modal
|
||||
function openBugtrackerModal(itemId) {
|
||||
const modal = document.getElementById('btDetailModal');
|
||||
const content = document.getElementById('btModalContent');
|
||||
modal.style.display = 'flex';
|
||||
content.innerHTML = '<div style="text-align:center; padding:2rem; color:var(--text-muted);">⏳ Lade Details & Timeline...</div>';
|
||||
|
||||
fetch(`api/bugtracker/v1/manage/index.php?id=${itemId}`)
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (!res.item) {
|
||||
content.innerHTML = '<div class="alert alert-danger">Fehler beim Laden des Items.</div>';
|
||||
return;
|
||||
}
|
||||
const item = res.item;
|
||||
const comments = item.comments || [];
|
||||
|
||||
let commentsHtml = '';
|
||||
if (comments.length === 0) {
|
||||
commentsHtml = '<p style="color:var(--text-muted); font-size:0.85rem;">Noch keine Kommentare oder Ermittlungsschritte hinterlegt.</p>';
|
||||
} else {
|
||||
comments.forEach(c => {
|
||||
commentsHtml += `
|
||||
<div style="background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); padding:0.75rem; border-radius:8px; margin-bottom:0.6rem;">
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.8rem; margin-bottom:0.3rem;">
|
||||
<strong style="color:var(--primary);">${c.author}</strong>
|
||||
<span style="color:var(--text-muted);">${c.created_at}</span>
|
||||
</div>
|
||||
<div style="font-size:0.875rem; white-space:pre-wrap;">${c.comment}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:1rem;">
|
||||
<div>
|
||||
<span class="badge badge-${item.type === 'feature_request' ? 'warning' : 'down'}">${item.type.toUpperCase()}</span>
|
||||
<span class="badge badge-stopped">${item.environment.toUpperCase()}</span>
|
||||
<h3 style="margin:0.5rem 0 0.2rem 0; color:#fff;">#${item.id}: ${item.title}</h3>
|
||||
<div style="font-size:0.8rem; color:var(--text-muted);">
|
||||
Projekt: <code>${item.project_slug}</code> | Build: <code>${item.build_version || 'v1.0.0'}</code> | Gemeldet von: ${item.created_by}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="closeBugtrackerModal()">✕</button>
|
||||
</div>
|
||||
|
||||
${item.description ? `
|
||||
<div style="margin-bottom:1rem;">
|
||||
<strong>Beschreibung:</strong>
|
||||
<div style="background:rgba(0,0,0,0.2); padding:0.6rem; border-radius:6px; font-size:0.875rem; margin-top:0.3rem;">${item.description}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${item.error_message ? `
|
||||
<div style="margin-bottom:1rem;">
|
||||
<strong style="color:var(--danger);">Fehlermeldung:</strong>
|
||||
<pre style="background:#0d1117; color:#ff7b72; padding:0.75rem; border-radius:6px; font-family:monospace; font-size:0.82rem; overflow-x:auto; margin-top:0.3rem;">${item.error_message}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${item.stack_trace ? `
|
||||
<div style="margin-bottom:1rem;">
|
||||
<strong>Stacktrace:</strong>
|
||||
<pre style="background:#0d1117; color:#c9d1d9; padding:0.75rem; border-radius:6px; font-family:monospace; font-size:0.78rem; max-height:200px; overflow-y:auto; margin-top:0.3rem;">${item.stack_trace}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<hr style="border-color:var(--border-glass); margin:1.25rem 0;">
|
||||
|
||||
<h4 style="margin-bottom:0.75rem; color:#fff;">📜 Agenten-Historie & Kommentar-Timeline</h4>
|
||||
<div style="max-height:250px; overflow-y:auto; margin-bottom:1rem;">${commentsHtml}</div>
|
||||
|
||||
<form method="POST" action="index.php#tab-bugtracker">
|
||||
<input type="hidden" name="action" value="bt_add_comment">
|
||||
<input type="hidden" name="item_id" value="${item.id}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Ermittlungsschritt / Kommentar Hinzufügen</label>
|
||||
<textarea name="comment" class="form-input" rows="2" required placeholder="Notiere hier Diagnoseergebnisse oder Hinweise für andere Agenten..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm" style="margin-top:0.5rem;">Kommentar Speichern</button>
|
||||
</form>
|
||||
`;
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<div class="alert alert-danger">Fehler beim Laden: ${err.message}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function closeBugtrackerModal() {
|
||||
document.getElementById('btDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function openResolveModal(itemId, title) {
|
||||
document.getElementById('resolveItemId').value = itemId;
|
||||
document.getElementById('resolveItemTitle').innerText = title;
|
||||
document.getElementById('btResolveModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeResolveModal() {
|
||||
document.getElementById('btResolveModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Horizontal Submenu Switcher in Top Bar
|
||||
function switchSubTab(moduleName, subtabId, el) {
|
||||
const parentModule = document.getElementById('tab-' + moduleName);
|
||||
@@ -2021,5 +2702,41 @@ $baseUrl = $protocol . '://' . $host;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Bugtracker Details & Timeline Modal -->
|
||||
<div id="btDetailModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||||
<div class="card" style="width:100%; max-width:750px; max-height:90vh; overflow-y:auto; position:relative; background:#161c28; border:1px solid var(--border-glass);">
|
||||
<div id="btModalContent">
|
||||
<!-- Loaded via JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bugtracker Resolve Modal -->
|
||||
<div id="btResolveModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||||
<div class="card" style="width:100%; max-width:500px; background:#161c28; border:1px solid var(--border-glass);">
|
||||
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 class="card-title" style="margin:0; color:#fff;">✔ Item als Gelöst / Umgesetzt Markieren</h3>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="closeResolveModal()">✕</button>
|
||||
</div>
|
||||
<p style="font-size:0.875rem; color:var(--text-muted); margin-bottom:1rem;" id="resolveItemTitle"></p>
|
||||
<form method="POST" action="index.php#tab-bugtracker">
|
||||
<input type="hidden" name="action" value="bt_resolve">
|
||||
<input type="hidden" name="item_id" id="resolveItemId" value="0">
|
||||
<div class="form-group" style="margin-bottom:1rem;">
|
||||
<label class="form-label">Lösungs-Build / Version (z. B. v1.4.3)</label>
|
||||
<input type="text" name="resolved_in_build" class="form-input" required value="v1.4.3" placeholder="v1.4.3">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1.25rem;">
|
||||
<label class="form-label">Lösungs-Notizen / Dokumentation</label>
|
||||
<textarea name="resolution_notes" class="form-input" rows="3" placeholder="Kurze Beschreibung, wie das Problem behoben oder das Feature umgesetzt wurde..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:0.5rem;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeResolveModal()">Abbrechen</button>
|
||||
<button type="submit" class="btn">Als Gelöst Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+5
-3
@@ -17,7 +17,9 @@ IGNORE_PATTERNS = {
|
||||
'scripts',
|
||||
'.deploy_cache.json',
|
||||
'Serverdaten.txt',
|
||||
'Serverdaten.txt.bak'
|
||||
'Serverdaten.txt.bak',
|
||||
'bin',
|
||||
'obj'
|
||||
}
|
||||
|
||||
def load_config():
|
||||
@@ -65,9 +67,9 @@ def should_ignore(rel_path):
|
||||
parts = Path(rel_path).parts
|
||||
if not parts:
|
||||
return False
|
||||
if parts[0] in IGNORE_PATTERNS:
|
||||
return True
|
||||
for part in parts:
|
||||
if part in IGNORE_PATTERNS:
|
||||
return True
|
||||
if part == '.htaccess':
|
||||
continue
|
||||
if part.startswith('.'):
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
-- Migration 004: Unified Tokens (Master & Sub-Tokens) and Bugtracker / Feature-Tracker Module
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS bugtracker_comments;
|
||||
DROP TABLE IF EXISTS bugtracker_items;
|
||||
DROP TABLE IF EXISTS dc_tokens;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 1. Central Master & Sub-Token Hierarchy Table
|
||||
CREATE TABLE dc_tokens (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
parent_token_id VARCHAR(64) NULL,
|
||||
token_hash VARCHAR(128) NOT NULL,
|
||||
raw_token VARCHAR(128) NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
project_slug VARCHAR(64) NULL,
|
||||
license_key CHAR(29) NULL,
|
||||
owner_type ENUM('license', 'project', 'host', 'dev_agent', 'custom') NOT NULL DEFAULT 'custom',
|
||||
owner_identity VARCHAR(190) NULL,
|
||||
type ENUM('master', 'sub') NOT NULL DEFAULT 'sub',
|
||||
scopes JSON NOT NULL,
|
||||
environment ENUM('production', 'development', 'all') NOT NULL DEFAULT 'all',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (parent_token_id) REFERENCES dc_tokens(token_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. Unified Bugtracker & Feature Request Items
|
||||
CREATE TABLE bugtracker_items (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT NULL,
|
||||
project_slug VARCHAR(64) NOT NULL,
|
||||
type ENUM('bug', 'feature_request') NOT NULL DEFAULT 'bug',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
stack_trace TEXT NULL,
|
||||
error_hash VARCHAR(64) NULL,
|
||||
build_version VARCHAR(64) NULL,
|
||||
environment ENUM('production', 'development', 'staging', 'testing') NOT NULL DEFAULT 'production',
|
||||
severity ENUM('low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium',
|
||||
status ENUM('open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected') NOT NULL DEFAULT 'open',
|
||||
occurrence_count INT NOT NULL DEFAULT 1,
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME NULL,
|
||||
resolved_in_build VARCHAR(64) NULL,
|
||||
resolution_notes TEXT NULL,
|
||||
created_by VARCHAR(100) NOT NULL DEFAULT 'agent',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY ix_bt_proj_env (project_slug, environment, type, status),
|
||||
KEY ix_bt_hash (error_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 3. Diagnostic Comments & Timeline Table
|
||||
CREATE TABLE bugtracker_comments (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
item_id BIGINT NOT NULL,
|
||||
author VARCHAR(100) NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
action_taken VARCHAR(64) NULL,
|
||||
meta_json JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (item_id) REFERENCES bugtracker_items(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Seed Sample Master Token
|
||||
-- Raw Master Token: dc_master_myapp_dev_agent_001
|
||||
-- Hash: SHA256 of 'dc_master_myapp_dev_agent_001'
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name, project_slug, owner_type, owner_identity, type, scopes, environment
|
||||
) VALUES (
|
||||
'tok_m_myapp_dev', NULL,
|
||||
'5a38a7c29e64e526c71c4c16a695123d6874e0d9bf0d768132049e8fa65e10aa',
|
||||
'dc_master_myapp_dev_agent_001',
|
||||
'MyApp Dev Workstation Master Key',
|
||||
'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'master',
|
||||
'["bugtracker:report", "bugtracker:manage", "watchdog:ping", "updateservice:read"]',
|
||||
'all'
|
||||
);
|
||||
|
||||
-- Seed Sample Sub Token provisioned from Master Token
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name, project_slug, owner_type, owner_identity, type, scopes, environment
|
||||
) VALUES (
|
||||
'tok_s_myapp_agent_sub1', 'tok_m_myapp_dev',
|
||||
'7c7bc0a5d4d3856b3e9447477c133a8a3a0e67617ab6f6d0a793a388b14a27bc',
|
||||
'dc_sub_myapp_agent_live_001',
|
||||
'Dev Agent Auto-Provisioned Token',
|
||||
'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'sub',
|
||||
'["bugtracker:report", "bugtracker:manage"]',
|
||||
'development'
|
||||
);
|
||||
|
||||
-- Seed Sample Bugs and Feature Requests
|
||||
INSERT INTO bugtracker_items (
|
||||
id, project_slug, type, title, description, error_message, stack_trace, error_hash, build_version, environment, severity, status, occurrence_count, created_by
|
||||
) VALUES
|
||||
(1, 'myapp', 'bug', 'NullReferenceException in UserAuthService.cs line 42', 'Beim Login ohne gesetzte Session ist ein Unerwarteter Nullpointer-Fehler aufgetreten.', 'NullReferenceException: Object reference not set to an instance of an object.', 'at MyApp.Core.UserAuthService.ValidateToken(String token) in UserAuthService.cs:line 42\nat MyApp.Controllers.AuthController.Login() in AuthController.cs:line 18', 'e2c918a514d89a42f', 'v1.4.2-dev', 'development', 'high', 'open', 3, 'agent:dev-monitor-01'),
|
||||
(2, 'polytrader', 'bug', 'Database Connection Timeout on Order Execution', 'Unter hoher Last bricht die Verbindung zur MariaDB nach 30s ab.', 'SQLSTATE[HY000] [2002] Connection timed out', 'PDOException: SQLSTATE[HY000] [2002] Connection timed out at Db.php:24', 'f9a2b881c1092837d', 'v2.0.1', 'production', 'critical', 'in_progress', 14, 'agent:prod-watchdog'),
|
||||
(3, 'myapp', 'feature_request', 'Unterstützung für TOTP 2FA Login erzwingen', 'KI-Agent empfiehlt die Hinzufügung einer erzwungenen Zwei-Faktor-Authentifizierung für Administrator-Konten.', NULL, NULL, NULL, 'v1.5.0-roadmap', 'development', 'medium', 'open', 1, 'agent:security-agent');
|
||||
|
||||
-- Seed Sample Comments
|
||||
INSERT INTO bugtracker_comments (
|
||||
item_id, author, comment, action_taken, created_at
|
||||
) VALUES
|
||||
(1, 'agent:code-fixer-01', 'Log-Analyse gestartet. Der Fehler tritt auf, wenn $_SESSION["dc_user_id"] nicht gesetzt ist.', 'investigated', NOW()),
|
||||
(2, 'agent:db-optimizer', 'Max Connection Limit in config.php auf 100 erhöht. Connection Pool wird beobachtet.', 'added_hint', NOW());
|
||||
+96
-1
@@ -25,6 +25,11 @@ DROP TABLE IF EXISTS watchdog_monitors;
|
||||
-- UpdateService Module Tables
|
||||
DROP TABLE IF EXISTS updateservice_releases;
|
||||
|
||||
-- Bugtracker & Token Hierarchy Tables
|
||||
DROP TABLE IF EXISTS bugtracker_comments;
|
||||
DROP TABLE IF EXISTS bugtracker_items;
|
||||
DROP TABLE IF EXISTS dc_tokens;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 1. Core Platform Tables
|
||||
@@ -191,12 +196,16 @@ CREATE TABLE updateservice_releases (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
product_slug VARCHAR(64) NOT NULL,
|
||||
version VARCHAR(32) NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL DEFAULT 'prod',
|
||||
release_notes TEXT NULL,
|
||||
download_url VARCHAR(255) NOT NULL,
|
||||
sha256_hash VARCHAR(64) NULL,
|
||||
git_commit VARCHAR(64) NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
manifest_json JSON NULL,
|
||||
is_critical TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_prod_ver (product_slug, version)
|
||||
UNIQUE KEY uq_prod_ver_chan (product_slug, version, channel)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Seed default cron jobs for Watchdog
|
||||
@@ -265,3 +274,89 @@ INSERT INTO updateservice_releases (product_slug, version, release_notes, downlo
|
||||
('myapp', '1.1.0', 'Fehlerbehebungen und Performance-Optimierung', 'https://dc.mhdf.de/downloads/myapp-1.1.0.zip', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', 0),
|
||||
('polytrader', '2.0.1', 'Kritisches Sicherheits-Update für Handelsverbindungen', 'https://dc.mhdf.de/downloads/polytrader-2.0.1.zip', '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8', 1)
|
||||
ON DUPLICATE KEY UPDATE release_notes = VALUES(release_notes);
|
||||
|
||||
-- 5. Core Token Hierarchy & Bugtracker Tables
|
||||
CREATE TABLE dc_tokens (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
parent_token_id VARCHAR(64) NULL,
|
||||
token_hash VARCHAR(128) NOT NULL,
|
||||
raw_token VARCHAR(128) NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
project_slug VARCHAR(64) NULL,
|
||||
license_key CHAR(29) NULL,
|
||||
owner_type ENUM('license', 'project', 'host', 'dev_agent', 'custom') NOT NULL DEFAULT 'custom',
|
||||
owner_identity VARCHAR(190) NULL,
|
||||
type ENUM('master', 'sub') NOT NULL DEFAULT 'sub',
|
||||
scopes JSON NOT NULL,
|
||||
environment ENUM('production', 'development', 'all') NOT NULL DEFAULT 'all',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (parent_token_id) REFERENCES dc_tokens(token_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE bugtracker_items (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT NULL,
|
||||
project_slug VARCHAR(64) NOT NULL,
|
||||
type ENUM('bug', 'feature_request') NOT NULL DEFAULT 'bug',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
stack_trace TEXT NULL,
|
||||
error_hash VARCHAR(64) NULL,
|
||||
build_version VARCHAR(64) NULL,
|
||||
environment ENUM('production', 'development', 'staging', 'testing') NOT NULL DEFAULT 'production',
|
||||
severity ENUM('low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium',
|
||||
status ENUM('open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected') NOT NULL DEFAULT 'open',
|
||||
occurrence_count INT NOT NULL DEFAULT 1,
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME NULL,
|
||||
resolved_in_build VARCHAR(64) NULL,
|
||||
resolution_notes TEXT NULL,
|
||||
created_by VARCHAR(100) NOT NULL DEFAULT 'agent',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY ix_bt_proj_env (project_slug, environment, type, status),
|
||||
KEY ix_bt_hash (error_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE bugtracker_comments (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
item_id BIGINT NOT NULL,
|
||||
author VARCHAR(100) NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
action_taken VARCHAR(64) NULL,
|
||||
meta_json JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (item_id) REFERENCES bugtracker_items(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Seed Initial Tokens
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name, project_slug, owner_type, owner_identity, type, scopes, environment
|
||||
) VALUES
|
||||
('tok_m_myapp_dev', NULL, '5a38a7c29e64e526c71c4c16a695123d6874e0d9bf0d768132049e8fa65e10aa', 'dc_master_myapp_dev_agent_001', 'MyApp Dev Workstation Master Key', 'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'master', '["bugtracker:report", "bugtracker:manage", "watchdog:ping", "updateservice:read"]', 'all'),
|
||||
('tok_s_myapp_agent_sub1', 'tok_m_myapp_dev', '7c7bc0a5d4d3856b3e9447477c133a8a3a0e67617ab6f6d0a793a388b14a27bc', 'dc_sub_myapp_agent_live_001', 'Dev Agent Auto-Provisioned Token', 'myapp', 'dev_agent', 'DEV-WORKSTATION-01', 'sub', '["bugtracker:report", "bugtracker:manage"]', 'development')
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
||||
|
||||
-- Seed Initial Bugtracker Items
|
||||
INSERT INTO bugtracker_items (
|
||||
id, project_slug, type, title, description, error_message, stack_trace, error_hash, build_version, environment, severity, status, occurrence_count, created_by
|
||||
) VALUES
|
||||
(1, 'myapp', 'bug', 'NullReferenceException in UserAuthService.cs line 42', 'Beim Login ohne gesetzte Session ist ein Unerwarteter Nullpointer-Fehler aufgetreten.', 'NullReferenceException: Object reference not set to an instance of an object.', 'at MyApp.Core.UserAuthService.ValidateToken(String token) in UserAuthService.cs:line 42\nat MyApp.Controllers.AuthController.Login() in AuthController.cs:line 18', 'e2c918a514d89a42f', 'v1.4.2-dev', 'development', 'high', 'open', 3, 'agent:dev-monitor-01'),
|
||||
(2, 'polytrader', 'bug', 'Database Connection Timeout on Order Execution', 'Unter hoher Last bricht die Verbindung zur MariaDB nach 30s ab.', 'SQLSTATE[HY000] [2002] Connection timed out', 'PDOException: SQLSTATE[HY000] [2002] Connection timed out at Db.php:24', 'f9a2b881c1092837d', 'v2.0.1', 'production', 'critical', 'in_progress', 14, 'agent:prod-watchdog'),
|
||||
(3, 'myapp', 'feature_request', 'Unterstützung für TOTP 2FA Login erzwingen', 'KI-Agent empfiehlt die Hinzufügung einer erzwungenen Zwei-Faktor-Authentifizierung für Administrator-Konten.', NULL, NULL, NULL, 'v1.5.0-roadmap', 'development', 'medium', 'open', 1, 'agent:security-agent')
|
||||
ON DUPLICATE KEY UPDATE title = VALUES(title);
|
||||
|
||||
-- Seed Initial Comments
|
||||
INSERT INTO bugtracker_comments (
|
||||
item_id, author, comment, action_taken, created_at
|
||||
) VALUES
|
||||
(1, 'agent:code-fixer-01', 'Log-Analyse gestartet. Der Fehler tritt auf, wenn $_SESSION["dc_user_id"] nicht gesetzt ist.', 'investigated', NOW()),
|
||||
(2, 'agent:db-optimizer', 'Max Connection Limit in config.php auf 100 erhöht. Connection Pool wird beobachtet.', 'added_hint', NOW())
|
||||
ON DUPLICATE KEY UPDATE comment = VALUES(comment);
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
class TokenManager
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Master Token.
|
||||
*/
|
||||
public function createMasterToken(
|
||||
string $name,
|
||||
?string $projectSlug = null,
|
||||
?string $licenseKey = null,
|
||||
string $ownerType = 'custom',
|
||||
?string $ownerIdentity = null,
|
||||
array $scopes = ['*'],
|
||||
string $environment = 'all'
|
||||
): array {
|
||||
$tokenId = 'tok_m_' . bin2hex(random_bytes(8));
|
||||
$rawToken = 'dc_master_' . bin2hex(random_bytes(20));
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name,
|
||||
project_slug, license_key, owner_type, owner_identity,
|
||||
type, scopes, environment, created_at
|
||||
) VALUES (
|
||||
:id, NULL, :hash, :raw, :name,
|
||||
:proj, :lic, :type, :identity,
|
||||
"master", :scopes, :env, NOW()
|
||||
)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
':id' => $tokenId,
|
||||
':hash' => $tokenHash,
|
||||
':raw' => $rawToken,
|
||||
':name' => $name,
|
||||
':proj' => !empty($projectSlug) ? $projectSlug : null,
|
||||
':lic' => !empty($licenseKey) ? $licenseKey : null,
|
||||
':type' => in_array($ownerType, ['license', 'project', 'host', 'dev_agent', 'custom']) ? $ownerType : 'custom',
|
||||
':identity' => !empty($ownerIdentity) ? $ownerIdentity : null,
|
||||
':scopes' => json_encode(!empty($scopes) ? $scopes : ['*']),
|
||||
':env' => in_array($environment, ['production', 'development', 'all']) ? $environment : 'all',
|
||||
]);
|
||||
|
||||
return [
|
||||
'token_id' => $tokenId,
|
||||
'raw_token' => $rawToken,
|
||||
'name' => $name,
|
||||
'type' => 'master',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a Sub-Token using a Master-Token.
|
||||
*/
|
||||
public function provisionSubToken(
|
||||
string $rawMasterToken,
|
||||
string $name,
|
||||
?string $instanceIdentity = null,
|
||||
array $requestedScopes = [],
|
||||
string $environment = 'all'
|
||||
): array {
|
||||
$masterHash = hash('sha256', $rawMasterToken);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM dc_tokens
|
||||
WHERE (token_hash = :hash OR raw_token = :raw)
|
||||
AND type = "master"
|
||||
AND revoked = 0
|
||||
');
|
||||
$stmt->execute([':hash' => $masterHash, ':raw' => $rawMasterToken]);
|
||||
$master = $stmt->fetch();
|
||||
|
||||
if (!$master) {
|
||||
throw new \InvalidArgumentException('Invalid or revoked Master Token.');
|
||||
}
|
||||
|
||||
$masterScopes = json_decode($master['scopes'], true) ?: ['*'];
|
||||
|
||||
// Determine effective scopes
|
||||
$effectiveScopes = [];
|
||||
if (in_array('*', $masterScopes)) {
|
||||
$effectiveScopes = !empty($requestedScopes) ? $requestedScopes : ['*'];
|
||||
} else {
|
||||
if (empty($requestedScopes)) {
|
||||
$effectiveScopes = $masterScopes;
|
||||
} else {
|
||||
$effectiveScopes = array_intersect($requestedScopes, $masterScopes);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($effectiveScopes)) {
|
||||
throw new \InvalidArgumentException('Requested scopes are not allowed by this Master Token.');
|
||||
}
|
||||
|
||||
// Determine effective environment
|
||||
$effectiveEnv = $environment;
|
||||
if ($master['environment'] !== 'all') {
|
||||
$effectiveEnv = $master['environment'];
|
||||
}
|
||||
|
||||
$subTokenId = 'tok_s_' . bin2hex(random_bytes(8));
|
||||
$rawSubToken = 'dc_sub_' . bin2hex(random_bytes(20));
|
||||
$subHash = hash('sha256', $rawSubToken);
|
||||
|
||||
$ins = $this->db->prepare('
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name,
|
||||
project_slug, license_key, owner_type, owner_identity,
|
||||
type, scopes, environment, created_at
|
||||
) VALUES (
|
||||
:id, :parent_id, :hash, :raw, :name,
|
||||
:proj, :lic, :owner_type, :identity,
|
||||
"sub", :scopes, :env, NOW()
|
||||
)
|
||||
');
|
||||
|
||||
$ins->execute([
|
||||
':id' => $subTokenId,
|
||||
':parent_id' => $master['token_id'],
|
||||
':hash' => $subHash,
|
||||
':raw' => $rawSubToken,
|
||||
':name' => $name,
|
||||
':proj' => $master['project_slug'],
|
||||
':lic' => $master['license_key'],
|
||||
':owner_type'=> $master['owner_type'],
|
||||
':identity' => !empty($instanceIdentity) ? $instanceIdentity : $master['owner_identity'],
|
||||
':scopes' => json_encode(array_values($effectiveScopes)),
|
||||
':env' => $effectiveEnv,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token_id' => $subTokenId,
|
||||
'raw_token' => $rawSubToken,
|
||||
'name' => $name,
|
||||
'scopes' => array_values($effectiveScopes),
|
||||
'environment'=> $effectiveEnv,
|
||||
'type' => 'sub',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate any Token (Master or Sub) and check cascading revocation of parent tokens.
|
||||
*/
|
||||
public function validateToken(string $rawToken, ?string $requiredScope = null, ?string $environment = null): ?array
|
||||
{
|
||||
$hash = hash('sha256', $rawToken);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT t.*, p.revoked as parent_revoked
|
||||
FROM dc_tokens t
|
||||
LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id
|
||||
WHERE (t.token_hash = :hash OR t.raw_token = :raw)
|
||||
AND t.revoked = 0
|
||||
');
|
||||
$stmt->execute([':hash' => $hash, ':raw' => $rawToken]);
|
||||
$token = $stmt->fetch();
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cascading Revocation Check
|
||||
if ($token['type'] === 'sub' && !empty($token['parent_token_id']) && (int)$token['parent_revoked'] === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scope Check
|
||||
if ($requiredScope !== null) {
|
||||
$scopes = json_decode($token['scopes'], true) ?: [];
|
||||
if (!in_array('*', $scopes) && !in_array($requiredScope, $scopes)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Environment Check
|
||||
if ($environment !== null && $token['environment'] !== 'all' && $token['environment'] !== $environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update Last Used Timestamp
|
||||
$upd = $this->db->prepare('UPDATE dc_tokens SET last_used_at = NOW() WHERE id = :id');
|
||||
$upd->execute([':id' => $token['id']]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a Token (Master or Sub). If Master, cascade revokes all child Sub-Tokens via DB foreign key or query.
|
||||
*/
|
||||
public function revokeToken(string $tokenId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('UPDATE dc_tokens SET revoked = 1 WHERE token_id = :id OR parent_token_id = :id');
|
||||
return $stmt->execute([':id' => $tokenId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Master Tokens with child count.
|
||||
*/
|
||||
public function getAllMasterTokens(): array
|
||||
{
|
||||
$stmt = $this->db->query('
|
||||
SELECT m.*, COUNT(s.id) as sub_token_count
|
||||
FROM dc_tokens m
|
||||
LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id
|
||||
WHERE m.type = "master"
|
||||
GROUP BY m.id
|
||||
ORDER BY m.created_at DESC
|
||||
');
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Tokens (Master & Sub).
|
||||
*/
|
||||
public function getAllTokens(): array
|
||||
{
|
||||
$stmt = $this->db->query('SELECT * FROM dc_tokens ORDER BY created_at DESC');
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace Deploymentcenter\Modules\Bugtracker;
|
||||
|
||||
use PDO;
|
||||
|
||||
class BugRepo
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a Bug or Feature Request. Automates error-hash deduplication for bugs.
|
||||
*/
|
||||
public function reportItem(array $data): array
|
||||
{
|
||||
$projectSlug = !empty($data['project_slug']) ? trim($data['project_slug']) : 'default';
|
||||
$type = (isset($data['type']) && $data['type'] === 'feature_request') ? 'feature_request' : 'bug';
|
||||
$title = !empty($data['title']) ? trim($data['title']) : ($type === 'bug' ? 'Unhandled Exception' : 'New Feature Request');
|
||||
$description = $data['description'] ?? null;
|
||||
$errorMessage = $data['error_message'] ?? null;
|
||||
$stackTrace = $data['stack_trace'] ?? null;
|
||||
$buildVersion = $data['build_version'] ?? 'v1.0.0';
|
||||
$environment = in_array($data['environment'] ?? '', ['production', 'development', 'staging', 'testing'])
|
||||
? $data['environment']
|
||||
: 'production';
|
||||
$severity = in_array($data['severity'] ?? '', ['low', 'medium', 'high', 'critical'])
|
||||
? $data['severity']
|
||||
: 'medium';
|
||||
$createdBy = !empty($data['created_by']) ? trim($data['created_by']) : 'agent';
|
||||
|
||||
// Find associated project_id from dc_projects
|
||||
$projStmt = $this->db->prepare('SELECT id FROM dc_projects WHERE slug = :slug');
|
||||
$projStmt->execute([':slug' => $projectSlug]);
|
||||
$projectId = $projStmt->fetchColumn() ?: null;
|
||||
|
||||
// Deduplication logic for Bugs
|
||||
$errorHash = null;
|
||||
if ($type === 'bug') {
|
||||
$hashInput = $projectSlug . '|' . ($errorMessage ?: $title) . '|' . substr($stackTrace ?: '', 0, 200) . '|' . $environment;
|
||||
$errorHash = substr(hash('sha256', $hashInput), 0, 24);
|
||||
|
||||
$existingStmt = $this->db->prepare('
|
||||
SELECT id, occurrence_count
|
||||
FROM bugtracker_items
|
||||
WHERE error_hash = :hash
|
||||
AND environment = :env
|
||||
AND status IN ("open", "in_progress", "planned")
|
||||
LIMIT 1
|
||||
');
|
||||
$existingStmt->execute([':hash' => $errorHash, ':env' => $environment]);
|
||||
$existing = $existingStmt->fetch();
|
||||
|
||||
if ($existing) {
|
||||
$newCount = (int)$existing['occurrence_count'] + 1;
|
||||
$upd = $this->db->prepare('
|
||||
UPDATE bugtracker_items
|
||||
SET occurrence_count = :count, last_seen_at = NOW()
|
||||
WHERE id = :id
|
||||
');
|
||||
$upd->execute([':count' => $newCount, ':id' => $existing['id']]);
|
||||
|
||||
return [
|
||||
'id' => (int)$existing['id'],
|
||||
'is_new' => false,
|
||||
'occurrence_count' => $newCount,
|
||||
'error_hash' => $errorHash,
|
||||
'type' => $type,
|
||||
'environment' => $environment,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$ins = $this->db->prepare('
|
||||
INSERT INTO bugtracker_items (
|
||||
project_id, project_slug, type, title, description,
|
||||
error_message, stack_trace, error_hash, build_version,
|
||||
environment, severity, status, occurrence_count,
|
||||
first_seen_at, last_seen_at, created_by, created_at
|
||||
) VALUES (
|
||||
:pid, :slug, :type, :title, :desc,
|
||||
:err, :trace, :hash, :build,
|
||||
:env, :sev, "open", 1,
|
||||
NOW(), NOW(), :created_by, NOW()
|
||||
)
|
||||
');
|
||||
|
||||
$ins->execute([
|
||||
':pid' => $projectId,
|
||||
':slug' => $projectSlug,
|
||||
':type' => $type,
|
||||
':title' => $title,
|
||||
':desc' => $description,
|
||||
':err' => $errorMessage,
|
||||
':trace' => $stackTrace,
|
||||
':hash' => $errorHash,
|
||||
':build' => $buildVersion,
|
||||
':env' => $environment,
|
||||
':sev' => $severity,
|
||||
':created_by' => $createdBy,
|
||||
]);
|
||||
|
||||
$newItemId = (int)$this->db->lastInsertId();
|
||||
|
||||
// Initial comment log
|
||||
$this->addComment(
|
||||
$newItemId,
|
||||
$createdBy,
|
||||
$type === 'bug' ? 'Bug in System erfasst.' : 'Feature-Request eingereicht.',
|
||||
'reported'
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $newItemId,
|
||||
'is_new' => true,
|
||||
'occurrence_count' => 1,
|
||||
'error_hash' => $errorHash,
|
||||
'type' => $type,
|
||||
'environment' => $environment,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered list of Bugs & Feature Requests.
|
||||
*/
|
||||
public function getItems(array $filters = []): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filters['project_slug']) && $filters['project_slug'] !== 'all') {
|
||||
$where[] = 'project_slug = :slug';
|
||||
$params[':slug'] = $filters['project_slug'];
|
||||
}
|
||||
|
||||
if (!empty($filters['environment']) && $filters['environment'] !== 'all') {
|
||||
$where[] = 'environment = :env';
|
||||
$params[':env'] = $filters['environment'];
|
||||
}
|
||||
|
||||
if (!empty($filters['type']) && $filters['type'] !== 'all') {
|
||||
$where[] = 'type = :type';
|
||||
$params[':type'] = $filters['type'];
|
||||
}
|
||||
|
||||
if (!empty($filters['status']) && $filters['status'] !== 'all') {
|
||||
$where[] = 'status = :status';
|
||||
$params[':status'] = $filters['status'];
|
||||
}
|
||||
|
||||
if (!empty($filters['severity']) && $filters['severity'] !== 'all') {
|
||||
$where[] = 'severity = :severity';
|
||||
$params[':severity'] = $filters['severity'];
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$where[] = '(title LIKE :q OR description LIKE :q OR error_message LIKE :q)';
|
||||
$params[':q'] = '%' . trim($filters['search']) . '%';
|
||||
}
|
||||
|
||||
$sql = 'SELECT * FROM bugtracker_items';
|
||||
if (!empty($where)) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
$sql .= ' ORDER BY last_seen_at DESC, id DESC';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single Item details including complete comment history.
|
||||
*/
|
||||
public function getItemDetails(int $id): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM bugtracker_items WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$item = $stmt->fetch();
|
||||
|
||||
if (!$item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$commStmt = $this->db->prepare('SELECT * FROM bugtracker_comments WHERE item_id = :id ORDER BY created_at ASC');
|
||||
$commStmt->execute([':id' => $id]);
|
||||
$item['comments'] = $commStmt->fetchAll() ?: [];
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a diagnostic comment or timeline entry to an Item.
|
||||
*/
|
||||
public function addComment(
|
||||
int $itemId,
|
||||
string $author,
|
||||
string $comment,
|
||||
?string $actionTaken = null,
|
||||
?array $meta = null
|
||||
): array {
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO bugtracker_comments (
|
||||
item_id, author, comment, action_taken, meta_json, created_at
|
||||
) VALUES (
|
||||
:item_id, :author, :comment, :action, :meta, NOW()
|
||||
)
|
||||
');
|
||||
$stmt->execute([
|
||||
':item_id' => $itemId,
|
||||
':author' => $author,
|
||||
':comment' => $comment,
|
||||
':action' => $actionTaken,
|
||||
':meta' => !empty($meta) ? json_encode($meta) : null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int)$this->db->lastInsertId(),
|
||||
'item_id' => $itemId,
|
||||
'author' => $author,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Item status.
|
||||
*/
|
||||
public function updateStatus(int $itemId, string $status, ?string $notes = null, string $author = 'agent'): bool
|
||||
{
|
||||
$allowed = ['open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected'];
|
||||
if (!in_array($status, $allowed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('UPDATE bugtracker_items SET status = :status WHERE id = :id');
|
||||
$result = $stmt->execute([':status' => $status, ':id' => $itemId]);
|
||||
|
||||
if ($result) {
|
||||
$msg = 'Status geändert auf "' . $status . '"' . ($notes ? ': ' . $notes : '');
|
||||
$this->addComment($itemId, $author, $msg, 'status_changed');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Bug or complete a Feature Request with build details.
|
||||
*/
|
||||
public function resolveItem(
|
||||
int $itemId,
|
||||
string $resolvedInBuild,
|
||||
?string $resolutionNotes = null,
|
||||
string $author = 'agent'
|
||||
): bool {
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE bugtracker_items
|
||||
SET status = "resolved",
|
||||
resolved_in_build = :build,
|
||||
resolution_notes = :notes,
|
||||
resolved_at = NOW()
|
||||
WHERE id = :id
|
||||
');
|
||||
$result = $stmt->execute([
|
||||
':build' => $resolvedInBuild,
|
||||
':notes' => $resolutionNotes,
|
||||
':id' => $itemId,
|
||||
]);
|
||||
|
||||
if ($result) {
|
||||
$msg = 'Als gelöst/umgesetzt markiert in Build "' . $resolvedInBuild . '"' . ($resolutionNotes ? '. Note: ' . $resolutionNotes : '');
|
||||
$this->addComment($itemId, $author, $msg, 'marked_resolved');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary stats for the dashboard.
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
$stats = [
|
||||
'open_bugs_prod' => 0,
|
||||
'open_bugs_dev' => 0,
|
||||
'open_features' => 0,
|
||||
'resolved_total' => 0,
|
||||
'critical_bugs' => 0,
|
||||
];
|
||||
|
||||
$res = $this->db->query('
|
||||
SELECT
|
||||
SUM(CASE WHEN type = "bug" AND environment = "production" AND status IN ("open", "in_progress") THEN 1 ELSE 0 END) as open_bugs_prod,
|
||||
SUM(CASE WHEN type = "bug" AND environment = "development" AND status IN ("open", "in_progress") THEN 1 ELSE 0 END) as open_bugs_dev,
|
||||
SUM(CASE WHEN type = "feature_request" AND status IN ("open", "planned", "in_progress") THEN 1 ELSE 0 END) as open_features,
|
||||
SUM(CASE WHEN status = "resolved" THEN 1 ELSE 0 END) as resolved_total,
|
||||
SUM(CASE WHEN type = "bug" AND severity = "critical" AND status IN ("open", "in_progress") THEN 1 ELSE 0 END) as critical_bugs
|
||||
FROM bugtracker_items
|
||||
')->fetch();
|
||||
|
||||
if ($res) {
|
||||
$stats['open_bugs_prod'] = (int)($res['open_bugs_prod'] ?? 0);
|
||||
$stats['open_bugs_dev'] = (int)($res['open_bugs_dev'] ?? 0);
|
||||
$stats['open_features'] = (int)($res['open_features'] ?? 0);
|
||||
$stats['resolved_total'] = (int)($res['resolved_total'] ?? 0);
|
||||
$stats['critical_bugs'] = (int)($res['critical_bugs'] ?? 0);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,14 @@ class UpdateManager
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public function checkUpdate(string $productSlug, string $currentVersion): ?array
|
||||
public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM updateservice_releases
|
||||
WHERE product_slug = :slug AND version > :ver
|
||||
WHERE product_slug = :slug AND channel = :channel AND version > :ver
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
');
|
||||
$stmt->execute([':slug' => $productSlug, ':ver' => $currentVersion]);
|
||||
$stmt->execute([':slug' => $productSlug, ':channel' => $channel, ':ver' => $currentVersion]);
|
||||
$latest = $stmt->fetch();
|
||||
|
||||
return $latest ?: null;
|
||||
@@ -29,38 +29,55 @@ class UpdateManager
|
||||
public function addRelease(
|
||||
string $productSlug,
|
||||
string $version,
|
||||
?string $releaseNotes,
|
||||
string $downloadUrl,
|
||||
?string $sha256Hash,
|
||||
string $channel = 'prod',
|
||||
?string $releaseNotes = null,
|
||||
string $downloadUrl = '',
|
||||
?string $sha256Hash = null,
|
||||
?string $gitCommit = null,
|
||||
int $sizeBytes = 0,
|
||||
?string $manifestJson = null,
|
||||
bool $isCritical = false
|
||||
): bool {
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO updateservice_releases (
|
||||
product_slug, version, release_notes, download_url, sha256_hash, is_critical
|
||||
product_slug, version, channel, release_notes, download_url, sha256_hash, git_commit, size_bytes, manifest_json, is_critical
|
||||
) VALUES (
|
||||
:slug, :version, :notes, :url, :hash, :critical
|
||||
:slug, :version, :channel, :notes, :url, :hash, :git, :size, :manifest, :critical
|
||||
) ON DUPLICATE KEY UPDATE
|
||||
release_notes = VALUES(release_notes),
|
||||
download_url = VALUES(download_url),
|
||||
sha256_hash = VALUES(sha256_hash),
|
||||
git_commit = VALUES(git_commit),
|
||||
size_bytes = VALUES(size_bytes),
|
||||
manifest_json = VALUES(manifest_json),
|
||||
is_critical = VALUES(is_critical)
|
||||
');
|
||||
|
||||
return $stmt->execute([
|
||||
':slug' => $productSlug,
|
||||
':version' => $version,
|
||||
':channel' => $channel,
|
||||
':notes' => $releaseNotes,
|
||||
':url' => $downloadUrl,
|
||||
':hash' => $sha256Hash,
|
||||
':git' => $gitCommit,
|
||||
':size' => $sizeBytes,
|
||||
':manifest' => $manifestJson,
|
||||
':critical' => $isCritical ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getReleases(?string $productSlug = null): array
|
||||
public function getReleases(?string $productSlug = null, ?string $channel = null): array
|
||||
{
|
||||
if ($productSlug) {
|
||||
if ($productSlug && $channel) {
|
||||
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug AND channel = :channel ORDER BY created_at DESC');
|
||||
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
|
||||
} elseif ($productSlug) {
|
||||
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug ORDER BY created_at DESC');
|
||||
$stmt->execute([':slug' => $productSlug]);
|
||||
} elseif ($channel) {
|
||||
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE channel = :channel ORDER BY created_at DESC');
|
||||
$stmt->execute([':channel' => $channel]);
|
||||
} else {
|
||||
$stmt = $this->db->query('SELECT * FROM updateservice_releases ORDER BY created_at DESC');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user