Initial commit

This commit is contained in:
Janus C. H. Knudsen 2026-02-03 00:17:08 +01:00
commit 77d35ff965
51 changed files with 5591 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
@using PlanTempusAdmin
@namespace PlanTempusAdmin.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

3
Pages/_ViewStart.cshtml Normal file
View file

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}