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