Initial commit
This commit is contained in:
commit
77d35ff965
51 changed files with 5591 additions and 0 deletions
287
Pages/Azure/Container.cshtml
Normal file
287
Pages/Azure/Container.cshtml
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Azure.ContainerModel
|
||||
@{
|
||||
ViewData["Title"] = $"Container: {Model.Name}";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<div class="breadcrumb">
|
||||
<a href="/Azure">Azure Storage</a>
|
||||
<span class="separator">/</span>
|
||||
@if (string.IsNullOrEmpty(Model.Prefix))
|
||||
{
|
||||
<span>@Model.Name</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/Azure/Container?name=@Model.Name">@Model.Name</a>
|
||||
var parts = Model.Prefix.Split('/').Where(p => !string.IsNullOrEmpty(p)).ToList();
|
||||
var currentPath = "";
|
||||
foreach (var part in parts)
|
||||
{
|
||||
currentPath += part + "/";
|
||||
<span class="separator">/</span>
|
||||
if (currentPath == Model.Prefix + "/")
|
||||
{
|
||||
<span>@part</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/Azure/Container?name=@Model.Name&prefix=@currentPath">@part</a>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<h1 class="page-title">@Model.Name</h1>
|
||||
<p class="page-subtitle">@Model.Details.BlobCount filer · @FormatBytes(Model.Details.TotalSize)</p>
|
||||
</div>
|
||||
|
||||
@if (!Model.IsConnected)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-danger">Kan ikke forbinde til Azure Storage</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Directories -->
|
||||
@if (Model.Details.Prefixes.Count > 0)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header">Mapper</div>
|
||||
<div class="folder-grid">
|
||||
@foreach (var prefix in Model.Details.Prefixes)
|
||||
{
|
||||
var folderName = prefix.Split('/').Last(p => !string.IsNullOrEmpty(p));
|
||||
<a href="/Azure/Container?name=@Model.Name&prefix=@(prefix)/" class="folder-item">
|
||||
<span class="folder-icon">📁</span>
|
||||
<span class="folder-name">@folderName</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Files -->
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
Filer
|
||||
<span class="header-meta">@Model.Details.Blobs.Count filer</span>
|
||||
</div>
|
||||
@if (Model.Details.Blobs.Count == 0)
|
||||
{
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Ingen filer i denne mappe</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navn</th>
|
||||
<th>Størrelse</th>
|
||||
<th>Type</th>
|
||||
<th>Tier</th>
|
||||
<th>Ændret</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var blob in Model.Details.Blobs)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<span class="file-icon">@GetFileIcon(blob.Name)</span>
|
||||
<code class="blob-name" title="@blob.Name">@blob.FileName</code>
|
||||
</td>
|
||||
<td>@FormatBytes(blob.Size)</td>
|
||||
<td><span class="badge">@(blob.ContentType ?? "-")</span></td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(blob.AccessTier))
|
||||
{
|
||||
<span class="badge badge-tier @GetTierClass(blob.AccessTier)">@blob.AccessTier</span>
|
||||
}
|
||||
</td>
|
||||
<td>@FormatTimeAgo(blob.LastModified)</td>
|
||||
<td class="actions">
|
||||
<a href="/Azure/Container?name=@Model.Name&blob=@blob.Name&handler=Download"
|
||||
class="btn btn-sm" title="Download">⬇️</a>
|
||||
<form method="post" asp-page-handler="Delete" style="display:inline;">
|
||||
<input type="hidden" name="container" value="@Model.Name" />
|
||||
<input type="hidden" name="blob" value="@blob.Name" />
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Slet @blob.FileName?')" title="Slet">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<a href="/Azure" class="btn">← Tilbage til oversigt</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<style>
|
||||
.breadcrumb {
|
||||
font-size: 12px;
|
||||
color: var(--muted-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb .separator {
|
||||
margin: 0 6px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.folder-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.blob-name {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
float: right;
|
||||
font-weight: normal;
|
||||
font-size: 11px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.badge-tier {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.badge-tier.hot {
|
||||
background: rgba(240, 165, 0, 0.2);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.badge-tier.cool {
|
||||
background: rgba(0, 123, 255, 0.2);
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.badge-tier.archive {
|
||||
background: rgba(108, 117, 125, 0.2);
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border-color: var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@functions {
|
||||
string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes == 0) return "0 B";
|
||||
var sizes = new[] { "B", "KB", "MB", "GB", "TB" };
|
||||
var i = (int)Math.Floor(Math.Log(bytes) / Math.Log(1024));
|
||||
return $"{Math.Round(bytes / Math.Pow(1024, i), 1)} {sizes[i]}";
|
||||
}
|
||||
|
||||
string FormatTimeAgo(DateTimeOffset? time)
|
||||
{
|
||||
if (!time.HasValue) return "-";
|
||||
var diff = DateTimeOffset.Now - time.Value;
|
||||
if (diff.TotalMinutes < 1) return "lige nu";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m siden";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}t siden";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d siden";
|
||||
return time.Value.ToString("dd/MM HH:mm");
|
||||
}
|
||||
|
||||
string GetFileIcon(string name)
|
||||
{
|
||||
if (name.EndsWith(".tar.gz")) return "📦";
|
||||
if (name.EndsWith(".gz") || name.EndsWith(".zip") || name.EndsWith(".7z")) return "📦";
|
||||
if (name.EndsWith(".sql")) return "🐘";
|
||||
if (name.EndsWith(".bak")) return "💾";
|
||||
if (name.EndsWith(".log")) return "📜";
|
||||
if (name.EndsWith(".json")) return "📋";
|
||||
if (name.EndsWith(".xml")) return "📄";
|
||||
return "📄";
|
||||
}
|
||||
|
||||
string GetTierClass(string tier)
|
||||
{
|
||||
var t = tier.ToLower();
|
||||
if (t == "hot") return "hot";
|
||||
if (t == "cool") return "cool";
|
||||
if (t == "archive") return "archive";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
57
Pages/Azure/Container.cshtml.cs
Normal file
57
Pages/Azure/Container.cshtml.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Models;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Azure;
|
||||
|
||||
public class ContainerModel : PageModel
|
||||
{
|
||||
private readonly AzureStorageService _azureService;
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? Prefix { get; set; }
|
||||
|
||||
public ContainerDetails Details { get; set; } = new();
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
public ContainerModel(AzureStorageService azureService)
|
||||
{
|
||||
_azureService = azureService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Name))
|
||||
{
|
||||
return RedirectToPage("/Azure/Index");
|
||||
}
|
||||
|
||||
IsConnected = await _azureService.TestConnectionAsync();
|
||||
if (IsConnected)
|
||||
{
|
||||
Details = await _azureService.GetContainerDetailsAsync(Name, Prefix, limit: 200);
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(string container, string blob)
|
||||
{
|
||||
await _azureService.DeleteBlobAsync(container, blob);
|
||||
return RedirectToPage(new { name = container, prefix = Prefix });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetDownloadAsync(string container, string blob)
|
||||
{
|
||||
var url = await _azureService.GetBlobDownloadUrlAsync(container, blob);
|
||||
if (url == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Redirect(url);
|
||||
}
|
||||
}
|
||||
235
Pages/Azure/Index.cshtml
Normal file
235
Pages/Azure/Index.cshtml
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Azure.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Azure Storage";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Azure Blob Storage</h1>
|
||||
<p class="page-subtitle">@Model.Dashboard.AccountName</p>
|
||||
</div>
|
||||
|
||||
@if (!Model.IsConnected)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-danger">Kan ikke forbinde til Azure Storage</p>
|
||||
<p class="text-muted">Tjek at <code>ConnectionStrings:AzureStorage</code> er konfigureret i appsettings.json</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var d = Model.Dashboard;
|
||||
|
||||
<!-- Hero Stats -->
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Status</div>
|
||||
<div class="status-value success">ONLINE</div>
|
||||
<div class="status-detail">@d.AccountName</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Containers</div>
|
||||
<div class="status-value">@d.TotalContainers</div>
|
||||
<div class="status-detail">@d.TotalBlobs blobs total</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Total Størrelse</div>
|
||||
<div class="status-value">@FormatBytes(d.TotalSize)</div>
|
||||
<div class="status-detail">Backup: @FormatBytes(d.BackupTotalSize)</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Sidste Upload</div>
|
||||
<div class="status-value @(d.LastBackupUpload.HasValue && (DateTimeOffset.Now - d.LastBackupUpload.Value).TotalHours < 24 ? "success" : "warning")">
|
||||
@FormatTimeAgo(d.LastBackupUpload)
|
||||
</div>
|
||||
<div class="status-detail">@d.BackupFileCount backup filer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid mt-2">
|
||||
<!-- Containers -->
|
||||
<div class="card">
|
||||
<div class="card-header">Containers</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navn</th>
|
||||
<th>Blobs</th>
|
||||
<th>Størrelse</th>
|
||||
<th>Ændret</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var container in d.Containers)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (container.Name.Contains("backup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<span class="container-icon">💾</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="container-icon">📦</span>
|
||||
}
|
||||
<code>@container.Name</code>
|
||||
</td>
|
||||
<td>@container.BlobCount</td>
|
||||
<td>@FormatBytes(container.TotalSize)</td>
|
||||
<td>@FormatTimeAgo(container.LastModified)</td>
|
||||
<td>
|
||||
<a href="/Azure/Container?name=@container.Name" class="btn btn-sm">Åbn</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Recent Uploads -->
|
||||
<div class="card">
|
||||
<div class="card-header">Seneste Uploads</div>
|
||||
<div class="card-body compact-list">
|
||||
@if (d.RecentBlobs.Count == 0)
|
||||
{
|
||||
<p class="text-muted">Ingen filer endnu</p>
|
||||
}
|
||||
@foreach (var blob in d.RecentBlobs)
|
||||
{
|
||||
<div class="list-item">
|
||||
<div class="item-main">
|
||||
<span class="file-icon">@GetFileIcon(blob.Name)</span>
|
||||
<code class="blob-name" title="@blob.Name">@blob.FileName</code>
|
||||
@if (!string.IsNullOrEmpty(blob.AccessTier))
|
||||
{
|
||||
<span class="badge badge-tier">@blob.AccessTier</span>
|
||||
}
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
@FormatBytes(blob.Size) · @FormatTimeAgo(blob.LastModified)
|
||||
@if (!string.IsNullOrEmpty(blob.Directory))
|
||||
{
|
||||
<text>· @blob.Directory</text>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Updated -->
|
||||
<div class="last-updated mt-2">
|
||||
Opdateret: @DateTime.Now.ToString("HH:mm:ss")
|
||||
</div>
|
||||
}
|
||||
|
||||
<style>
|
||||
.status-detail {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.container-icon, .file-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 4px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.blob-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-tier {
|
||||
background: var(--border-color);
|
||||
color: var(--muted-color);
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.success { color: var(--success-color); }
|
||||
.warning { color: var(--warning-color); }
|
||||
|
||||
@@media (max-width: 1000px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@functions {
|
||||
string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes == 0) return "0 B";
|
||||
var sizes = new[] { "B", "KB", "MB", "GB", "TB" };
|
||||
var i = (int)Math.Floor(Math.Log(bytes) / Math.Log(1024));
|
||||
return $"{Math.Round(bytes / Math.Pow(1024, i), 1)} {sizes[i]}";
|
||||
}
|
||||
|
||||
string FormatTimeAgo(DateTimeOffset? time)
|
||||
{
|
||||
if (!time.HasValue) return "-";
|
||||
var diff = DateTimeOffset.Now - time.Value;
|
||||
if (diff.TotalMinutes < 1) return "lige nu";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m siden";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}t siden";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d siden";
|
||||
return time.Value.ToString("dd/MM");
|
||||
}
|
||||
|
||||
string GetFileIcon(string name)
|
||||
{
|
||||
if (name.EndsWith(".tar.gz")) return "📦";
|
||||
if (name.EndsWith(".gz") || name.EndsWith(".zip") || name.EndsWith(".7z")) return "📦";
|
||||
if (name.EndsWith(".sql")) return "🐘";
|
||||
if (name.EndsWith(".bak")) return "💾";
|
||||
if (name.EndsWith(".log")) return "📜";
|
||||
if (name.EndsWith(".json")) return "📋";
|
||||
if (name.EndsWith(".xml")) return "📄";
|
||||
return "📄";
|
||||
}
|
||||
}
|
||||
27
Pages/Azure/Index.cshtml.cs
Normal file
27
Pages/Azure/Index.cshtml.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Models;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Azure;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly AzureStorageService _azureService;
|
||||
|
||||
public bool IsConnected { get; set; }
|
||||
public AzureStorageDashboard Dashboard { get; set; } = new();
|
||||
|
||||
public IndexModel(AzureStorageService azureService)
|
||||
{
|
||||
_azureService = azureService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsConnected = await _azureService.TestConnectionAsync();
|
||||
if (IsConnected)
|
||||
{
|
||||
Dashboard = await _azureService.GetDashboardAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
62
Pages/Caddy/Hosts.cshtml
Normal file
62
Pages/Caddy/Hosts.cshtml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Caddy.HostsModel
|
||||
@{
|
||||
ViewData["Title"] = "Caddy Hosts";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Registrerede Hosts</h1>
|
||||
<p class="page-subtitle">Alle hostnames konfigureret i Caddy</p>
|
||||
</div>
|
||||
|
||||
@if (!Model.IsRunning)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-danger">Caddy server er ikke tilgængelig</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (Model.Hosts.Count == 0)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Ingen hosts fundet</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>TLS</th>
|
||||
<th>Upstream</th>
|
||||
<th>Listen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var host in Model.Hosts)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@host.Hostname</code></td>
|
||||
<td>
|
||||
@if (host.Tls)
|
||||
{
|
||||
<span class="badge badge-success">HTTPS</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-warning">HTTP</span>
|
||||
}
|
||||
</td>
|
||||
<td><code>@(host.Upstream ?? "-")</code></td>
|
||||
<td><code>@string.Join(", ", host.Addresses)</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
27
Pages/Caddy/Hosts.cshtml.cs
Normal file
27
Pages/Caddy/Hosts.cshtml.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Models;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Caddy;
|
||||
|
||||
public class HostsModel : PageModel
|
||||
{
|
||||
private readonly CaddyService _caddyService;
|
||||
|
||||
public bool IsRunning { get; set; }
|
||||
public List<CaddyHost> Hosts { get; set; } = new();
|
||||
|
||||
public HostsModel(CaddyService caddyService)
|
||||
{
|
||||
_caddyService = caddyService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsRunning = await _caddyService.IsRunningAsync();
|
||||
if (IsRunning)
|
||||
{
|
||||
Hosts = await _caddyService.GetHostsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Pages/Caddy/Index.cshtml
Normal file
41
Pages/Caddy/Index.cshtml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Caddy.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Caddy Oversigt";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Caddy Oversigt</h1>
|
||||
<p class="page-subtitle">Status for Caddy reverse proxy server</p>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Server Status</div>
|
||||
<div class="status-value @(Model.IsRunning ? "success" : "danger")">
|
||||
@(Model.IsRunning ? "ONLINE" : "OFFLINE")
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Registrerede Hosts</div>
|
||||
<div class="status-value">@Model.HostCount</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.IsRunning && !string.IsNullOrEmpty(Model.RawConfig))
|
||||
{
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">Aktiv Konfiguration</div>
|
||||
<div class="card-body">
|
||||
<pre>@Model.RawConfig</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!Model.IsRunning)
|
||||
{
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Kan ikke forbinde til Caddy Admin API på @Model.CaddyAdminUrl</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
51
Pages/Caddy/Index.cshtml.cs
Normal file
51
Pages/Caddy/Index.cshtml.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Caddy;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly CaddyService _caddyService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public bool IsRunning { get; set; }
|
||||
public int HostCount { get; set; }
|
||||
public string? RawConfig { get; set; }
|
||||
public string CaddyAdminUrl { get; set; } = string.Empty;
|
||||
|
||||
public IndexModel(CaddyService caddyService, IConfiguration configuration)
|
||||
{
|
||||
_caddyService = caddyService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
CaddyAdminUrl = _configuration.GetValue<string>("Caddy:AdminUrl") ?? "http://localhost:2019";
|
||||
IsRunning = await _caddyService.IsRunningAsync();
|
||||
|
||||
if (IsRunning)
|
||||
{
|
||||
var hosts = await _caddyService.GetHostsAsync();
|
||||
HostCount = hosts.Count;
|
||||
RawConfig = await _caddyService.GetRawConfigAsync();
|
||||
|
||||
// Pretty print JSON
|
||||
if (!string.IsNullOrEmpty(RawConfig))
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonDoc = System.Text.Json.JsonDocument.Parse(RawConfig);
|
||||
RawConfig = System.Text.Json.JsonSerializer.Serialize(jsonDoc, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep raw config if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Pages/Error.cshtml
Normal file
26
Pages/Error.cshtml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
@page
|
||||
@model ErrorModel
|
||||
@{
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (Model.ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
27
Pages/Error.cshtml.cs
Normal file
27
Pages/Error.cshtml.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace PlanTempusAdmin.Pages;
|
||||
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class ErrorModel : PageModel
|
||||
{
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
private readonly ILogger<ErrorModel> _logger;
|
||||
|
||||
public ErrorModel(ILogger<ErrorModel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
292
Pages/Forgejo/Actions.cshtml
Normal file
292
Pages/Forgejo/Actions.cshtml
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Forgejo.ActionsModel
|
||||
@{
|
||||
ViewData["Title"] = "Forgejo Actions";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">CI/CD Actions</h1>
|
||||
<p class="page-subtitle">Workflow runs og statistik</p>
|
||||
</div>
|
||||
|
||||
@if (!Model.IsConnected)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-danger">Kan ikke forbinde til Forgejo database</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var running = Model.Runs.Count(r => r.Status == 2);
|
||||
var successful = Model.Runs.Count(r => r.Status == 3);
|
||||
var failed = Model.Runs.Count(r => r.Status == 4);
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Status</div>
|
||||
<div class="status-value @(running > 0 ? "warning" : "success")">
|
||||
@if (running > 0)
|
||||
{
|
||||
<span class="pulse">●</span> @running <text> KØRER</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>IDLE</text>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Workflows</div>
|
||||
<div class="status-value">@Model.Stats.Count</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Success Rate</div>
|
||||
<div class="status-value @(Model.Stats.Count > 0 ? (Model.Stats.Average(s => s.SuccessRate) >= 90 ? "success" : "warning") : "")">
|
||||
@(Model.Stats.Count > 0 ? Model.Stats.Average(s => s.SuccessRate).ToString("0") : "0")%
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Viste Runs</div>
|
||||
<div class="status-value">@Model.Runs.Count</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Running Now -->
|
||||
@if (running > 0)
|
||||
{
|
||||
<div class="card mt-2 running-card">
|
||||
<div class="card-header">
|
||||
<span class="pulse">●</span> Kørende Workflows
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repository</th>
|
||||
<th>Workflow</th>
|
||||
<th>Branch</th>
|
||||
<th>Trigger</th>
|
||||
<th>Startet</th>
|
||||
<th>Varighed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var run in Model.Runs.Where(r => r.Status == 2))
|
||||
{
|
||||
<tr>
|
||||
<td><code>@run.FullRepoName</code></td>
|
||||
<td>@run.WorkflowId</td>
|
||||
<td><code>@run.Ref.Replace("refs/heads/", "").Replace("refs/tags/", "tag:")</code></td>
|
||||
<td>
|
||||
<span class="badge">@run.Event</span>
|
||||
@if (!string.IsNullOrEmpty(run.TriggerUser))
|
||||
{
|
||||
<span class="text-muted">by @run.TriggerUser</span>
|
||||
}
|
||||
</td>
|
||||
<td>@run.Started?.ToString("HH:mm:ss")</td>
|
||||
<td class="warning">@FormatDuration(run.Duration)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="dashboard-grid mt-2">
|
||||
<!-- Left Column: Workflow Stats -->
|
||||
<div class="dashboard-col">
|
||||
<div class="card">
|
||||
<div class="card-header">Workflow Statistik</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Workflow</th>
|
||||
<th>Repo</th>
|
||||
<th>Runs</th>
|
||||
<th>Rate</th>
|
||||
<th>Avg. Tid</th>
|
||||
<th>Sidst</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var stat in Model.Stats.OrderByDescending(s => s.TotalRuns))
|
||||
{
|
||||
<tr>
|
||||
<td><code>@stat.WorkflowId</code></td>
|
||||
<td><code>@stat.RepoName</code></td>
|
||||
<td>
|
||||
<span class="success">@stat.Successful</span>
|
||||
@if (stat.Failed > 0)
|
||||
{
|
||||
<span class="text-danger">/ @stat.Failed</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="rate-bar">
|
||||
<div class="rate-fill @(stat.SuccessRate >= 90 ? "good" : stat.SuccessRate >= 70 ? "warn" : "bad")"
|
||||
style="width: @stat.SuccessRate.ToString("0")%"></div>
|
||||
</div>
|
||||
<span class="rate-text">@stat.SuccessRate.ToString("0")%</span>
|
||||
</td>
|
||||
<td>@(stat.AvgDurationSeconds.HasValue ? FormatDuration(TimeSpan.FromSeconds(stat.AvgDurationSeconds.Value)) : "-")</td>
|
||||
<td>@FormatTimeAgo(stat.LastRun)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Recent Runs -->
|
||||
<div class="dashboard-col">
|
||||
<div class="card">
|
||||
<div class="card-header">Seneste Workflow Runs</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Repository</th>
|
||||
<th>Workflow</th>
|
||||
<th>Event</th>
|
||||
<th>Tid</th>
|
||||
<th>Varighed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var run in Model.Runs.Take(50))
|
||||
{
|
||||
<tr class="@(run.Status == 4 ? "failed-row" : "")">
|
||||
<td>
|
||||
@switch (run.Status)
|
||||
{
|
||||
case 1:
|
||||
<span class="badge">VENTER</span>
|
||||
break;
|
||||
case 2:
|
||||
<span class="badge badge-warning">KØRER</span>
|
||||
break;
|
||||
case 3:
|
||||
<span class="badge badge-success">OK</span>
|
||||
break;
|
||||
case 4:
|
||||
<span class="badge badge-danger">FEJL</span>
|
||||
break;
|
||||
case 5:
|
||||
<span class="badge">ANNULLERET</span>
|
||||
break;
|
||||
case 6:
|
||||
<span class="badge">SKIPPED</span>
|
||||
break;
|
||||
default:
|
||||
<span class="badge">@run.StatusText</span>
|
||||
break;
|
||||
}
|
||||
</td>
|
||||
<td><code>@run.FullRepoName</code></td>
|
||||
<td>@run.WorkflowId</td>
|
||||
<td>
|
||||
<span class="badge">@run.Event</span>
|
||||
</td>
|
||||
<td>@FormatTimeAgo(run.Created)</td>
|
||||
<td>@FormatDuration(run.Duration)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<style>
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.running-card {
|
||||
border-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.running-card .card-header {
|
||||
background: linear-gradient(90deg, rgba(240, 165, 0, 0.1), transparent);
|
||||
}
|
||||
|
||||
.rate-bar {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rate-fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rate-fill.good { background: var(--success-color); }
|
||||
.rate-fill.warn { background: var(--warning-color); }
|
||||
.rate-fill.bad { background: var(--danger-color); }
|
||||
|
||||
.rate-text {
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.failed-row {
|
||||
background: rgba(220, 53, 69, 0.05);
|
||||
}
|
||||
|
||||
.success { color: var(--success-color); }
|
||||
.warning { color: var(--warning-color); }
|
||||
|
||||
@@media (max-width: 1400px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@functions {
|
||||
string FormatDuration(TimeSpan? duration)
|
||||
{
|
||||
if (!duration.HasValue) return "-";
|
||||
var d = duration.Value;
|
||||
if (d.TotalHours >= 1) return $"{(int)d.TotalHours}t {d.Minutes}m";
|
||||
if (d.TotalMinutes >= 1) return $"{(int)d.TotalMinutes}m {d.Seconds}s";
|
||||
return $"{d.Seconds}s";
|
||||
}
|
||||
|
||||
string FormatTimeAgo(DateTime? time)
|
||||
{
|
||||
if (!time.HasValue) return "-";
|
||||
var diff = DateTime.Now - time.Value;
|
||||
if (diff.TotalMinutes < 1) return "lige nu";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m siden";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}t siden";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d siden";
|
||||
return time.Value.ToString("dd/MM HH:mm");
|
||||
}
|
||||
}
|
||||
29
Pages/Forgejo/Actions.cshtml.cs
Normal file
29
Pages/Forgejo/Actions.cshtml.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Models;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Forgejo;
|
||||
|
||||
public class ActionsModel : PageModel
|
||||
{
|
||||
private readonly ForgejoService _forgejoService;
|
||||
|
||||
public bool IsConnected { get; set; }
|
||||
public List<ForgejoActionRun> Runs { get; set; } = new();
|
||||
public List<ForgejoActionStats> Stats { get; set; } = new();
|
||||
|
||||
public ActionsModel(ForgejoService forgejoService)
|
||||
{
|
||||
_forgejoService = forgejoService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsConnected = await _forgejoService.TestConnectionAsync();
|
||||
if (IsConnected)
|
||||
{
|
||||
Runs = await _forgejoService.GetAllActionRunsAsync(100);
|
||||
Stats = await _forgejoService.GetActionStatsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
475
Pages/Forgejo/Index.cshtml
Normal file
475
Pages/Forgejo/Index.cshtml
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Forgejo.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Forgejo Oversigt";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Forgejo Oversigt</h1>
|
||||
<p class="page-subtitle">Git repositories og CI/CD status</p>
|
||||
</div>
|
||||
|
||||
@if (!Model.IsConnected)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-danger">Kan ikke forbinde til Forgejo database</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var d = Model.Dashboard;
|
||||
|
||||
<!-- Hero Stats -->
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Repositories</div>
|
||||
<div class="status-value">@d.TotalRepos</div>
|
||||
<div class="status-detail">@d.PublicRepos public · @d.PrivateRepos private</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Total Størrelse</div>
|
||||
<div class="status-value">@FormatSize(d.TotalSize * 1024)</div>
|
||||
<div class="status-detail">@d.TotalStars stars · @d.TotalForks forks</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Åbne Issues/PRs</div>
|
||||
<div class="status-value">@(d.TotalOpenIssues + d.TotalOpenPRs)</div>
|
||||
<div class="status-detail">@d.TotalOpenIssues issues · @d.TotalOpenPRs PRs</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">CI Status</div>
|
||||
<div class="status-value @(d.RunningNow > 0 ? "warning" : d.SuccessRate >= 90 ? "success" : "")">
|
||||
@if (d.RunningNow > 0)
|
||||
{
|
||||
<span class="pulse">●</span> @d.RunningNow
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>@d.SuccessRate.ToString("0")%</text>
|
||||
}
|
||||
</div>
|
||||
<div class="status-detail">@d.RunsToday i dag · @d.RunsThisWeek denne uge</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Running Actions -->
|
||||
@if (d.RunningRuns.Count > 0)
|
||||
{
|
||||
<div class="card mt-2 running-card">
|
||||
<div class="card-header">
|
||||
<span class="pulse">●</span> Kørende Workflows
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repository</th>
|
||||
<th>Workflow</th>
|
||||
<th>Event</th>
|
||||
<th>Branch</th>
|
||||
<th>Startet</th>
|
||||
<th>Varighed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var run in d.RunningRuns)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@run.FullRepoName</code></td>
|
||||
<td>@run.WorkflowId</td>
|
||||
<td><span class="badge">@run.Event</span></td>
|
||||
<td><code>@run.Ref.Replace("refs/heads/", "")</code></td>
|
||||
<td>@run.Started?.ToString("HH:mm:ss")</td>
|
||||
<td>@FormatDuration(run.Duration)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="dashboard-grid mt-2">
|
||||
<!-- Left Column -->
|
||||
<div class="dashboard-col">
|
||||
<!-- Repos Overview -->
|
||||
<div class="card">
|
||||
<div class="card-header">Repository Typer</div>
|
||||
<div class="card-body">
|
||||
<div class="stat-bars">
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-label">Public</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: @(d.TotalRepos > 0 ? d.PublicRepos * 100 / d.TotalRepos : 0)%; background: var(--success-color);"></div>
|
||||
</div>
|
||||
<span class="stat-bar-value">@d.PublicRepos</span>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-label">Private</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: @(d.TotalRepos > 0 ? d.PrivateRepos * 100 / d.TotalRepos : 0)%; background: var(--accent-color);"></div>
|
||||
</div>
|
||||
<span class="stat-bar-value">@d.PrivateRepos</span>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-label">Forks</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: @(d.TotalRepos > 0 ? d.ForkedRepos * 100 / d.TotalRepos : 0)%; background: var(--warning-color);"></div>
|
||||
</div>
|
||||
<span class="stat-bar-value">@d.ForkedRepos</span>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-label">Mirrors</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: @(d.TotalRepos > 0 ? d.MirrorRepos * 100 / d.TotalRepos : 0)%; background: var(--muted-color);"></div>
|
||||
</div>
|
||||
<span class="stat-bar-value">@d.MirrorRepos</span>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-label">Archived</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: @(d.TotalRepos > 0 ? d.ArchivedRepos * 100 / d.TotalRepos : 0)%; background: var(--danger-color);"></div>
|
||||
</div>
|
||||
<span class="stat-bar-value">@d.ArchivedRepos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Largest Repos -->
|
||||
<div class="card">
|
||||
<div class="card-header">Største Repositories</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repository</th>
|
||||
<th>Størrelse</th>
|
||||
<th>Issues</th>
|
||||
<th>PRs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var repo in d.LargestRepos)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<code>@repo.FullName</code>
|
||||
@if (repo.IsPrivate) { <span class="badge">privat</span> }
|
||||
</td>
|
||||
<td>@repo.SizeFormatted</td>
|
||||
<td>@repo.OpenIssues</td>
|
||||
<td>@repo.OpenPulls</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Recently Updated -->
|
||||
<div class="card">
|
||||
<div class="card-header">Senest Opdaterede</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repository</th>
|
||||
<th>Opdateret</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var repo in d.RecentlyUpdated)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<code>@repo.FullName</code>
|
||||
@if (repo.IsPrivate) { <span class="badge">privat</span> }
|
||||
</td>
|
||||
<td>@FormatTimeAgo(repo.UpdatedAt)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="dashboard-col">
|
||||
<!-- CI Stats -->
|
||||
<div class="card">
|
||||
<div class="card-header">CI/CD Statistik</div>
|
||||
<div class="card-body">
|
||||
<div class="ci-stats">
|
||||
<div class="ci-stat">
|
||||
<div class="ci-stat-value success">@d.SuccessfulRuns</div>
|
||||
<div class="ci-stat-label">Success</div>
|
||||
</div>
|
||||
<div class="ci-stat">
|
||||
<div class="ci-stat-value danger">@d.FailedRunsCount</div>
|
||||
<div class="ci-stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="ci-stat">
|
||||
<div class="ci-stat-value">@d.TotalRuns</div>
|
||||
<div class="ci-stat-label">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-rate-bar mt-1">
|
||||
<div class="ci-rate-success" style="width: @d.SuccessRate.ToString("0")%"></div>
|
||||
</div>
|
||||
<div class="ci-rate-label">@d.SuccessRate.ToString("0.0")% success rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Runs -->
|
||||
<div class="card">
|
||||
<div class="card-header">Seneste Workflow Runs</div>
|
||||
<div class="card-body compact-list">
|
||||
@if (d.RecentRuns.Count == 0)
|
||||
{
|
||||
<p class="text-muted">Ingen workflow runs</p>
|
||||
}
|
||||
@foreach (var run in d.RecentRuns)
|
||||
{
|
||||
<div class="list-item">
|
||||
<div class="item-main">
|
||||
@if (run.Status == 3)
|
||||
{
|
||||
<span class="badge badge-success">OK</span>
|
||||
}
|
||||
else if (run.Status == 4)
|
||||
{
|
||||
<span class="badge badge-danger">FEJL</span>
|
||||
}
|
||||
else if (run.Status == 2)
|
||||
{
|
||||
<span class="badge badge-warning">KØRER</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge">@run.StatusText</span>
|
||||
}
|
||||
<code>@run.FullRepoName</code>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
@run.WorkflowId · @run.Event · @FormatTimeAgo(run.Created)
|
||||
@if (run.Duration.HasValue)
|
||||
{
|
||||
<text>· @FormatDuration(run.Duration)</text>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed Runs -->
|
||||
@if (d.FailedRuns.Count > 0)
|
||||
{
|
||||
<div class="card error-card">
|
||||
<div class="card-header">Fejlede Workflows</div>
|
||||
<div class="card-body compact-list">
|
||||
@foreach (var run in d.FailedRuns)
|
||||
{
|
||||
<div class="list-item">
|
||||
<div class="item-main">
|
||||
<span class="badge badge-danger">FEJL</span>
|
||||
<code>@run.FullRepoName</code>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
@run.WorkflowId · @run.Event · @FormatTimeAgo(run.Created)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<style>
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.running-card {
|
||||
border-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.running-card .card-header {
|
||||
background: linear-gradient(90deg, rgba(240, 165, 0, 0.1), transparent);
|
||||
}
|
||||
|
||||
.error-card {
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.error-card .card-header {
|
||||
background: linear-gradient(90deg, rgba(220, 53, 69, 0.1), transparent);
|
||||
}
|
||||
|
||||
.stat-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-bar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-bar-label {
|
||||
width: 70px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-bar-value {
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ci-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ci-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ci-stat-value.success { color: var(--success-color); }
|
||||
.ci-stat-value.danger { color: var(--danger-color); }
|
||||
|
||||
.ci-stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ci-rate-bar {
|
||||
height: 8px;
|
||||
background: var(--danger-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ci-rate-success {
|
||||
height: 100%;
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.ci-rate-label {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 4px;
|
||||
padding-left: 45px;
|
||||
}
|
||||
|
||||
.success { color: var(--success-color); }
|
||||
.danger { color: var(--danger-color); }
|
||||
|
||||
@@media (max-width: 1200px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@functions {
|
||||
string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes == 0) return "0 B";
|
||||
var sizes = new[] { "B", "KB", "MB", "GB", "TB" };
|
||||
var i = (int)Math.Floor(Math.Log(bytes) / Math.Log(1024));
|
||||
return $"{Math.Round(bytes / Math.Pow(1024, i), 1)} {sizes[i]}";
|
||||
}
|
||||
|
||||
string FormatDuration(TimeSpan? duration)
|
||||
{
|
||||
if (!duration.HasValue) return "-";
|
||||
var d = duration.Value;
|
||||
if (d.TotalHours >= 1) return $"{(int)d.TotalHours}t {d.Minutes}m";
|
||||
if (d.TotalMinutes >= 1) return $"{(int)d.TotalMinutes}m {d.Seconds}s";
|
||||
return $"{d.Seconds}s";
|
||||
}
|
||||
|
||||
string FormatTimeAgo(DateTime? time)
|
||||
{
|
||||
if (!time.HasValue) return "-";
|
||||
var diff = DateTime.Now - time.Value;
|
||||
if (diff.TotalMinutes < 1) return "lige nu";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m siden";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}t siden";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d siden";
|
||||
return time.Value.ToString("dd/MM");
|
||||
}
|
||||
|
||||
string FormatTimeAgo(DateTime time) => FormatTimeAgo((DateTime?)time);
|
||||
}
|
||||
27
Pages/Forgejo/Index.cshtml.cs
Normal file
27
Pages/Forgejo/Index.cshtml.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Models;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Forgejo;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly ForgejoService _forgejoService;
|
||||
|
||||
public bool IsConnected { get; set; }
|
||||
public ForgejoDashboard Dashboard { get; set; } = new();
|
||||
|
||||
public IndexModel(ForgejoService forgejoService)
|
||||
{
|
||||
_forgejoService = forgejoService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsConnected = await _forgejoService.TestConnectionAsync();
|
||||
if (IsConnected)
|
||||
{
|
||||
Dashboard = await _forgejoService.GetDashboardAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
270
Pages/Forgejo/Repositories.cshtml
Normal file
270
Pages/Forgejo/Repositories.cshtml
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Forgejo.RepositoriesModel
|
||||
@{
|
||||
ViewData["Title"] = "Forgejo Repositories";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Repositories</h1>
|
||||
<p class="page-subtitle">Alle repositories med backup status</p>
|
||||
</div>
|
||||
|
||||
@if (!Model.IsConnected)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-danger">Kan ikke forbinde til Forgejo database</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var backedUp = Model.Repositories.Count(r => Model.BackupStatus.ContainsKey(r.FullName.ToLower()));
|
||||
var notBackedUp = Model.Repositories.Count - backedUp;
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Total Repos</div>
|
||||
<div class="status-value">@Model.Repositories.Count</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Med Backup</div>
|
||||
<div class="status-value success">@backedUp</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Mangler Backup</div>
|
||||
<div class="status-value @(notBackedUp > 0 ? "warning" : "")">@notBackedUp</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Total Størrelse</div>
|
||||
<div class="status-value">@FormatSize(Model.Repositories.Sum(r => r.Size) * 1024)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="filter-tabs mt-2">
|
||||
<button class="filter-tab active" onclick="filterRepos('all')">Alle (@Model.Repositories.Count)</button>
|
||||
<button class="filter-tab" onclick="filterRepos('backed-up')">Med Backup (@backedUp)</button>
|
||||
<button class="filter-tab" onclick="filterRepos('not-backed-up')">Mangler Backup (@notBackedUp)</button>
|
||||
<button class="filter-tab" onclick="filterRepos('private')">Private (@Model.Repositories.Count(r => r.IsPrivate))</button>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<table class="table" id="repos-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Repository</th>
|
||||
<th>Type</th>
|
||||
<th>Størrelse</th>
|
||||
<th>Issues</th>
|
||||
<th>PRs</th>
|
||||
<th>Opdateret</th>
|
||||
<th>Backup Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var repo in Model.Repositories)
|
||||
{
|
||||
var hasBackup = Model.BackupStatus.TryGetValue(repo.FullName.ToLower(), out var backupInfo);
|
||||
var rowClass = hasBackup ? "backed-up" : "not-backed-up";
|
||||
if (repo.IsPrivate) { rowClass += " private"; }
|
||||
|
||||
<tr class="repo-row @rowClass" data-name="@repo.FullName.ToLower()">
|
||||
<td>
|
||||
<code>@repo.FullName</code>
|
||||
@if (repo.Description != null)
|
||||
{
|
||||
<div class="repo-desc">@TruncateText(repo.Description, 60)</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (repo.IsPrivate) { <span class="badge">privat</span> }
|
||||
@if (repo.IsFork) { <span class="badge">fork</span> }
|
||||
@if (repo.IsMirror) { <span class="badge">mirror</span> }
|
||||
@if (repo.IsArchived) { <span class="badge badge-warning">arkiveret</span> }
|
||||
@if (!repo.IsPrivate && !repo.IsFork && !repo.IsMirror && !repo.IsArchived) { <span class="badge badge-success">public</span> }
|
||||
</td>
|
||||
<td>@repo.SizeFormatted</td>
|
||||
<td>
|
||||
@if (repo.OpenIssues > 0)
|
||||
{
|
||||
<span class="text-warning">@repo.OpenIssues</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">0</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (repo.OpenPulls > 0)
|
||||
{
|
||||
<span class="text-warning">@repo.OpenPulls</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">0</span>
|
||||
}
|
||||
</td>
|
||||
<td>@FormatTimeAgo(repo.UpdatedAt)</td>
|
||||
<td>
|
||||
@if (hasBackup)
|
||||
{
|
||||
<span class="badge badge-success">OK</span>
|
||||
<span class="backup-detail">
|
||||
@FormatTimeAgo(backupInfo.LastBackup) · @FormatSize(backupInfo.LastSize)
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-warning">MANGLER</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Missing Backups Alert -->
|
||||
@if (notBackedUp > 0)
|
||||
{
|
||||
<div class="card mt-2 warning-card">
|
||||
<div class="card-header">Repositories uden backup</div>
|
||||
<div class="card-body">
|
||||
<p style="margin-bottom: 12px;">Følgende repositories har ingen backup registreret:</p>
|
||||
<div class="missing-list">
|
||||
@foreach (var repo in Model.Repositories.Where(r => !Model.BackupStatus.ContainsKey(r.FullName)))
|
||||
{
|
||||
<code class="missing-repo">@repo.FullName</code>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<style>
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--text-color);
|
||||
color: var(--bg-color);
|
||||
border-color: var(--text-color);
|
||||
}
|
||||
|
||||
.repo-desc {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.backup-detail {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.warning-card {
|
||||
border-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.warning-card .card-header {
|
||||
background: linear-gradient(90deg, rgba(240, 165, 0, 0.1), transparent);
|
||||
}
|
||||
|
||||
.missing-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.missing-repo {
|
||||
background: rgba(240, 165, 0, 0.1);
|
||||
border: 1px solid var(--warning-color);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.repo-row.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success { color: var(--success-color); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function filterRepos(filter) {
|
||||
// Update active tab
|
||||
document.querySelectorAll('.filter-tab').forEach(tab => tab.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Filter rows
|
||||
document.querySelectorAll('.repo-row').forEach(row => {
|
||||
let show = false;
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
show = true;
|
||||
break;
|
||||
case 'backed-up':
|
||||
show = row.classList.contains('backed-up');
|
||||
break;
|
||||
case 'not-backed-up':
|
||||
show = row.classList.contains('not-backed-up') && !row.classList.contains('backed-up');
|
||||
break;
|
||||
case 'private':
|
||||
show = row.classList.contains('private');
|
||||
break;
|
||||
}
|
||||
row.classList.toggle('hidden', !show);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@functions {
|
||||
string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes == 0) return "0 B";
|
||||
var sizes = new[] { "B", "KB", "MB", "GB", "TB" };
|
||||
var i = (int)Math.Floor(Math.Log(bytes) / Math.Log(1024));
|
||||
return $"{Math.Round(bytes / Math.Pow(1024, i), 1)} {sizes[i]}";
|
||||
}
|
||||
|
||||
string FormatTimeAgo(DateTime? time)
|
||||
{
|
||||
if (!time.HasValue) return "-";
|
||||
var diff = DateTime.Now - time.Value;
|
||||
if (diff.TotalMinutes < 1) return "lige nu";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m siden";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}t siden";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d siden";
|
||||
return time.Value.ToString("dd/MM");
|
||||
}
|
||||
|
||||
string FormatTimeAgo(DateTime time) => FormatTimeAgo((DateTime?)time);
|
||||
|
||||
string TruncateText(string text, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return "";
|
||||
return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "...";
|
||||
}
|
||||
}
|
||||
40
Pages/Forgejo/Repositories.cshtml.cs
Normal file
40
Pages/Forgejo/Repositories.cshtml.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Models;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Forgejo;
|
||||
|
||||
public class RepositoriesModel : PageModel
|
||||
{
|
||||
private readonly ForgejoService _forgejoService;
|
||||
private readonly BackupService _backupService;
|
||||
|
||||
public bool IsConnected { get; set; }
|
||||
public List<ForgejoRepository> Repositories { get; set; } = new();
|
||||
public Dictionary<string, (DateTime? LastBackup, long LastSize)> BackupStatus { get; set; } = new();
|
||||
|
||||
public RepositoriesModel(ForgejoService forgejoService, BackupService backupService)
|
||||
{
|
||||
_forgejoService = forgejoService;
|
||||
_backupService = backupService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsConnected = await _forgejoService.TestConnectionAsync();
|
||||
if (IsConnected)
|
||||
{
|
||||
Repositories = await _forgejoService.GetAllRepositoriesAsync();
|
||||
|
||||
// Get backup status for comparison
|
||||
if (await _backupService.TestConnectionAsync())
|
||||
{
|
||||
var repoSummaries = await _backupService.GetRepositorySummariesAsync();
|
||||
foreach (var summary in repoSummaries.Where(s => s.BackupType == "forgejo_repos"))
|
||||
{
|
||||
BackupStatus[summary.SourceName.ToLower()] = (summary.LastSuccessfulBackup, summary.LastBackupSizeBytes ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
333
Pages/Index.cshtml
Normal file
333
Pages/Index.cshtml
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Dashboard";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
<p class="page-subtitle">PlanTempus SaaS Infrastructure Status</p>
|
||||
</div>
|
||||
|
||||
<!-- Top Status Grid -->
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Caddy Server</div>
|
||||
<div class="status-value @(Model.CaddyRunning ? "success" : "danger")">
|
||||
@(Model.CaddyRunning ? "ONLINE" : "OFFLINE")
|
||||
</div>
|
||||
<div class="status-detail">@Model.HostCount hosts</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Forgejo</div>
|
||||
<div class="status-value @(Model.ForgejoConnected ? "success" : "danger")">
|
||||
@if (Model.ForgejoConnected)
|
||||
{
|
||||
@if (Model.ForgejoDashboard.RunningNow > 0)
|
||||
{
|
||||
<span class="pulse">●</span> @Model.ForgejoDashboard.RunningNow <text> CI</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>ONLINE</text>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>OFFLINE</text>
|
||||
}
|
||||
</div>
|
||||
<div class="status-detail">@Model.ForgejoDashboard.TotalRepos repos</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Backup</div>
|
||||
<div class="status-value @(Model.BackupDbConnected ? (Model.LastBackupOk ? "success" : "warning") : "danger")">
|
||||
@if (Model.BackupDbConnected)
|
||||
{
|
||||
@(Model.LastBackupAge ?? "INGEN")
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>OFFLINE</text>
|
||||
}
|
||||
</div>
|
||||
<div class="status-detail">@Model.BackupSummary.SuccessfulBackups OK / @Model.BackupSummary.FailedBackups fejl</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Azure Storage</div>
|
||||
<div class="status-value @(Model.AzureConnected ? "success" : "danger")">
|
||||
@(Model.AzureConnected ? "ONLINE" : "OFFLINE")
|
||||
</div>
|
||||
<div class="status-detail">@FormatSize(Model.AzureDashboard.TotalSize)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid -->
|
||||
<div class="dashboard-cards mt-2">
|
||||
<!-- Forgejo Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">Forgejo Git</div>
|
||||
<div class="card-body">
|
||||
@if (Model.ForgejoConnected)
|
||||
{
|
||||
<div class="mini-stats">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@Model.ForgejoDashboard.TotalRepos</div>
|
||||
<div class="mini-stat-label">Repos</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@Model.ForgejoDashboard.TotalOpenIssues</div>
|
||||
<div class="mini-stat-label">Issues</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@Model.ForgejoDashboard.TotalOpenPRs</div>
|
||||
<div class="mini-stat-label">PRs</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@FormatSize(Model.ForgejoDashboard.TotalSize * 1024)</div>
|
||||
<div class="mini-stat-label">Størrelse</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.ForgejoDashboard.RunningRuns.Count > 0)
|
||||
{
|
||||
<div class="running-list mt-1">
|
||||
@foreach (var run in Model.ForgejoDashboard.RunningRuns.Take(3))
|
||||
{
|
||||
<div class="running-item">
|
||||
<span class="pulse">●</span>
|
||||
<code>@run.FullRepoName</code>
|
||||
<span class="text-muted">@run.WorkflowId</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<a href="/Forgejo" class="btn btn-primary mt-1">Se detaljer</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-danger">Forgejo database ikke tilgængelig</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caddy Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">Caddy Reverse Proxy</div>
|
||||
<div class="card-body">
|
||||
@if (Model.CaddyRunning)
|
||||
{
|
||||
<p>Server kører og håndterer <strong>@Model.HostCount</strong> host(s).</p>
|
||||
<a href="/Caddy" class="btn btn-primary mt-1">Se detaljer</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-danger">Caddy server er ikke tilgængelig.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">Backup Status</div>
|
||||
<div class="card-body">
|
||||
@if (Model.BackupDbConnected)
|
||||
{
|
||||
<div class="mini-stats">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value success">@Model.BackupSummary.SuccessfulBackups</div>
|
||||
<div class="mini-stat-label">Success</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value @(Model.BackupSummary.FailedBackups > 0 ? "danger" : "")">@Model.BackupSummary.FailedBackups</div>
|
||||
<div class="mini-stat-label">Fejlet</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@FormatSize(Model.BackupSummary.TotalSizeBytes)</div>
|
||||
<div class="mini-stat-label">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/Backup" class="btn btn-primary mt-1">Se detaljer</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-danger">Backup database er ikke tilgængelig.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CI/CD Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">CI/CD Actions</div>
|
||||
<div class="card-body">
|
||||
@if (Model.ForgejoConnected)
|
||||
{
|
||||
<div class="mini-stats">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value success">@Model.ForgejoDashboard.SuccessfulRuns</div>
|
||||
<div class="mini-stat-label">Success</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value @(Model.ForgejoDashboard.FailedRunsCount > 0 ? "danger" : "")">@Model.ForgejoDashboard.FailedRunsCount</div>
|
||||
<div class="mini-stat-label">Fejlet</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@Model.ForgejoDashboard.RunsThisWeek</div>
|
||||
<div class="mini-stat-label">Denne uge</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.ForgejoDashboard.FailedRuns.Count > 0)
|
||||
{
|
||||
<div class="failed-list mt-1">
|
||||
<div class="failed-header">Seneste fejl:</div>
|
||||
@foreach (var run in Model.ForgejoDashboard.FailedRuns.Take(2))
|
||||
{
|
||||
<div class="failed-item">
|
||||
<span class="badge badge-danger">FEJL</span>
|
||||
<code>@run.FullRepoName</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<a href="/Forgejo/Actions" class="btn btn-primary mt-1">Se detaljer</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">Ikke tilgængelig</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Azure Storage Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">Azure Blob Storage</div>
|
||||
<div class="card-body">
|
||||
@if (Model.AzureConnected)
|
||||
{
|
||||
<div class="mini-stats">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@Model.AzureDashboard.TotalContainers</div>
|
||||
<div class="mini-stat-label">Containers</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@Model.AzureDashboard.TotalBlobs</div>
|
||||
<div class="mini-stat-label">Blobs</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@FormatSize(Model.AzureDashboard.TotalSize)</div>
|
||||
<div class="mini-stat-label">Størrelse</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-value">@Model.AzureDashboard.BackupFileCount</div>
|
||||
<div class="mini-stat-label">Backups</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.AzureDashboard.RecentBlobs.Count > 0)
|
||||
{
|
||||
<div class="running-list mt-1">
|
||||
@foreach (var blob in Model.AzureDashboard.RecentBlobs.Take(2))
|
||||
{
|
||||
<div class="running-item">
|
||||
<span>📄</span>
|
||||
<code>@blob.FileName</code>
|
||||
<span class="text-muted">@FormatSize(blob.Size)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<a href="/Azure" class="btn btn-primary mt-1">Se detaljer</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-danger">Azure Storage ikke tilgængelig</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-detail {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.dashboard-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mini-stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mini-stat-value.success { color: var(--success-color); }
|
||||
.mini-stat-value.danger { color: var(--danger-color); }
|
||||
|
||||
.mini-stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.running-list, .failed-list {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.running-item, .failed-item {
|
||||
font-size: 11px;
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.failed-header {
|
||||
font-size: 10px;
|
||||
color: var(--muted-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@media (max-width: 900px) {
|
||||
.dashboard-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@functions {
|
||||
string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes == 0) return "0 B";
|
||||
var sizes = new[] { "B", "KB", "MB", "GB", "TB" };
|
||||
var i = (int)Math.Floor(Math.Log(bytes) / Math.Log(1024));
|
||||
return $"{Math.Round(bytes / Math.Pow(1024, i), 1)} {sizes[i]}";
|
||||
}
|
||||
}
|
||||
87
Pages/Index.cshtml.cs
Normal file
87
Pages/Index.cshtml.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using PlanTempusAdmin.Models;
|
||||
using PlanTempusAdmin.Services;
|
||||
|
||||
namespace PlanTempusAdmin.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly CaddyService _caddyService;
|
||||
private readonly BackupService _backupService;
|
||||
private readonly ForgejoService _forgejoService;
|
||||
private readonly AzureStorageService _azureService;
|
||||
|
||||
// Caddy
|
||||
public bool CaddyRunning { get; set; }
|
||||
public int HostCount { get; set; }
|
||||
|
||||
// Backup
|
||||
public bool BackupDbConnected { get; set; }
|
||||
public BackupSummary BackupSummary { get; set; } = new();
|
||||
public string? LastBackupAge { get; set; }
|
||||
public bool LastBackupOk { get; set; }
|
||||
|
||||
// Forgejo
|
||||
public bool ForgejoConnected { get; set; }
|
||||
public ForgejoDashboard ForgejoDashboard { get; set; } = new();
|
||||
|
||||
// Azure Storage
|
||||
public bool AzureConnected { get; set; }
|
||||
public AzureStorageDashboard AzureDashboard { get; set; } = new();
|
||||
|
||||
public IndexModel(CaddyService caddyService, BackupService backupService, ForgejoService forgejoService, AzureStorageService azureService)
|
||||
{
|
||||
_caddyService = caddyService;
|
||||
_backupService = backupService;
|
||||
_forgejoService = forgejoService;
|
||||
_azureService = azureService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
// Caddy status
|
||||
CaddyRunning = await _caddyService.IsRunningAsync();
|
||||
if (CaddyRunning)
|
||||
{
|
||||
var hosts = await _caddyService.GetHostsAsync();
|
||||
HostCount = hosts.Count;
|
||||
}
|
||||
|
||||
// Backup status
|
||||
BackupDbConnected = await _backupService.TestConnectionAsync();
|
||||
if (BackupDbConnected)
|
||||
{
|
||||
BackupSummary = await _backupService.GetSummaryAsync();
|
||||
|
||||
if (BackupSummary.LastBackup.HasValue)
|
||||
{
|
||||
var age = DateTime.Now - BackupSummary.LastBackup.Value;
|
||||
LastBackupAge = FormatAge(age);
|
||||
LastBackupOk = age.TotalHours < 24;
|
||||
}
|
||||
}
|
||||
|
||||
// Forgejo status
|
||||
ForgejoConnected = await _forgejoService.TestConnectionAsync();
|
||||
if (ForgejoConnected)
|
||||
{
|
||||
ForgejoDashboard = await _forgejoService.GetDashboardAsync();
|
||||
}
|
||||
|
||||
// Azure Storage status
|
||||
AzureConnected = await _azureService.TestConnectionAsync();
|
||||
if (AzureConnected)
|
||||
{
|
||||
AzureDashboard = await _azureService.GetDashboardAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatAge(TimeSpan age)
|
||||
{
|
||||
if (age.TotalMinutes < 60)
|
||||
return $"{(int)age.TotalMinutes}m siden";
|
||||
if (age.TotalHours < 24)
|
||||
return $"{(int)age.TotalHours}t siden";
|
||||
return $"{(int)age.TotalDays}d siden";
|
||||
}
|
||||
}
|
||||
93
Pages/Setup/Database.cshtml
Normal file
93
Pages/Setup/Database.cshtml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Setup.DatabaseModel
|
||||
@{
|
||||
ViewData["Title"] = "Database Schema";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Database Schema</h1>
|
||||
<p class="page-subtitle">PostgreSQL schema til backup logs</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
backup-logs.sql
|
||||
<button onclick="copyScript('sql-schema')" style="float: right; background: var(--accent); color: var(--background); border: none; padding: 4px 12px; cursor: pointer; font-size: 11px;">
|
||||
Kopier
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre id="sql-schema" style="max-height: 600px; overflow: auto;"><code class="language-sql">@Model.SqlSchema</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">Database Bruger Setup</div>
|
||||
<div class="card-body">
|
||||
<p>Opret en dedikeret bruger til backup scriptet:</p>
|
||||
<pre id="user-setup"><code class="language-sql">-- Opret bruger til backup script
|
||||
CREATE USER backup_writer WITH PASSWORD 'your_secure_password_here';
|
||||
|
||||
-- Giv rettigheder
|
||||
GRANT CONNECT ON DATABASE plantempus TO backup_writer;
|
||||
GRANT USAGE ON SCHEMA public TO backup_writer;
|
||||
GRANT INSERT, UPDATE ON backup_logs TO backup_writer;
|
||||
GRANT USAGE, SELECT ON SEQUENCE backup_logs_id_seq TO backup_writer;
|
||||
|
||||
-- Giv læseadgang (brug din eksisterende app-bruger)
|
||||
-- GRANT SELECT ON backup_logs TO your_app_user;
|
||||
-- GRANT SELECT ON backup_repository_summary TO your_app_user;</code></pre>
|
||||
<button onclick="copyScript('user-setup')" style="background: var(--accent); color: var(--background); border: none; padding: 4px 12px; cursor: pointer; font-size: 11px; margin-top: 8px;">
|
||||
Kopier bruger setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">Tabel Struktur</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kolonne</th>
|
||||
<th>Type</th>
|
||||
<th>Beskrivelse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>id</code></td><td>SERIAL</td><td>Primary key</td></tr>
|
||||
<tr><td><code>started_at</code></td><td>TIMESTAMP</td><td>Backup start tidspunkt</td></tr>
|
||||
<tr><td><code>completed_at</code></td><td>TIMESTAMP</td><td>Backup slut tidspunkt</td></tr>
|
||||
<tr><td><code>duration_ms</code></td><td>INTEGER</td><td>Varighed i millisekunder</td></tr>
|
||||
<tr><td><code>backup_type</code></td><td>VARCHAR(50)</td><td>'forgejo_repos', 'postgres_db', etc.</td></tr>
|
||||
<tr><td><code>source_name</code></td><td>VARCHAR(255)</td><td>Repository eller database navn</td></tr>
|
||||
<tr><td><code>source_path</code></td><td>VARCHAR(500)</td><td>Fuld sti på serveren</td></tr>
|
||||
<tr><td><code>destination</code></td><td>VARCHAR(50)</td><td>'azure_blob', 's3', 'local', 'sftp'</td></tr>
|
||||
<tr><td><code>remote_path</code></td><td>VARCHAR(500)</td><td>Sti på destination</td></tr>
|
||||
<tr><td><code>status</code></td><td>VARCHAR(20)</td><td>'running', 'success', 'failed', 'partial'</td></tr>
|
||||
<tr><td><code>size_bytes</code></td><td>BIGINT</td><td>Backup størrelse</td></tr>
|
||||
<tr><td><code>file_count</code></td><td>INTEGER</td><td>Antal filer i backup</td></tr>
|
||||
<tr><td><code>error_message</code></td><td>TEXT</td><td>Fejlbesked hvis fejlet</td></tr>
|
||||
<tr><td><code>error_code</code></td><td>VARCHAR(50)</td><td>Fejlkode</td></tr>
|
||||
<tr><td><code>retry_count</code></td><td>INTEGER</td><td>Antal forsøg</td></tr>
|
||||
<tr><td><code>hostname</code></td><td>VARCHAR(100)</td><td>Server hostname</td></tr>
|
||||
<tr><td><code>script_version</code></td><td>VARCHAR(20)</td><td>Script version</td></tr>
|
||||
<tr><td><code>checksum</code></td><td>VARCHAR(64)</td><td>SHA256 checksum</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function copyScript(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
const text = element.textContent || element.innerText;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('Kopieret til udklipsholder!');
|
||||
}).catch(err => {
|
||||
console.error('Kunne ikke kopiere:', err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
}
|
||||
28
Pages/Setup/Database.cshtml.cs
Normal file
28
Pages/Setup/Database.cshtml.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Setup;
|
||||
|
||||
public class DatabaseModel : PageModel
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public string SqlSchema { get; set; } = string.Empty;
|
||||
|
||||
public DatabaseModel(IWebHostEnvironment environment)
|
||||
{
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
var schemaPath = Path.Combine(_environment.ContentRootPath, "Scripts", "backup-logs.sql");
|
||||
if (System.IO.File.Exists(schemaPath))
|
||||
{
|
||||
SqlSchema = System.IO.File.ReadAllText(schemaPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
SqlSchema = "-- Schema not found at: " + schemaPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Pages/Setup/Index.cshtml
Normal file
54
Pages/Setup/Index.cshtml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Setup.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Setup";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Setup</h1>
|
||||
<p class="page-subtitle">Scripts og konfiguration til servere</p>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<a href="/Setup/Scripts" class="status-item" style="text-decoration: none; color: inherit;">
|
||||
<div class="status-label">Scripts</div>
|
||||
<div class="status-value">Bash</div>
|
||||
<p style="margin-top: 8px; font-size: 11px; color: var(--foreground);">Backup scripts til Ubuntu servere</p>
|
||||
</a>
|
||||
<a href="/Setup/Database" class="status-item" style="text-decoration: none; color: inherit;">
|
||||
<div class="status-label">Database</div>
|
||||
<div class="status-value">SQL</div>
|
||||
<p style="margin-top: 8px; font-size: 11px; color: var(--foreground);">PostgreSQL schema og migrations</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">Hurtig Guide</div>
|
||||
<div class="card-body">
|
||||
<h3 style="margin-bottom: 12px;">1. Database Setup</h3>
|
||||
<p>Kør SQL scriptet fra <a href="/Setup/Database">Database</a> siden på din PostgreSQL server.</p>
|
||||
|
||||
<h3 style="margin: 16px 0 12px;">2. Azure CLI Setup</h3>
|
||||
<p>Installer Azure CLI på serveren:</p>
|
||||
<pre><code>curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
|
||||
az --version</code></pre>
|
||||
|
||||
<h3 style="margin: 16px 0 12px;">3. Backup Script</h3>
|
||||
<p>Download scriptet fra <a href="/Setup/Scripts">Scripts</a> siden og placer det på serveren.</p>
|
||||
<pre><code># Placer script
|
||||
sudo mkdir -p /opt/backup
|
||||
sudo cp forgejo-backup.sh /opt/backup/
|
||||
sudo chmod +x /opt/backup/forgejo-backup.sh
|
||||
|
||||
# Opret .env fil med konfiguration
|
||||
sudo nano /opt/backup/.env</code></pre>
|
||||
|
||||
<h3 style="margin: 16px 0 12px;">4. Cron Job</h3>
|
||||
<p>Tilføj daglig backup via cron:</p>
|
||||
<pre><code># Rediger crontab
|
||||
sudo crontab -e
|
||||
|
||||
# Tilføj linje (kører kl. 02:00 hver nat)
|
||||
0 2 * * * /opt/backup/forgejo-backup.sh >> /var/log/forgejo-backup.log 2>&1</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
10
Pages/Setup/Index.cshtml.cs
Normal file
10
Pages/Setup/Index.cshtml.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Setup;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
107
Pages/Setup/Scripts.cshtml
Normal file
107
Pages/Setup/Scripts.cshtml
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
@page
|
||||
@model PlanTempusAdmin.Pages.Setup.ScriptsModel
|
||||
@{
|
||||
ViewData["Title"] = "Backup Scripts";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Backup Scripts</h1>
|
||||
<p class="page-subtitle">Bash scripts til Forgejo repository backup</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
forgejo-backup.sh
|
||||
<button onclick="copyScript('bash-script')" style="float: right; background: var(--accent); color: var(--background); border: none; padding: 4px 12px; cursor: pointer; font-size: 11px;">
|
||||
Kopier
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre id="bash-script" style="max-height: 600px; overflow: auto;"><code class="language-bash">@Model.BashScript</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">Environment Konfiguration (.env)</div>
|
||||
<div class="card-body">
|
||||
<pre id="env-config"><code class="language-bash"># Forgejo Repository Backup Configuration
|
||||
|
||||
# Sti til Forgejo repositories
|
||||
FORGEJO_REPO_PATH=/var/lib/forgejo/data/forgejo-repositories
|
||||
|
||||
# Midlertidig mappe til backup filer
|
||||
BACKUP_TEMP_DIR=/tmp/forgejo-backups
|
||||
|
||||
# PostgreSQL database konfiguration
|
||||
BACKUP_DB_HOST=192.168.1.43
|
||||
BACKUP_DB_PORT=5432
|
||||
BACKUP_DB_NAME=ptadmin
|
||||
BACKUP_DB_USER=plantempus_app
|
||||
BACKUP_DB_PASSWORD=your_secure_password_here
|
||||
|
||||
# Azure Blob Storage konfiguration
|
||||
AZURE_STORAGE_ACCOUNT=storageptadmin
|
||||
AZURE_STORAGE_KEY=your_storage_key_here
|
||||
AZURE_STORAGE_CONTAINER=backups
|
||||
AZURE_STORAGE_PATH=forgejo
|
||||
|
||||
# Antal dage backup gemmes
|
||||
BACKUP_RETENTION_DAYS=30</code></pre>
|
||||
<button onclick="copyScript('env-config')" style="background: var(--accent); color: var(--background); border: none; padding: 4px 12px; cursor: pointer; font-size: 11px; margin-top: 8px;">
|
||||
Kopier .env
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">Cron Job Setup</div>
|
||||
<div class="card-body">
|
||||
<p>Tilføj følgende linje til crontab (<code>sudo crontab -e</code>):</p>
|
||||
<pre id="cron-config"><code># Kører backup hver dag kl. 02:00
|
||||
0 2 * * * /opt/backup/forgejo-backup.sh >> /var/log/forgejo-backup.log 2>&1</code></pre>
|
||||
<button onclick="copyScript('cron-config')" style="background: var(--accent); color: var(--background); border: none; padding: 4px 12px; cursor: pointer; font-size: 11px; margin-top: 8px;">
|
||||
Kopier cron
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">Azure CLI Setup</div>
|
||||
<div class="card-body">
|
||||
<p>Installer Azure CLI på serveren:</p>
|
||||
<pre><code class="language-bash"># Installer Azure CLI (Ubuntu/Debian)
|
||||
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
|
||||
|
||||
# Verificer installation
|
||||
az --version
|
||||
|
||||
# Test forbindelse til storage account
|
||||
az storage container list \
|
||||
--account-name storageptadmin \
|
||||
--account-key "YOUR_KEY_HERE" \
|
||||
--output table
|
||||
|
||||
# Test upload
|
||||
echo "test" > /tmp/test.txt
|
||||
az storage blob upload \
|
||||
--account-name storageptadmin \
|
||||
--account-key "YOUR_KEY_HERE" \
|
||||
--container-name backups \
|
||||
--file /tmp/test.txt \
|
||||
--name test/test.txt</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function copyScript(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
const text = element.textContent || element.innerText;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('Kopieret til udklipsholder!');
|
||||
}).catch(err => {
|
||||
console.error('Kunne ikke kopiere:', err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
}
|
||||
28
Pages/Setup/Scripts.cshtml.cs
Normal file
28
Pages/Setup/Scripts.cshtml.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace PlanTempusAdmin.Pages.Setup;
|
||||
|
||||
public class ScriptsModel : PageModel
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public string BashScript { get; set; } = string.Empty;
|
||||
|
||||
public ScriptsModel(IWebHostEnvironment environment)
|
||||
{
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
var scriptPath = Path.Combine(_environment.ContentRootPath, "Scripts", "forgejo-backup.sh");
|
||||
if (System.IO.File.Exists(scriptPath))
|
||||
{
|
||||
BashScript = System.IO.File.ReadAllText(scriptPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
BashScript = "# Script not found at: " + scriptPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Pages/Shared/_Layout.cshtml
Normal file
23
Pages/Shared/_Layout.cshtml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - PlanTempusAdmin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<partial name="_Menu" />
|
||||
<main class="main-content">
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
42
Pages/Shared/_Menu.cshtml
Normal file
42
Pages/Shared/_Menu.cshtml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
PlanTempusAdmin
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
<a class="nav-link" asp-page="/Index">Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Caddy</div>
|
||||
<a class="nav-link" asp-page="/Caddy/Index">Oversigt</a>
|
||||
<a class="nav-link" asp-page="/Caddy/Hosts">Hosts</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Forgejo</div>
|
||||
<a class="nav-link" asp-page="/Forgejo/Index">Oversigt</a>
|
||||
<a class="nav-link" asp-page="/Forgejo/Repositories">Repositories</a>
|
||||
<a class="nav-link" asp-page="/Forgejo/Actions">Actions</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Backup</div>
|
||||
<a class="nav-link" asp-page="/Backup/Index">Oversigt</a>
|
||||
<a class="nav-link" asp-page="/Backup/Logs">Logs</a>
|
||||
<a class="nav-link" asp-page="/Backup/Repositories">Repositories</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Azure Storage</div>
|
||||
<a class="nav-link" asp-page="/Azure/Index">Oversigt</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Setup</div>
|
||||
<a class="nav-link" asp-page="/Setup/Index">Oversigt</a>
|
||||
<a class="nav-link" asp-page="/Setup/Scripts">Scripts</a>
|
||||
<a class="nav-link" asp-page="/Setup/Database">Database</a>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
2
Pages/Shared/_ValidationScriptsPartial.cshtml
Normal file
2
Pages/Shared/_ValidationScriptsPartial.cshtml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
|
||||
<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>
|
||||
3
Pages/_ViewImports.cshtml
Normal file
3
Pages/_ViewImports.cshtml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@using PlanTempusAdmin
|
||||
@namespace PlanTempusAdmin.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
Pages/_ViewStart.cshtml
Normal file
3
Pages/_ViewStart.cshtml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue