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,9 @@
{
"permissions": {
"allow": [
"Bash(node:*)",
"Bash(dotnet build:*)",
"Bash(dotnet add:*)"
]
}
}

484
.gitignore vendored Normal file
View file

@ -0,0 +1,484 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View file

@ -0,0 +1,63 @@
namespace PlanTempusAdmin.Models;
public class AzureContainer
{
public string Name { get; set; } = string.Empty;
public DateTimeOffset? LastModified { get; set; }
public long TotalSize { get; set; }
public int BlobCount { get; set; }
}
public class AzureBlob
{
public string Name { get; set; } = string.Empty;
public string? ContentType { get; set; }
public long Size { get; set; }
public DateTimeOffset? LastModified { get; set; }
public DateTimeOffset? CreatedOn { get; set; }
public string? AccessTier { get; set; }
public BlobType BlobType { get; set; }
// Computed
public string FileName => Name.Contains('/') ? Name.Substring(Name.LastIndexOf('/') + 1) : Name;
public string? Directory => Name.Contains('/') ? Name.Substring(0, Name.LastIndexOf('/')) : null;
public bool IsBackup => Name.EndsWith(".tar.gz") || Name.EndsWith(".sql.gz") || Name.EndsWith(".zip") || Name.EndsWith(".bak");
}
public enum BlobType
{
Block,
Page,
Append
}
public class AzureStorageDashboard
{
public bool IsConnected { get; set; }
public string AccountName { get; set; } = string.Empty;
// Stats
public int TotalContainers { get; set; }
public long TotalSize { get; set; }
public int TotalBlobs { get; set; }
// Containers
public List<AzureContainer> Containers { get; set; } = new();
// Recent blobs
public List<AzureBlob> RecentBlobs { get; set; } = new();
// Backup specific
public int BackupFileCount { get; set; }
public long BackupTotalSize { get; set; }
public DateTimeOffset? LastBackupUpload { get; set; }
}
public class ContainerDetails
{
public string Name { get; set; } = string.Empty;
public long TotalSize { get; set; }
public int BlobCount { get; set; }
public List<AzureBlob> Blobs { get; set; } = new();
public List<string> Prefixes { get; set; } = new(); // Virtual directories
}

162
Models/BackupLog.cs Normal file
View file

@ -0,0 +1,162 @@
namespace PlanTempusAdmin.Models;
public class BackupLog
{
public int Id { get; set; }
// Timing
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public int? DurationMs { get; set; }
// Identifikation
public string BackupType { get; set; } = string.Empty; // 'forgejo_repos', 'postgres_db', etc.
public string SourceName { get; set; } = string.Empty; // repo navn eller db navn
public string? SourcePath { get; set; } // /var/lib/forgejo/repositories/user/repo.git
// Destination
public string Destination { get; set; } = string.Empty; // 'azure_blob', 's3', 'local', 'sftp'
public string? RemotePath { get; set; } // azure:container/backups/2024-01-31/repo.tar.gz
// Resultat
public string Status { get; set; } = string.Empty; // 'running', 'success', 'failed', 'partial'
public long? SizeBytes { get; set; }
public int? FileCount { get; set; } // antal filer i backup
// Fejlhåndtering
public string? ErrorMessage { get; set; }
public string? ErrorCode { get; set; } // 'AZURE_UPLOAD_FAILED', 'DISK_FULL', 'TAR_FAILED', etc.
public int RetryCount { get; set; }
// Metadata
public string? Hostname { get; set; } // hvilken server kørte backup
public string? ScriptVersion { get; set; } // version af backup script
public string? Checksum { get; set; } // SHA256 af backup fil
public DateTime CreatedAt { get; set; }
// Legacy compatibility properties for existing pages
public DateTime Timestamp => StartedAt;
public string Database => SourceName;
public TimeSpan? Duration => DurationMs.HasValue ? TimeSpan.FromMilliseconds(DurationMs.Value) : null;
public string? FilePath => RemotePath ?? SourcePath;
}
public class BackupSummary
{
public int TotalBackups { get; set; }
public int SuccessfulBackups { get; set; }
public int FailedBackups { get; set; }
public DateTime? LastBackup { get; set; }
public DateTime? LastSuccessfulBackup { get; set; }
public long TotalSizeBytes { get; set; }
}
public class RepositorySummary
{
public string SourceName { get; set; } = string.Empty;
public string BackupType { get; set; } = string.Empty;
public int TotalBackups { get; set; }
public int SuccessfulBackups { get; set; }
public int FailedBackups { get; set; }
public DateTime? LastBackup { get; set; }
public DateTime? LastSuccessfulBackup { get; set; }
public long TotalSizeBytes { get; set; }
public long? LastBackupSizeBytes { get; set; }
public long? PreviousBackupSizeBytes { get; set; }
public string SizeTrend
{
get
{
if (!LastBackupSizeBytes.HasValue || !PreviousBackupSizeBytes.HasValue)
return "unknown";
if (LastBackupSizeBytes > PreviousBackupSizeBytes * 1.1m)
return "growing";
if (LastBackupSizeBytes < PreviousBackupSizeBytes * 0.9m)
return "shrinking";
return "stable";
}
}
}
public class BackupDashboard
{
// Overall stats
public int TotalBackups { get; set; }
public int SuccessfulBackups { get; set; }
public int FailedBackups { get; set; }
public int RunningBackups { get; set; }
public long TotalSizeBytes { get; set; }
public double SuccessRate => TotalBackups > 0 ? (double)SuccessfulBackups / TotalBackups * 100 : 0;
// Time-based
public DateTime? LastBackup { get; set; }
public DateTime? LastSuccessfulBackup { get; set; }
public int BackupsLast24Hours { get; set; }
public int BackupsLast7Days { get; set; }
public long SizeLast24Hours { get; set; }
public long SizeLast7Days { get; set; }
// Grouped stats
public List<BackupTypeStat> ByType { get; set; } = new();
public List<DestinationStat> ByDestination { get; set; } = new();
public List<HostStat> ByHost { get; set; } = new();
public List<ErrorStat> TopErrors { get; set; } = new();
public List<DailyStat> DailyStats { get; set; } = new();
// Recent activity
public List<BackupLog> RunningNow { get; set; } = new();
public List<BackupLog> RecentSuccesses { get; set; } = new();
public List<BackupLog> RecentFailures { get; set; } = new();
}
public class BackupTypeStat
{
public string BackupType { get; set; } = string.Empty;
public int Total { get; set; }
public int Successful { get; set; }
public int Failed { get; set; }
public int Running { get; set; }
public long TotalSize { get; set; }
public DateTime? LastBackup { get; set; }
public double SuccessRate => Total > 0 ? (double)Successful / Total * 100 : 0;
}
public class DestinationStat
{
public string Destination { get; set; } = string.Empty;
public int Total { get; set; }
public int Successful { get; set; }
public int Failed { get; set; }
public long TotalSize { get; set; }
public DateTime? LastBackup { get; set; }
}
public class HostStat
{
public string Hostname { get; set; } = string.Empty;
public int Total { get; set; }
public int Successful { get; set; }
public int Failed { get; set; }
public int Running { get; set; }
public DateTime? LastBackup { get; set; }
public string? ScriptVersion { get; set; }
}
public class ErrorStat
{
public string ErrorCode { get; set; } = string.Empty;
public int Count { get; set; }
public DateTime? LastOccurrence { get; set; }
public string? LastMessage { get; set; }
}
public class DailyStat
{
public DateTime Date { get; set; }
public int Total { get; set; }
public int Successful { get; set; }
public int Failed { get; set; }
public long TotalSize { get; set; }
}

52
Models/CaddyHost.cs Normal file
View file

@ -0,0 +1,52 @@
namespace PlanTempusAdmin.Models;
public class CaddyHost
{
public string Hostname { get; set; } = string.Empty;
public string[] Addresses { get; set; } = Array.Empty<string>();
public bool Tls { get; set; }
public string? Upstream { get; set; }
}
public class CaddyConfig
{
public CaddyApps? Apps { get; set; }
}
public class CaddyApps
{
public CaddyHttpApp? Http { get; set; }
}
public class CaddyHttpApp
{
public Dictionary<string, CaddyServer>? Servers { get; set; }
}
public class CaddyServer
{
public string[]? Listen { get; set; }
public CaddyRoute[]? Routes { get; set; }
}
public class CaddyRoute
{
public CaddyMatch[]? Match { get; set; }
public CaddyHandle[]? Handle { get; set; }
}
public class CaddyMatch
{
public string[]? Host { get; set; }
}
public class CaddyHandle
{
public string Handler { get; set; } = string.Empty;
public CaddyUpstream[]? Upstreams { get; set; }
}
public class CaddyUpstream
{
public string Dial { get; set; } = string.Empty;
}

143
Models/Forgejo.cs Normal file
View file

@ -0,0 +1,143 @@
namespace PlanTempusAdmin.Models;
public class ForgejoRepository
{
public long Id { get; set; }
public string OwnerName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string FullName => $"{OwnerName}/{Name}";
public string? Description { get; set; }
public bool IsPrivate { get; set; }
public bool IsFork { get; set; }
public bool IsArchived { get; set; }
public bool IsMirror { get; set; }
// Stats
public int NumStars { get; set; }
public int NumForks { get; set; }
public int NumWatches { get; set; }
public int NumIssues { get; set; }
public int NumClosedIssues { get; set; }
public int NumPulls { get; set; }
public int NumClosedPulls { get; set; }
public long Size { get; set; } // in KB
// Timestamps
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// Calculated
public int OpenIssues => NumIssues - NumClosedIssues;
public int OpenPulls => NumPulls - NumClosedPulls;
public string SizeFormatted => FormatSize(Size * 1024); // Size is in KB
private static 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]}";
}
}
public class ForgejoActionRun
{
public long Id { get; set; }
public long RepoId { get; set; }
public string RepoName { get; set; } = string.Empty;
public string OwnerName { get; set; } = string.Empty;
public string FullRepoName => $"{OwnerName}/{RepoName}";
public string WorkflowId { get; set; } = string.Empty;
public long Index { get; set; }
public string TriggerUser { get; set; } = string.Empty;
public string Ref { get; set; } = string.Empty;
public string CommitSha { get; set; } = string.Empty;
public string Event { get; set; } = string.Empty; // push, pull_request, etc.
public string Title { get; set; } = string.Empty;
// Status: 0=unknown, 1=waiting, 2=running, 3=success, 4=failure, 5=cancelled, 6=skipped
public int Status { get; set; }
public string StatusText => Status switch
{
1 => "waiting",
2 => "running",
3 => "success",
4 => "failure",
5 => "cancelled",
6 => "skipped",
_ => "unknown"
};
public DateTime? Started { get; set; }
public DateTime? Stopped { get; set; }
public DateTime Created { get; set; }
public DateTime Updated { get; set; }
public TimeSpan? Duration => Started.HasValue && Stopped.HasValue
? Stopped.Value - Started.Value
: Started.HasValue ? DateTime.UtcNow - Started.Value : null;
}
public class ForgejoActionJob
{
public long Id { get; set; }
public long RunId { get; set; }
public string Name { get; set; } = string.Empty;
public int Status { get; set; }
public string StatusText => Status switch
{
1 => "waiting",
2 => "running",
3 => "success",
4 => "failure",
5 => "cancelled",
6 => "skipped",
_ => "unknown"
};
public DateTime? Started { get; set; }
public DateTime? Stopped { get; set; }
}
public class ForgejoDashboard
{
// Repository stats
public int TotalRepos { get; set; }
public int PublicRepos { get; set; }
public int PrivateRepos { get; set; }
public int ForkedRepos { get; set; }
public int ArchivedRepos { get; set; }
public int MirrorRepos { get; set; }
public long TotalSize { get; set; }
public int TotalStars { get; set; }
public int TotalForks { get; set; }
public int TotalOpenIssues { get; set; }
public int TotalOpenPRs { get; set; }
// Actions stats
public int TotalRuns { get; set; }
public int RunsToday { get; set; }
public int RunsThisWeek { get; set; }
public int SuccessfulRuns { get; set; }
public int FailedRunsCount { get; set; }
public int RunningNow { get; set; }
public double SuccessRate => TotalRuns > 0 ? (double)SuccessfulRuns / TotalRuns * 100 : 0;
// Recent activity
public List<ForgejoRepository> RecentlyUpdated { get; set; } = new();
public List<ForgejoRepository> LargestRepos { get; set; } = new();
public List<ForgejoActionRun> RecentRuns { get; set; } = new();
public List<ForgejoActionRun> FailedRuns { get; set; } = new();
public List<ForgejoActionRun> RunningRuns { get; set; } = new();
}
public class ForgejoActionStats
{
public string WorkflowId { get; set; } = string.Empty;
public string RepoName { get; set; } = string.Empty;
public int TotalRuns { get; set; }
public int Successful { get; set; }
public int Failed { get; set; }
public double SuccessRate => TotalRuns > 0 ? (double)Successful / TotalRuns * 100 : 0;
public DateTime? LastRun { get; set; }
public double? AvgDurationSeconds { get; set; }
}

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

15
PlanTempusAdmin.csproj Normal file
View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.23.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Npgsql" Version="10.0.1" />
</ItemGroup>
</Project>

25
PlanTempusAdmin.sln Normal file
View file

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36202.13
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanTempusAdmin", "PlanTempusAdmin.csproj", "{8225A85E-3F75-3F24-EDC8-600BBD2C0E05}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{8225A85E-3F75-3F24-EDC8-600BBD2C0E05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8225A85E-3F75-3F24-EDC8-600BBD2C0E05}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8225A85E-3F75-3F24-EDC8-600BBD2C0E05}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8225A85E-3F75-3F24-EDC8-600BBD2C0E05}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7BC55246-E17D-43BF-A85B-37BA22C877C6}
EndGlobalSection
EndGlobal

39
Program.cs Normal file
View file

@ -0,0 +1,39 @@
using PlanTempusAdmin.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
// Register HttpClient for CaddyService
builder.Services.AddHttpClient<CaddyService>();
// Register BackupService
builder.Services.AddScoped<BackupService>();
// Register ForgejoService
builder.Services.AddScoped<ForgejoService>();
// Register AzureStorageService
builder.Services.AddSingleton<AzureStorageService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.MapStaticAssets();
app.MapRazorPages()
.WithStaticAssets();
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5097",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7183;http://localhost:5097",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

73
Scripts/backup-logs.sql Normal file
View file

@ -0,0 +1,73 @@
-- Backup Logs Database Schema
-- PostgreSQL schema for tracking backup operations
CREATE TABLE backup_logs (
id SERIAL PRIMARY KEY,
-- Timing
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP,
duration_ms INTEGER,
-- Identifikation
backup_type VARCHAR(50) NOT NULL, -- 'forgejo_repos', 'postgres_db', etc.
source_name VARCHAR(255) NOT NULL, -- repo navn eller db navn
source_path VARCHAR(500), -- /var/lib/forgejo/repositories/user/repo.git
-- Destination
destination VARCHAR(50) NOT NULL, -- 'azure_blob', 's3', 'local', 'sftp'
remote_path VARCHAR(500), -- https://storage.blob.core.windows.net/backups/forgejo/2024-01-31/repo.tar.gz
-- Resultat
status VARCHAR(20) NOT NULL, -- 'running', 'success', 'failed', 'partial'
size_bytes BIGINT,
file_count INTEGER, -- antal filer i backup
-- Fejlhåndtering
error_message TEXT,
error_code VARCHAR(50), -- 'AZURE_UPLOAD_FAILED', 'DISK_FULL', 'TAR_FAILED', etc.
retry_count INTEGER DEFAULT 0,
-- Metadata
hostname VARCHAR(100), -- hvilken server kørte backup
script_version VARCHAR(20), -- version af backup script
checksum VARCHAR(64), -- SHA256 af backup fil
-- Indexes
created_at TIMESTAMP DEFAULT NOW()
);
-- Performance indexes
CREATE INDEX idx_backup_logs_started ON backup_logs(started_at DESC);
CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type);
CREATE INDEX idx_backup_logs_status ON backup_logs(status);
CREATE INDEX idx_backup_logs_source ON backup_logs(source_name);
-- Composite index for repository summary queries
CREATE INDEX idx_backup_logs_source_started ON backup_logs(source_name, started_at DESC);
-- View for repository summaries (optional, can be used for performance)
CREATE OR REPLACE VIEW backup_repository_summary AS
SELECT
source_name,
backup_type,
COUNT(*) as total_backups,
COUNT(*) FILTER (WHERE status = 'success') as successful_backups,
COUNT(*) FILTER (WHERE status = 'failed') as failed_backups,
MAX(started_at) as last_backup,
MAX(started_at) FILTER (WHERE status = 'success') as last_successful_backup,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success'), 0) as total_size_bytes
FROM backup_logs
GROUP BY source_name, backup_type;
-- Sample data for testing (optional)
-- INSERT INTO backup_logs (backup_type, source_name, source_path, destination, remote_path, status, size_bytes, hostname, script_version)
-- VALUES ('forgejo_repos', 'myorg/myrepo', '/var/lib/forgejo/data/forgejo-repositories/myorg/myrepo.git', 'azure_blob', 'https://storageptadmin.blob.core.windows.net/backups/forgejo/2024-01-31/myorg-myrepo.tar.gz', 'success', 1048576, 'forgejo-server', '2.0.0');
-- Bruger setup til backup script
CREATE USER backup_writer WITH PASSWORD 'your_secure_password_here';
GRANT CONNECT ON DATABASE ptadmin TO backup_writer;
GRANT USAGE ON SCHEMA public TO backup_writer;
GRANT SELECT, INSERT, UPDATE ON backup_logs TO backup_writer;
GRANT USAGE, SELECT ON SEQUENCE backup_logs_id_seq TO backup_writer;

View file

@ -0,0 +1,24 @@
# Forgejo Backup Configuration
# Copy this file to .env and update values
# Forgejo repository path
FORGEJO_REPO_PATH=/var/lib/forgejo/data/forgejo-repositories
# Temporary directory for creating archives
BACKUP_TEMP_DIR=/tmp/forgejo-backups
# PostgreSQL database for logging
BACKUP_DB_HOST=192.168.1.43
BACKUP_DB_PORT=5432
BACKUP_DB_NAME=ptadmin
BACKUP_DB_USER=plantempus_app
BACKUP_DB_PASSWORD=your_password_here
# Backup retention (days)
BACKUP_RETENTION_DAYS=30
# Azure Storage Configuration
AZURE_STORAGE_ACCOUNT=storageptadmin
AZURE_STORAGE_KEY=your_storage_key_here
AZURE_STORAGE_CONTAINER=backups
AZURE_STORAGE_PATH=forgejo

340
Scripts/forgejo-backup.sh Normal file
View file

@ -0,0 +1,340 @@
#!/bin/bash
#
# Forgejo Repository Backup Script
# Version: 2.0.0
#
# Backs up all Forgejo git repositories to Azure Blob Storage
# and logs results to PostgreSQL.
#
# Usage: ./forgejo-backup.sh
#
# Configuration via environment variables or .env file
set -euo pipefail
SCRIPT_VERSION="2.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOSTNAME=$(hostname)
DATE=$(date +%Y-%m-%d)
TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
# Load configuration from .env if it exists
if [[ -f "$SCRIPT_DIR/.env" ]]; then
source "$SCRIPT_DIR/.env"
fi
# Configuration with defaults
FORGEJO_REPO_PATH="${FORGEJO_REPO_PATH:-/var/lib/forgejo/data/forgejo-repositories}"
BACKUP_TEMP_DIR="${BACKUP_TEMP_DIR:-/tmp/forgejo-backups}"
BACKUP_DB_HOST="${BACKUP_DB_HOST:-localhost}"
BACKUP_DB_PORT="${BACKUP_DB_PORT:-5432}"
BACKUP_DB_NAME="${BACKUP_DB_NAME:-plantempus}"
BACKUP_DB_USER="${BACKUP_DB_USER:-backup_writer}"
BACKUP_DB_PASSWORD="${BACKUP_DB_PASSWORD:-}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
# Azure Storage Configuration
AZURE_STORAGE_ACCOUNT="${AZURE_STORAGE_ACCOUNT:-}"
AZURE_STORAGE_KEY="${AZURE_STORAGE_KEY:-}"
AZURE_STORAGE_CONTAINER="${AZURE_STORAGE_CONTAINER:-backups}"
AZURE_STORAGE_PATH="${AZURE_STORAGE_PATH:-forgejo}"
# Build Azure destination URL
AZURE_BLOB_URL="https://${AZURE_STORAGE_ACCOUNT}.blob.core.windows.net/${AZURE_STORAGE_CONTAINER}/${AZURE_STORAGE_PATH}"
# Logging functions
log_info() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $*"
}
log_error() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
}
log_warn() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN: $*"
}
# Database logging function
db_log() {
local backup_type="$1"
local source_name="$2"
local source_path="$3"
local destination="$4"
local remote_path="$5"
local status="$6"
local size_bytes="${7:-}"
local error_message="${8:-}"
local error_code="${9:-}"
local checksum="${10:-}"
local started_at="${11:-}"
local file_count="${12:-}"
local duration_ms=""
if [[ -n "$started_at" && "$status" != "running" ]]; then
local start_epoch=$(date -d "$started_at" +%s 2>/dev/null || echo "")
local now_epoch=$(date +%s)
if [[ -n "$start_epoch" ]]; then
duration_ms=$(( (now_epoch - start_epoch) * 1000 ))
fi
fi
local completed_at=""
if [[ "$status" != "running" ]]; then
completed_at=$(date '+%Y-%m-%d %H:%M:%S')
fi
PGPASSWORD="$BACKUP_DB_PASSWORD" psql -h "$BACKUP_DB_HOST" -p "$BACKUP_DB_PORT" -U "$BACKUP_DB_USER" -d "$BACKUP_DB_NAME" -q <<EOF
INSERT INTO backup_logs (
backup_type, source_name, source_path, destination, remote_path,
status, size_bytes, file_count, error_message, error_code,
hostname, script_version, checksum, started_at, completed_at, duration_ms
) VALUES (
'$backup_type',
'$source_name',
'$source_path',
'$destination',
'$remote_path',
'$status',
$([ -n "$size_bytes" ] && echo "$size_bytes" || echo "NULL"),
$([ -n "$file_count" ] && echo "$file_count" || echo "NULL"),
$([ -n "$error_message" ] && echo "'$(echo "$error_message" | sed "s/'/''/g")'" || echo "NULL"),
$([ -n "$error_code" ] && echo "'$error_code'" || echo "NULL"),
'$HOSTNAME',
'$SCRIPT_VERSION',
$([ -n "$checksum" ] && echo "'$checksum'" || echo "NULL"),
$([ -n "$started_at" ] && echo "'$started_at'" || echo "NOW()"),
$([ -n "$completed_at" ] && echo "'$completed_at'" || echo "NULL"),
$([ -n "$duration_ms" ] && echo "$duration_ms" || echo "NULL")
);
EOF
}
# Update existing log entry status
db_update_status() {
local source_name="$1"
local started_at="$2"
local status="$3"
local size_bytes="${4:-}"
local error_message="${5:-}"
local error_code="${6:-}"
local checksum="${7:-}"
local file_count="${8:-}"
local start_epoch=$(date -d "$started_at" +%s 2>/dev/null || echo "")
local now_epoch=$(date +%s)
local duration_ms=""
if [[ -n "$start_epoch" ]]; then
duration_ms=$(( (now_epoch - start_epoch) * 1000 ))
fi
PGPASSWORD="$BACKUP_DB_PASSWORD" psql -h "$BACKUP_DB_HOST" -p "$BACKUP_DB_PORT" -U "$BACKUP_DB_USER" -d "$BACKUP_DB_NAME" -q <<EOF
UPDATE backup_logs SET
status = '$status',
completed_at = NOW(),
duration_ms = $([ -n "$duration_ms" ] && echo "$duration_ms" || echo "NULL"),
size_bytes = $([ -n "$size_bytes" ] && echo "$size_bytes" || echo "size_bytes"),
file_count = $([ -n "$file_count" ] && echo "$file_count" || echo "file_count"),
error_message = $([ -n "$error_message" ] && echo "'$(echo "$error_message" | sed "s/'/''/g")'" || echo "error_message"),
error_code = $([ -n "$error_code" ] && echo "'$error_code'" || echo "error_code"),
checksum = $([ -n "$checksum" ] && echo "'$checksum'" || echo "checksum")
WHERE source_name = '$source_name' AND started_at = '$started_at';
EOF
}
# Upload to Azure Blob Storage using azcopy
azure_upload() {
local local_file="$1"
local remote_path="$2"
export AZCOPY_AUTO_LOGIN_TYPE=AZCLI 2>/dev/null || true
# Use SAS token or account key authentication
if [[ -n "$AZURE_STORAGE_KEY" ]]; then
azcopy copy "$local_file" "${remote_path}?sv=2022-11-02&ss=b&srt=co&sp=rwdlaciytfx&se=2030-01-01T00:00:00Z&st=2024-01-01T00:00:00Z&spr=https&sig=placeholder" \
--blob-type BlockBlob \
--overwrite=true \
2>&1
else
# Fallback to az cli
az storage blob upload \
--account-name "$AZURE_STORAGE_ACCOUNT" \
--container-name "$AZURE_STORAGE_CONTAINER" \
--file "$local_file" \
--name "${AZURE_STORAGE_PATH}/$DATE/$(basename "$local_file")" \
--overwrite \
2>&1
fi
}
# Upload using az cli (more reliable)
azure_upload_az() {
local local_file="$1"
local blob_name="$2"
az storage blob upload \
--account-name "$AZURE_STORAGE_ACCOUNT" \
--account-key "$AZURE_STORAGE_KEY" \
--container-name "$AZURE_STORAGE_CONTAINER" \
--file "$local_file" \
--name "$blob_name" \
--overwrite \
--only-show-errors \
2>&1
}
# Backup a single repository
backup_repo() {
local repo_path="$1"
local repo_name=$(echo "$repo_path" | sed "s|$FORGEJO_REPO_PATH/||" | sed 's|\.git$||')
local safe_name=$(echo "$repo_name" | tr '/' '-')
local backup_file="$BACKUP_TEMP_DIR/${safe_name}_${TIMESTAMP}.tar.gz"
local blob_name="${AZURE_STORAGE_PATH}/$DATE/${safe_name}.tar.gz"
local remote_path="${AZURE_BLOB_URL}/$DATE/${safe_name}.tar.gz"
local started_at=$(date '+%Y-%m-%d %H:%M:%S')
log_info "Backing up: $repo_name"
# Log start
db_log "forgejo_repos" "$repo_name" "$repo_path" "azure_blob" "$remote_path" "running" "" "" "" "" "$started_at" ""
# Create tar.gz archive
if ! tar -czf "$backup_file" -C "$(dirname "$repo_path")" "$(basename "$repo_path")" 2>/tmp/backup_error_$$; then
local error_msg=$(cat /tmp/backup_error_$$ 2>/dev/null || echo "Unknown tar error")
rm -f /tmp/backup_error_$$
log_error "Failed to create archive for $repo_name: $error_msg"
db_update_status "$repo_name" "$started_at" "failed" "" "$error_msg" "TAR_FAILED" "" ""
return 1
fi
# Get file info
local size_bytes=$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")
local file_count=$(tar -tzf "$backup_file" 2>/dev/null | wc -l || echo "0")
local checksum=$(sha256sum "$backup_file" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$backup_file" 2>/dev/null | cut -d' ' -f1 || echo "")
# Upload to Azure Blob Storage
if ! azure_upload_az "$backup_file" "$blob_name" 2>/tmp/backup_error_$$; then
local error_msg=$(cat /tmp/backup_error_$$ 2>/dev/null || echo "Unknown Azure upload error")
rm -f /tmp/backup_error_$$
log_error "Failed to upload $repo_name to Azure: $error_msg"
db_update_status "$repo_name" "$started_at" "failed" "$size_bytes" "$error_msg" "AZURE_UPLOAD_FAILED" "" "$file_count"
rm -f "$backup_file"
return 1
fi
rm -f /tmp/backup_error_$$
# Clean up local file
rm -f "$backup_file"
# Log success
db_update_status "$repo_name" "$started_at" "success" "$size_bytes" "" "" "$checksum" "$file_count"
log_info "Successfully backed up: $repo_name ($size_bytes bytes)"
return 0
}
# Clean up old remote backups
cleanup_old_backups() {
log_info "Cleaning up backups older than $BACKUP_RETENTION_DAYS days"
local cutoff_date=$(date -d "$BACKUP_RETENTION_DAYS days ago" +%Y-%m-%d 2>/dev/null || date -v-${BACKUP_RETENTION_DAYS}d +%Y-%m-%d)
# List blobs and filter by date prefix
az storage blob list \
--account-name "$AZURE_STORAGE_ACCOUNT" \
--account-key "$AZURE_STORAGE_KEY" \
--container-name "$AZURE_STORAGE_CONTAINER" \
--prefix "${AZURE_STORAGE_PATH}/" \
--query "[].name" \
--output tsv 2>/dev/null | while read -r blob_name; do
# Extract date from path (format: forgejo/2024-01-15/repo.tar.gz)
local blob_date=$(echo "$blob_name" | grep -oP '\d{4}-\d{2}-\d{2}' | head -1 || echo "")
if [[ -n "$blob_date" && "$blob_date" < "$cutoff_date" ]]; then
log_info "Deleting old backup: $blob_name"
az storage blob delete \
--account-name "$AZURE_STORAGE_ACCOUNT" \
--account-key "$AZURE_STORAGE_KEY" \
--container-name "$AZURE_STORAGE_CONTAINER" \
--name "$blob_name" \
--only-show-errors 2>/dev/null || true
fi
done
}
# Main backup function
main() {
log_info "Starting Forgejo backup (version $SCRIPT_VERSION)"
log_info "Repository path: $FORGEJO_REPO_PATH"
log_info "Destination: Azure Blob Storage ($AZURE_STORAGE_ACCOUNT/$AZURE_STORAGE_CONTAINER)"
# Verify configuration
if [[ ! -d "$FORGEJO_REPO_PATH" ]]; then
log_error "Repository path does not exist: $FORGEJO_REPO_PATH"
exit 1
fi
if [[ -z "$AZURE_STORAGE_ACCOUNT" ]]; then
log_error "AZURE_STORAGE_ACCOUNT is not set"
exit 1
fi
if [[ -z "$AZURE_STORAGE_KEY" ]]; then
log_error "AZURE_STORAGE_KEY is not set"
exit 1
fi
if ! command -v az &> /dev/null; then
log_error "Azure CLI (az) is not installed. Install with: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"
exit 1
fi
if ! command -v psql &> /dev/null; then
log_error "psql is not installed"
exit 1
fi
# Verify Azure connection
if ! az storage container show \
--account-name "$AZURE_STORAGE_ACCOUNT" \
--account-key "$AZURE_STORAGE_KEY" \
--name "$AZURE_STORAGE_CONTAINER" \
--only-show-errors &>/dev/null; then
log_error "Cannot connect to Azure Storage container: $AZURE_STORAGE_CONTAINER"
exit 1
fi
# Create temp directory
mkdir -p "$BACKUP_TEMP_DIR"
# Find and backup all repositories
local total=0
local success=0
local failed=0
while IFS= read -r -d '' repo; do
((total++)) || true
if backup_repo "$repo"; then
((success++)) || true
else
((failed++)) || true
fi
done < <(find "$FORGEJO_REPO_PATH" -maxdepth 3 -type d -name "*.git" -print0 2>/dev/null)
# Cleanup old backups
cleanup_old_backups
# Cleanup temp directory
rmdir "$BACKUP_TEMP_DIR" 2>/dev/null || true
# Summary
log_info "Backup complete: $total total, $success success, $failed failed"
if [[ $failed -gt 0 ]]; then
exit 1
fi
}
# Run main function
main "$@"

View file

@ -0,0 +1,304 @@
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using PlanTempusAdmin.Models;
using BlobType = PlanTempusAdmin.Models.BlobType;
namespace PlanTempusAdmin.Services;
public class AzureStorageService
{
private readonly BlobServiceClient? _serviceClient;
private readonly ILogger<AzureStorageService> _logger;
private readonly string _accountName;
public AzureStorageService(IConfiguration configuration, ILogger<AzureStorageService> logger)
{
_logger = logger;
var connectionString = configuration.GetConnectionString("AzureStorage");
if (!string.IsNullOrEmpty(connectionString))
{
try
{
_serviceClient = new BlobServiceClient(connectionString);
// Extract account name from connection string
var parts = connectionString.Split(';')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1], StringComparer.OrdinalIgnoreCase);
_accountName = parts.TryGetValue("AccountName", out var name) ? name : "unknown";
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize Azure Storage client");
}
}
_accountName ??= string.Empty;
}
public async Task<bool> TestConnectionAsync()
{
if (_serviceClient == null) return false;
try
{
await _serviceClient.GetPropertiesAsync();
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not connect to Azure Storage");
return false;
}
}
public async Task<List<AzureContainer>> GetContainersAsync()
{
if (_serviceClient == null) return new List<AzureContainer>();
var containers = new List<AzureContainer>();
try
{
await foreach (var container in _serviceClient.GetBlobContainersAsync())
{
var containerClient = _serviceClient.GetBlobContainerClient(container.Name);
var stats = await GetContainerStatsAsync(containerClient);
containers.Add(new AzureContainer
{
Name = container.Name,
LastModified = container.Properties.LastModified,
TotalSize = stats.size,
BlobCount = stats.count
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching containers");
}
return containers;
}
public async Task<ContainerDetails> GetContainerDetailsAsync(string containerName, string? prefix = null, int limit = 100)
{
var details = new ContainerDetails { Name = containerName };
if (_serviceClient == null) return details;
try
{
var containerClient = _serviceClient.GetBlobContainerClient(containerName);
var blobs = new List<AzureBlob>();
var prefixes = new HashSet<string>();
await foreach (var item in containerClient.GetBlobsByHierarchyAsync(prefix: prefix, delimiter: "/"))
{
if (item.IsPrefix)
{
prefixes.Add(item.Prefix.TrimEnd('/'));
}
else if (item.Blob != null)
{
blobs.Add(MapToAzureBlob(item.Blob));
details.TotalSize += item.Blob.Properties.ContentLength ?? 0;
details.BlobCount++;
}
if (blobs.Count >= limit) break;
}
details.Blobs = blobs.OrderByDescending(b => b.LastModified).ToList();
details.Prefixes = prefixes.OrderBy(p => p).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching container details for {Container}", containerName);
}
return details;
}
public async Task<List<AzureBlob>> GetBlobsAsync(string containerName, string? prefix = null, int limit = 100)
{
if (_serviceClient == null) return new List<AzureBlob>();
var blobs = new List<AzureBlob>();
try
{
var containerClient = _serviceClient.GetBlobContainerClient(containerName);
await foreach (var blob in containerClient.GetBlobsAsync(prefix: prefix))
{
blobs.Add(MapToAzureBlob(blob));
if (blobs.Count >= limit) break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching blobs from {Container}", containerName);
}
return blobs.OrderByDescending(b => b.LastModified).ToList();
}
public async Task<AzureStorageDashboard> GetDashboardAsync()
{
var dashboard = new AzureStorageDashboard
{
AccountName = _accountName
};
if (_serviceClient == null) return dashboard;
try
{
dashboard.IsConnected = await TestConnectionAsync();
if (!dashboard.IsConnected) return dashboard;
var containers = await GetContainersAsync();
dashboard.Containers = containers;
dashboard.TotalContainers = containers.Count;
dashboard.TotalSize = containers.Sum(c => c.TotalSize);
dashboard.TotalBlobs = containers.Sum(c => c.BlobCount);
// Find backup container(s) and get recent backups
var backupContainers = containers.Where(c =>
c.Name.Contains("backup", StringComparison.OrdinalIgnoreCase) ||
c.Name.Contains("backups", StringComparison.OrdinalIgnoreCase)).ToList();
var recentBlobs = new List<AzureBlob>();
foreach (var container in backupContainers.Take(3))
{
var blobs = await GetBlobsAsync(container.Name, limit: 20);
recentBlobs.AddRange(blobs);
var backupBlobs = blobs.Where(b => b.IsBackup).ToList();
dashboard.BackupFileCount += backupBlobs.Count;
dashboard.BackupTotalSize += backupBlobs.Sum(b => b.Size);
}
dashboard.RecentBlobs = recentBlobs
.OrderByDescending(b => b.LastModified)
.Take(10)
.ToList();
if (dashboard.RecentBlobs.Any())
{
dashboard.LastBackupUpload = dashboard.RecentBlobs
.Where(b => b.IsBackup)
.Max(b => b.LastModified);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error building Azure Storage dashboard");
}
return dashboard;
}
public async Task<string?> GetBlobDownloadUrlAsync(string containerName, string blobName, TimeSpan? expiry = null)
{
if (_serviceClient == null) return null;
try
{
var containerClient = _serviceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
if (!await blobClient.ExistsAsync()) return null;
// Generate SAS token for download
var sasBuilder = new Azure.Storage.Sas.BlobSasBuilder
{
BlobContainerName = containerName,
BlobName = blobName,
Resource = "b",
ExpiresOn = DateTimeOffset.UtcNow.Add(expiry ?? TimeSpan.FromHours(1))
};
sasBuilder.SetPermissions(Azure.Storage.Sas.BlobSasPermissions.Read);
// Check if we can generate SAS (requires account key)
if (blobClient.CanGenerateSasUri)
{
return blobClient.GenerateSasUri(sasBuilder).ToString();
}
return blobClient.Uri.ToString();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating download URL for {Container}/{Blob}", containerName, blobName);
return null;
}
}
public async Task<bool> DeleteBlobAsync(string containerName, string blobName)
{
if (_serviceClient == null) return false;
try
{
var containerClient = _serviceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
var response = await blobClient.DeleteIfExistsAsync();
return response.Value;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting blob {Container}/{Blob}", containerName, blobName);
return false;
}
}
private async Task<(long size, int count)> GetContainerStatsAsync(BlobContainerClient containerClient)
{
long totalSize = 0;
int count = 0;
try
{
await foreach (var blob in containerClient.GetBlobsAsync())
{
totalSize += blob.Properties.ContentLength ?? 0;
count++;
// Limit iteration for large containers
if (count >= 10000) break;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error getting stats for container {Container}", containerClient.Name);
}
return (totalSize, count);
}
private static AzureBlob MapToAzureBlob(BlobItem blob)
{
return new AzureBlob
{
Name = blob.Name,
ContentType = blob.Properties.ContentType,
Size = blob.Properties.ContentLength ?? 0,
LastModified = blob.Properties.LastModified,
CreatedOn = blob.Properties.CreatedOn,
AccessTier = blob.Properties.AccessTier?.ToString(),
BlobType = blob.Properties.BlobType switch
{
Azure.Storage.Blobs.Models.BlobType.Page => BlobType.Page,
Azure.Storage.Blobs.Models.BlobType.Append => BlobType.Append,
_ => BlobType.Block
}
};
}
}

261
Services/BackupService.cs Normal file
View file

@ -0,0 +1,261 @@
using Dapper;
using Npgsql;
using PlanTempusAdmin.Models;
namespace PlanTempusAdmin.Services;
public class BackupService
{
private readonly string _connectionString;
private readonly ILogger<BackupService> _logger;
static BackupService()
{
DefaultTypeMap.MatchNamesWithUnderscores = true;
}
public BackupService(IConfiguration configuration, ILogger<BackupService> logger)
{
_connectionString = configuration.GetConnectionString("BackupDb")
?? throw new InvalidOperationException("BackupDb connection string not configured");
_logger = logger;
}
public async Task<List<BackupLog>> GetLogsAsync(int limit = 100)
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
var logs = await connection.QueryAsync<BackupLog>(@"
SELECT id, started_at, completed_at, duration_ms, backup_type, source_name, source_path,
destination, remote_path, status, size_bytes, file_count, error_message, error_code,
retry_count, hostname, script_version, checksum, created_at
FROM backup_logs
ORDER BY started_at DESC
LIMIT @limit", new { limit });
return logs.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching backup logs");
return new List<BackupLog>();
}
}
public async Task<BackupSummary> GetSummaryAsync()
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
var summary = await connection.QuerySingleOrDefaultAsync<BackupSummary>(@"
SELECT
COUNT(*)::int as total_backups,
COUNT(*) FILTER (WHERE status = 'success')::int as successful_backups,
COUNT(*) FILTER (WHERE status = 'failed')::int as failed_backups,
MAX(started_at) as last_backup,
MAX(started_at) FILTER (WHERE status = 'success') as last_successful_backup,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success'), 0) as total_size_bytes
FROM backup_logs");
return summary ?? new BackupSummary();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching backup summary");
return new BackupSummary();
}
}
public async Task<List<RepositorySummary>> GetRepositorySummariesAsync()
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
var summaries = await connection.QueryAsync<RepositorySummary>(@"
WITH ranked_backups AS (
SELECT
source_name,
backup_type,
size_bytes,
started_at,
status,
ROW_NUMBER() OVER (PARTITION BY source_name ORDER BY started_at DESC) as rn
FROM backup_logs
WHERE status = 'success'
)
SELECT
bl.source_name,
bl.backup_type,
COUNT(*)::int as total_backups,
COUNT(*) FILTER (WHERE bl.status = 'success')::int as successful_backups,
COUNT(*) FILTER (WHERE bl.status = 'failed')::int as failed_backups,
MAX(bl.started_at) as last_backup,
MAX(bl.started_at) FILTER (WHERE bl.status = 'success') as last_successful_backup,
COALESCE(SUM(bl.size_bytes) FILTER (WHERE bl.status = 'success'), 0) as total_size_bytes,
(SELECT rb.size_bytes FROM ranked_backups rb WHERE rb.source_name = bl.source_name AND rb.rn = 1) as last_backup_size_bytes,
(SELECT rb.size_bytes FROM ranked_backups rb WHERE rb.source_name = bl.source_name AND rb.rn = 2) as previous_backup_size_bytes
FROM backup_logs bl
GROUP BY bl.source_name, bl.backup_type
ORDER BY last_backup DESC NULLS LAST");
return summaries.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching repository summaries");
return new List<RepositorySummary>();
}
}
public async Task<BackupDashboard> GetDashboardAsync()
{
var dashboard = new BackupDashboard();
try
{
await using var connection = new NpgsqlConnection(_connectionString);
// Overall stats
var stats = await connection.QuerySingleOrDefaultAsync<dynamic>(@"
SELECT
COUNT(*)::int as total_backups,
COUNT(*) FILTER (WHERE status = 'success')::int as successful_backups,
COUNT(*) FILTER (WHERE status = 'failed')::int as failed_backups,
COUNT(*) FILTER (WHERE status = 'running')::int as running_backups,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success'), 0) as total_size_bytes,
MAX(started_at) as last_backup,
MAX(started_at) FILTER (WHERE status = 'success') as last_successful_backup,
COUNT(*) FILTER (WHERE started_at > NOW() - INTERVAL '24 hours')::int as backups_last_24_hours,
COUNT(*) FILTER (WHERE started_at > NOW() - INTERVAL '7 days')::int as backups_last_7_days,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success' AND started_at > NOW() - INTERVAL '24 hours'), 0) as size_last_24_hours,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success' AND started_at > NOW() - INTERVAL '7 days'), 0) as size_last_7_days
FROM backup_logs");
if (stats != null)
{
dashboard.TotalBackups = (int)stats.total_backups;
dashboard.SuccessfulBackups = (int)stats.successful_backups;
dashboard.FailedBackups = (int)stats.failed_backups;
dashboard.RunningBackups = (int)stats.running_backups;
dashboard.TotalSizeBytes = (long)stats.total_size_bytes;
dashboard.LastBackup = stats.last_backup;
dashboard.LastSuccessfulBackup = stats.last_successful_backup;
dashboard.BackupsLast24Hours = (int)stats.backups_last_24_hours;
dashboard.BackupsLast7Days = (int)stats.backups_last_7_days;
dashboard.SizeLast24Hours = (long)stats.size_last_24_hours;
dashboard.SizeLast7Days = (long)stats.size_last_7_days;
}
// By backup type
dashboard.ByType = (await connection.QueryAsync<BackupTypeStat>(@"
SELECT
backup_type,
COUNT(*)::int as total,
COUNT(*) FILTER (WHERE status = 'success')::int as successful,
COUNT(*) FILTER (WHERE status = 'failed')::int as failed,
COUNT(*) FILTER (WHERE status = 'running')::int as running,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success'), 0) as total_size,
MAX(started_at) as last_backup
FROM backup_logs
GROUP BY backup_type
ORDER BY total DESC")).ToList();
// By destination
dashboard.ByDestination = (await connection.QueryAsync<DestinationStat>(@"
SELECT
destination,
COUNT(*)::int as total,
COUNT(*) FILTER (WHERE status = 'success')::int as successful,
COUNT(*) FILTER (WHERE status = 'failed')::int as failed,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success'), 0) as total_size,
MAX(started_at) as last_backup
FROM backup_logs
GROUP BY destination
ORDER BY total DESC")).ToList();
// By host
dashboard.ByHost = (await connection.QueryAsync<HostStat>(@"
SELECT
COALESCE(hostname, 'unknown') as hostname,
COUNT(*)::int as total,
COUNT(*) FILTER (WHERE status = 'success')::int as successful,
COUNT(*) FILTER (WHERE status = 'failed')::int as failed,
COUNT(*) FILTER (WHERE status = 'running')::int as running,
MAX(started_at) as last_backup,
(SELECT script_version FROM backup_logs b2
WHERE COALESCE(b2.hostname, 'unknown') = COALESCE(backup_logs.hostname, 'unknown')
ORDER BY started_at DESC LIMIT 1) as script_version
FROM backup_logs
GROUP BY COALESCE(hostname, 'unknown')
ORDER BY total DESC")).ToList();
// Top errors
dashboard.TopErrors = (await connection.QueryAsync<ErrorStat>(@"
SELECT
COALESCE(error_code, 'UNKNOWN') as error_code,
COUNT(*)::int as count,
MAX(started_at) as last_occurrence,
(SELECT error_message FROM backup_logs b2
WHERE COALESCE(b2.error_code, 'UNKNOWN') = COALESCE(backup_logs.error_code, 'UNKNOWN')
AND b2.status = 'failed'
ORDER BY started_at DESC LIMIT 1) as last_message
FROM backup_logs
WHERE status = 'failed'
GROUP BY COALESCE(error_code, 'UNKNOWN')
ORDER BY count DESC
LIMIT 5")).ToList();
// Daily stats (last 14 days)
dashboard.DailyStats = (await connection.QueryAsync<DailyStat>(@"
SELECT
DATE(started_at) as date,
COUNT(*)::int as total,
COUNT(*) FILTER (WHERE status = 'success')::int as successful,
COUNT(*) FILTER (WHERE status = 'failed')::int as failed,
COALESCE(SUM(size_bytes) FILTER (WHERE status = 'success'), 0) as total_size
FROM backup_logs
WHERE started_at > NOW() - INTERVAL '14 days'
GROUP BY DATE(started_at)
ORDER BY date DESC")).ToList();
// Running now
dashboard.RunningNow = (await connection.QueryAsync<BackupLog>(@"
SELECT * FROM backup_logs
WHERE status = 'running'
ORDER BY started_at DESC")).ToList();
// Recent successes
dashboard.RecentSuccesses = (await connection.QueryAsync<BackupLog>(@"
SELECT * FROM backup_logs
WHERE status = 'success'
ORDER BY started_at DESC
LIMIT 5")).ToList();
// Recent failures
dashboard.RecentFailures = (await connection.QueryAsync<BackupLog>(@"
SELECT * FROM backup_logs
WHERE status = 'failed'
ORDER BY started_at DESC
LIMIT 5")).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching backup dashboard");
}
return dashboard;
}
public async Task<bool> TestConnectionAsync()
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not connect to backup database");
return false;
}
}
}

132
Services/CaddyService.cs Normal file
View file

@ -0,0 +1,132 @@
using System.Text.Json;
using PlanTempusAdmin.Models;
namespace PlanTempusAdmin.Services;
public class CaddyService
{
private readonly HttpClient _httpClient;
private readonly ILogger<CaddyService> _logger;
private readonly string _caddyAdminUrl;
public CaddyService(HttpClient httpClient, ILogger<CaddyService> logger, IConfiguration configuration)
{
_httpClient = httpClient;
_logger = logger;
_caddyAdminUrl = configuration.GetValue<string>("Caddy:AdminUrl") ?? "http://localhost:2019";
}
public async Task<bool> IsRunningAsync()
{
try
{
var response = await _httpClient.GetAsync($"{_caddyAdminUrl}/config/");
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not connect to Caddy Admin API");
return false;
}
}
public async Task<CaddyConfig?> GetConfigAsync()
{
try
{
var response = await _httpClient.GetAsync($"{_caddyAdminUrl}/config/");
if (!response.IsSuccessStatusCode)
{
return null;
}
var json = await response.Content.ReadAsStringAsync();
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
return JsonSerializer.Deserialize<CaddyConfig>(json, options);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Caddy config");
return null;
}
}
public async Task<List<CaddyHost>> GetHostsAsync()
{
var hosts = new List<CaddyHost>();
try
{
var config = await GetConfigAsync();
if (config?.Apps?.Http?.Servers == null)
{
return hosts;
}
foreach (var server in config.Apps.Http.Servers.Values)
{
if (server.Routes == null) continue;
foreach (var route in server.Routes)
{
if (route.Match == null) continue;
foreach (var match in route.Match)
{
if (match.Host == null) continue;
foreach (var hostname in match.Host)
{
var host = new CaddyHost
{
Hostname = hostname,
Addresses = server.Listen ?? Array.Empty<string>(),
Tls = server.Listen?.Any(l => l.Contains(":443")) ?? false
};
// Try to get upstream from handlers
if (route.Handle != null)
{
var reverseProxy = route.Handle.FirstOrDefault(h => h.Handler == "reverse_proxy");
if (reverseProxy?.Upstreams?.Length > 0)
{
host.Upstream = reverseProxy.Upstreams[0].Dial;
}
}
hosts.Add(host);
}
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing Caddy hosts");
}
return hosts;
}
public async Task<string?> GetRawConfigAsync()
{
try
{
var response = await _httpClient.GetAsync($"{_caddyAdminUrl}/config/");
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadAsStringAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching raw Caddy config");
return null;
}
}
}

232
Services/ForgejoService.cs Normal file
View file

@ -0,0 +1,232 @@
using Dapper;
using Npgsql;
using PlanTempusAdmin.Models;
namespace PlanTempusAdmin.Services;
public class ForgejoService
{
private readonly string _connectionString;
private readonly ILogger<ForgejoService> _logger;
static ForgejoService()
{
DefaultTypeMap.MatchNamesWithUnderscores = true;
}
public ForgejoService(IConfiguration configuration, ILogger<ForgejoService> logger)
{
_connectionString = configuration.GetConnectionString("ForgejoDb")
?? throw new InvalidOperationException("ForgejoDb connection string not configured");
_logger = logger;
}
public async Task<bool> TestConnectionAsync()
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not connect to Forgejo database");
return false;
}
}
public async Task<ForgejoDashboard> GetDashboardAsync()
{
var dashboard = new ForgejoDashboard();
try
{
await using var connection = new NpgsqlConnection(_connectionString);
// Repository stats
var repoStats = await connection.QuerySingleOrDefaultAsync<dynamic>(@"
SELECT
COUNT(*)::int as total,
COUNT(*) FILTER (WHERE NOT is_private)::int as public_repos,
COUNT(*) FILTER (WHERE is_private)::int as private_repos,
COUNT(*) FILTER (WHERE is_fork)::int as forked,
COUNT(*) FILTER (WHERE is_archived)::int as archived,
COUNT(*) FILTER (WHERE is_mirror)::int as mirrors,
COALESCE(SUM(size), 0) as total_size,
COALESCE(SUM(num_stars), 0)::int as total_stars,
COALESCE(SUM(num_forks), 0)::int as total_forks,
COALESCE(SUM(num_issues - num_closed_issues), 0)::int as open_issues,
COALESCE(SUM(num_pulls - num_closed_pulls), 0)::int as open_prs
FROM repository");
if (repoStats != null)
{
dashboard.TotalRepos = (int)repoStats.total;
dashboard.PublicRepos = (int)repoStats.public_repos;
dashboard.PrivateRepos = (int)repoStats.private_repos;
dashboard.ForkedRepos = (int)repoStats.forked;
dashboard.ArchivedRepos = (int)repoStats.archived;
dashboard.MirrorRepos = (int)repoStats.mirrors;
dashboard.TotalSize = (long)repoStats.total_size;
dashboard.TotalStars = (int)repoStats.total_stars;
dashboard.TotalForks = (int)repoStats.total_forks;
dashboard.TotalOpenIssues = (int)repoStats.open_issues;
dashboard.TotalOpenPRs = (int)repoStats.open_prs;
}
// Actions stats
var actionStats = await connection.QuerySingleOrDefaultAsync<dynamic>(@"
SELECT
COUNT(*)::int as total,
COUNT(*) FILTER (WHERE TO_TIMESTAMP(created) >= NOW() - INTERVAL '1 day')::int as today,
COUNT(*) FILTER (WHERE TO_TIMESTAMP(created) >= NOW() - INTERVAL '7 days')::int as this_week,
COUNT(*) FILTER (WHERE status = 3)::int as successful,
COUNT(*) FILTER (WHERE status = 4)::int as failed,
COUNT(*) FILTER (WHERE status = 2)::int as running
FROM action_run");
if (actionStats != null)
{
dashboard.TotalRuns = (int)actionStats.total;
dashboard.RunsToday = (int)actionStats.today;
dashboard.RunsThisWeek = (int)actionStats.this_week;
dashboard.SuccessfulRuns = (int)actionStats.successful;
dashboard.FailedRunsCount = (int)actionStats.failed;
dashboard.RunningNow = (int)actionStats.running;
}
// Recently updated repos
dashboard.RecentlyUpdated = await GetRepositoriesAsync(connection, "ORDER BY r.updated_unix DESC LIMIT 5");
// Largest repos
dashboard.LargestRepos = await GetRepositoriesAsync(connection, "ORDER BY r.size DESC LIMIT 5");
// Recent action runs
dashboard.RecentRuns = await GetActionRunsAsync(connection, "ORDER BY ar.created DESC LIMIT 10");
// Failed runs
dashboard.FailedRuns = await GetActionRunsAsync(connection, "WHERE ar.status = 4 ORDER BY ar.created DESC LIMIT 5");
// Running now
dashboard.RunningRuns = await GetActionRunsAsync(connection, "WHERE ar.status = 2 ORDER BY ar.started LIMIT 10");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Forgejo dashboard");
}
return dashboard;
}
public async Task<List<ForgejoRepository>> GetAllRepositoriesAsync()
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
return await GetRepositoriesAsync(connection, "ORDER BY LOWER(u.name), LOWER(r.name)");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching repositories");
return new List<ForgejoRepository>();
}
}
public async Task<List<ForgejoActionRun>> GetAllActionRunsAsync(int limit = 100)
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
return await GetActionRunsAsync(connection, $"ORDER BY ar.created DESC LIMIT {limit}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching action runs");
return new List<ForgejoActionRun>();
}
}
public async Task<List<ForgejoActionStats>> GetActionStatsAsync()
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
var stats = await connection.QueryAsync<ForgejoActionStats>(@"
SELECT
ar.workflow_id,
r.name as repo_name,
COUNT(*)::int as total_runs,
COUNT(*) FILTER (WHERE ar.status = 3)::int as successful,
COUNT(*) FILTER (WHERE ar.status = 4)::int as failed,
TO_TIMESTAMP(MAX(ar.created)) as last_run,
AVG(ar.stopped - ar.started) FILTER (WHERE ar.stopped > 0 AND ar.started > 0) as avg_duration_seconds
FROM action_run ar
JOIN repository r ON ar.repo_id = r.id
GROUP BY ar.workflow_id, r.name
ORDER BY total_runs DESC");
return stats.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching action stats");
return new List<ForgejoActionStats>();
}
}
private async Task<List<ForgejoRepository>> GetRepositoriesAsync(NpgsqlConnection connection, string orderClause)
{
var repos = await connection.QueryAsync<ForgejoRepository>($@"
SELECT
r.id,
u.name as owner_name,
r.name,
r.description,
r.is_private,
r.is_fork,
r.is_archived,
r.is_mirror,
r.num_stars,
r.num_forks,
r.num_watches,
r.num_issues,
r.num_closed_issues,
r.num_pulls,
r.num_closed_pulls,
r.size,
TO_TIMESTAMP(r.created_unix) as created_at,
TO_TIMESTAMP(r.updated_unix) as updated_at
FROM repository r
JOIN ""user"" u ON r.owner_id = u.id
{orderClause}");
return repos.ToList();
}
private async Task<List<ForgejoActionRun>> GetActionRunsAsync(NpgsqlConnection connection, string whereOrderClause)
{
var runs = await connection.QueryAsync<ForgejoActionRun>($@"
SELECT
ar.id,
ar.repo_id,
r.name as repo_name,
u.name as owner_name,
ar.workflow_id,
ar.""index"",
COALESCE(tu.name, '') as trigger_user,
ar.ref,
ar.commit_sha,
ar.event,
ar.title,
ar.status,
CASE WHEN ar.started > 0 THEN TO_TIMESTAMP(ar.started) ELSE NULL END as started,
CASE WHEN ar.stopped > 0 THEN TO_TIMESTAMP(ar.stopped) ELSE NULL END as stopped,
TO_TIMESTAMP(ar.created) as created,
TO_TIMESTAMP(ar.updated) as updated
FROM action_run ar
JOIN repository r ON ar.repo_id = r.id
JOIN ""user"" u ON r.owner_id = u.id
LEFT JOIN ""user"" tu ON ar.trigger_user_id = tu.id
{whereOrderClause}");
return runs.ToList();
}
}

View file

@ -0,0 +1,9 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

17
appsettings.json Normal file
View file

@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"BackupDb": "Host=192.168.1.43:5432;Database=ptadmin;Username=plantempus_app;Password=39113911",
"ForgejoDb": "Host=192.168.1.43:5432;Database=forgejo;Username=plantempus_app;Password=39113911",
"AzureStorage": "DefaultEndpointsProtocol=https;AccountName=storageptadmin;AccountKey=cn+oahPdOwnt4Ph4M+H6/O3jd1bYx3LHBlTclPWOdM+LFuCYUiKMaSgYFIbbsewksuH7csEfbCYQ+AStYokrDQ==;EndpointSuffix=core.windows.net"
},
"Caddy": {
"AdminUrl": "http://localhost:2019"
}
}

340
wwwroot/css/site.css Normal file
View file

@ -0,0 +1,340 @@
/* PlanTempusAdmin - Technical/Terminal Style */
:root {
--bg-color: #fafafa;
--text-color: #1a1a1a;
--border-color: #d0d0d0;
--border-color-dark: #999;
--menu-bg: #f5f5f5;
--hover-bg: #eaeaea;
--accent-color: #0066cc;
--success-color: #28a745;
--warning-color: #f0a500;
--danger-color: #dc3545;
--muted-color: #666;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.5;
background-color: var(--bg-color);
color: var(--text-color);
}
/* Layout */
.app-container {
display: flex;
min-height: 100vh;
}
/* Sidebar Menu */
.sidebar {
width: 200px;
background-color: var(--menu-bg);
border-right: 1px solid var(--border-color);
position: fixed;
height: 100vh;
overflow-y: auto;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
font-weight: bold;
font-size: 14px;
}
.sidebar-nav {
padding: 8px 0;
}
.nav-section {
margin-bottom: 8px;
}
.nav-section-title {
padding: 8px 16px 4px;
font-size: 10px;
text-transform: uppercase;
color: var(--muted-color);
letter-spacing: 0.5px;
}
.nav-link {
display: block;
padding: 8px 16px;
margin: 2px 8px;
color: var(--text-color);
text-decoration: none;
border-radius: var(--radius-sm);
transition: all 0.15s ease;
}
.nav-link:hover {
background-color: var(--hover-bg);
}
.nav-link.active {
background-color: var(--hover-bg);
font-weight: bold;
}
/* Main Content */
.main-content {
flex: 1;
margin-left: 200px;
padding: 24px;
}
/* Page Header */
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.page-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 4px;
}
.page-subtitle {
font-size: 11px;
color: var(--muted-color);
}
/* Cards */
.card {
background-color: #fff;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: 16px;
box-shadow: var(--shadow);
overflow: hidden;
}
.card-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
font-weight: bold;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
background-color: var(--menu-bg);
}
.card-body {
padding: 16px;
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.status-item {
background-color: #fff;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow);
}
.status-label {
font-size: 10px;
text-transform: uppercase;
color: var(--muted-color);
margin-bottom: 4px;
}
.status-value {
font-size: 20px;
font-weight: bold;
}
.status-value.success { color: var(--success-color); }
.status-value.warning { color: var(--warning-color); }
.status-value.danger { color: var(--danger-color); }
/* Buttons - Outline Style */
.btn {
display: inline-block;
padding: 6px 14px;
font-family: inherit;
font-size: 11px;
text-decoration: none;
background-color: transparent;
border: 1px solid var(--border-color-dark);
border-radius: var(--radius-sm);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.btn:hover {
background-color: var(--text-color);
color: var(--bg-color);
}
.btn-primary {
border-color: var(--accent-color);
color: var(--accent-color);
}
.btn-primary:hover {
background-color: var(--accent-color);
color: #fff;
}
.btn-success {
border-color: var(--success-color);
color: var(--success-color);
}
.btn-success:hover {
background-color: var(--success-color);
color: #fff;
}
.btn-danger {
border-color: var(--danger-color);
color: var(--danger-color);
}
.btn-danger:hover {
background-color: var(--danger-color);
color: #fff;
}
/* Tables */
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 11px;
}
.table th,
.table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.table th {
font-weight: bold;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
background-color: var(--menu-bg);
}
.table th:first-child {
border-top-left-radius: var(--radius-sm);
}
.table th:last-child {
border-top-right-radius: var(--radius-sm);
}
.table tbody tr:last-child td:first-child {
border-bottom-left-radius: var(--radius-sm);
}
.table tbody tr:last-child td:last-child {
border-bottom-right-radius: var(--radius-sm);
}
.table tbody tr:hover {
background-color: var(--hover-bg);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Status Badges */
.badge {
display: inline-block;
padding: 3px 8px;
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
border: 1px solid;
border-radius: var(--radius-sm);
}
.badge-success {
border-color: var(--success-color);
color: var(--success-color);
}
.badge-warning {
border-color: var(--warning-color);
color: var(--warning-color);
}
.badge-danger {
border-color: var(--danger-color);
color: var(--danger-color);
}
/* Utility Classes */
.text-muted { color: var(--muted-color); }
.text-success { color: var(--success-color); }
.text-warning { color: var(--warning-color); }
.text-danger { color: var(--danger-color); }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
/* Code/Pre */
pre, code {
font-family: inherit;
background-color: var(--menu-bg);
padding: 2px 6px;
font-size: 11px;
border-radius: var(--radius-sm);
}
pre {
padding: 14px;
overflow-x: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
/* Loading Indicator */
.loading {
color: var(--muted-color);
font-style: italic;
}
.loading::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%, 20% { content: ''; }
40% { content: '.'; }
60% { content: '..'; }
80%, 100% { content: '...'; }
}

BIN
wwwroot/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

58
wwwroot/js/site.js Normal file
View file

@ -0,0 +1,58 @@
// PlanTempusAdmin - Site JavaScript
document.addEventListener('DOMContentLoaded', () => {
// Set active nav link based on current path
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
const href = link.getAttribute('href');
if (href === currentPath || (currentPath === '/' && href === '/')) {
link.classList.add('active');
}
});
});
// Utility function for API calls
async function fetchApi(url, options = {}) {
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
// Format date for display
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('da-DK', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Format bytes to human readable
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}