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