diff --git a/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue b/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue
index 40cd3ba..3785c4f 100644
--- a/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue
+++ b/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue
@@ -94,14 +94,14 @@ import ReaderPage from './ReaderPage.vue';
});
};
- // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
+ // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus
const getPlaceholderHeight = (page) => {
const dims = page?.dimensions;
- if (!dims?.width || !dims?.height) return 800;
+ if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom);
const displayWidth = windowWidth.value < 1200
? Math.min(dims.width, windowWidth.value * 0.95)
: Math.min(dims.width, 1200);
- return Math.round((dims.height / dims.width) * displayWidth);
+ return Math.round((dims.height / dims.width) * displayWidth * props.zoom);
};
const setupObservers = () => {
diff --git a/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue b/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue
index 36d88db..2cce8a6 100644
--- a/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue
+++ b/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue
@@ -87,13 +87,9 @@ import { useReaderStore } from '../../application/store/readerStore';
const store = useReaderStore();
- // En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
- // En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
+ // zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll)
const containerStyle = computed(() => {
- if (store.readingMode === 'single') {
- return { zoom: props.zoom };
- }
- return { transform: `scale(${props.zoom})` };
+ return { zoom: props.zoom };
});
const imageRef = ref(null);
diff --git a/assets/vue/app/domain/system/application/store/statusStore.js b/assets/vue/app/domain/system/application/store/statusStore.js
new file mode 100644
index 0000000..10fe8b6
--- /dev/null
+++ b/assets/vue/app/domain/system/application/store/statusStore.js
@@ -0,0 +1,26 @@
+import { defineStore } from 'pinia';
+import { ApiStatusRepository } from '../../infrastructure/api/ApiStatusRepository';
+
+const statusRepository = new ApiStatusRepository();
+
+export const useStatusStore = defineStore('system-status', {
+ state: () => ({
+ status: null,
+ loading: false,
+ error: null,
+ }),
+
+ actions: {
+ async loadStatus() {
+ this.loading = true;
+ this.error = null;
+ try {
+ this.status = await statusRepository.getStatus();
+ } catch (e) {
+ this.error = e.message ?? 'Erreur lors du chargement du statut système';
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+});
diff --git a/assets/vue/app/domain/system/infrastructure/api/ApiStatusRepository.js b/assets/vue/app/domain/system/infrastructure/api/ApiStatusRepository.js
new file mode 100644
index 0000000..c56d89c
--- /dev/null
+++ b/assets/vue/app/domain/system/infrastructure/api/ApiStatusRepository.js
@@ -0,0 +1,13 @@
+export class ApiStatusRepository {
+ async getStatus() {
+ const response = await fetch('/api/system/status', {
+ headers: { Accept: 'application/json' },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Erreur HTTP ${response.status}`);
+ }
+
+ return response.json();
+ }
+}
diff --git a/assets/vue/app/domain/system/presentation/components/ChaptersStatusCard.vue b/assets/vue/app/domain/system/presentation/components/ChaptersStatusCard.vue
new file mode 100644
index 0000000..c66de2d
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/components/ChaptersStatusCard.vue
@@ -0,0 +1,36 @@
+
+
+
+ {{ status.totalChapters }}
+ total
+
+
+ {{ status.downloadedChapters }} téléchargés
+ {{ downloadedPercent }}%
+
+
+ {{ status.pendingChapters }} en attente
+
+
+
+
diff --git a/assets/vue/app/domain/system/presentation/components/JobsStatusCard.vue b/assets/vue/app/domain/system/presentation/components/JobsStatusCard.vue
new file mode 100644
index 0000000..a1aade4
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/components/JobsStatusCard.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Taux de succès
+
+ {{ status.successRateLast24h }}%
+
+
+
+
+
+
+
+
+
+
+
+
Taux de succès
+
+ {{ status.successRateLast7d }}%
+
+
+
+
+
+
+
+
diff --git a/assets/vue/app/domain/system/presentation/components/MangasStatusCard.vue b/assets/vue/app/domain/system/presentation/components/MangasStatusCard.vue
new file mode 100644
index 0000000..0b22004
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/components/MangasStatusCard.vue
@@ -0,0 +1,33 @@
+
+
+
+ {{ status.totalMangas }}
+ total
+ {{ status.monitoredMangas }} suivis
+
+
+
+ {{ label }}: {{ count }}
+
+ Aucun statut disponible
+
+
+
+
+
diff --git a/assets/vue/app/domain/system/presentation/components/SourcesStatusCard.vue b/assets/vue/app/domain/system/presentation/components/SourcesStatusCard.vue
new file mode 100644
index 0000000..0dc5168
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/components/SourcesStatusCard.vue
@@ -0,0 +1,41 @@
+
+
+
+ {{ status.totalSources }}
+ sources configurées
+
+
+
+ {{ health }}: {{ count }}
+
+ Aucune source
+
+
+
+
+
diff --git a/assets/vue/app/domain/system/presentation/components/StatusCard.vue b/assets/vue/app/domain/system/presentation/components/StatusCard.vue
new file mode 100644
index 0000000..1b93ecd
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/components/StatusCard.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/assets/vue/app/domain/system/presentation/components/StorageStatusCard.vue b/assets/vue/app/domain/system/presentation/components/StorageStatusCard.vue
new file mode 100644
index 0000000..8971b11
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/components/StorageStatusCard.vue
@@ -0,0 +1,40 @@
+
+
+
+ {{ status.storageUsedHuman }}
+ utilisés
+
+
+ {{ status.storageFreeHuman }} libres
+ {{ usedPercent }}%
+
+
+ Total : {{ status.storageTotalHuman }}
+ {{ status.storagePath }}
+
+
+
+
diff --git a/assets/vue/app/domain/system/presentation/components/SystemInfoCard.vue b/assets/vue/app/domain/system/presentation/components/SystemInfoCard.vue
new file mode 100644
index 0000000..4057818
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/components/SystemInfoCard.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
- Version PHP
+ - {{ status.phpVersion }}
+
+
+
Généré le
+ {{ formattedDate }}
+
+
+
+
+
+
diff --git a/assets/vue/app/domain/system/presentation/pages/StatusPage.vue b/assets/vue/app/domain/system/presentation/pages/StatusPage.vue
new file mode 100644
index 0000000..ba60117
--- /dev/null
+++ b/assets/vue/app/domain/system/presentation/pages/StatusPage.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/vue/app/router/index.js b/assets/vue/app/router/index.js
index 89f1b3a..edbf340 100644
--- a/assets/vue/app/router/index.js
+++ b/assets/vue/app/router/index.js
@@ -11,24 +11,9 @@ import ScrapperConfigurations from '../domain/setting/presentation/pages/Scrappe
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
import LogsPage from '../domain/system/presentation/pages/LogsPage.vue';
+import StatusPage from '../domain/system/presentation/pages/StatusPage.vue';
import Layout from '../shared/components/layout/Layout.vue';
-// Placeholder component for new routes
-const PlaceholderComponent = {
- props: {
- title: {
- type: String,
- required: true
- }
- },
- template: `
-
-
{{ title }}
-
Cette fonctionnalité sera bientôt disponible.
-
- `
-};
-
const routes = [
{
path: '/',
@@ -66,13 +51,6 @@ const routes = [
name: 'import',
component: NewImportPage
},
- // Pages placeholder avec chargement différé
- {
- path: '/manga/import',
- name: 'manga-import',
- component: PlaceholderComponent,
- props: { title: 'Import de bibliothèque' }
- },
{
path: '/manga/discover',
name: 'discover',
@@ -91,21 +69,7 @@ const routes = [
// Paramètres
{
path: '/settings',
- name: 'settings',
- component: PlaceholderComponent,
- props: { title: 'Paramètres' }
- },
- {
- path: '/settings/general',
- name: 'settings-general',
- component: PlaceholderComponent,
- props: { title: 'Paramètres généraux' }
- },
- {
- path: '/settings/folders',
- name: 'settings-folders',
- component: PlaceholderComponent,
- props: { title: 'Gestion des dossiers' }
+ redirect: '/settings/scrappers',
},
{
path: '/settings/scrappers',
@@ -130,33 +94,18 @@ const routes = [
// Système
{
path: '/system',
- name: 'system',
- component: PlaceholderComponent,
- props: { title: 'Système' }
+ redirect: '/system/status',
},
{
path: '/system/status',
name: 'system-status',
- component: PlaceholderComponent,
- props: { title: 'Status du système' }
- },
- {
- path: '/system/backup',
- name: 'system-backup',
- component: PlaceholderComponent,
- props: { title: 'Sauvegarde' }
+ component: StatusPage,
},
{
path: '/system/logs',
name: 'system-logs',
component: LogsPage,
},
- {
- path: '/system/updates',
- name: 'system-updates',
- component: PlaceholderComponent,
- props: { title: 'Mises à jour' }
- }
]
}
];
diff --git a/assets/vue/app/shared/components/layout/Sidebar.vue b/assets/vue/app/shared/components/layout/Sidebar.vue
index 16fa2ed..aec7e24 100644
--- a/assets/vue/app/shared/components/layout/Sidebar.vue
+++ b/assets/vue/app/shared/components/layout/Sidebar.vue
@@ -78,11 +78,9 @@ import MenuGroup from './sidebar/MenuGroup.vue';
{
icon: Cog6ToothIcon,
text: 'Paramètres',
- to: '/settings',
+ to: '/settings/scrappers',
id: 'settings',
subItems: [
- { icon: null, text: 'Général', to: '/settings/general' },
- { icon: null, text: 'Dossiers', to: '/settings/folders' },
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' },
{ icon: null, text: 'UI', to: '/settings/ui' }
]
@@ -90,13 +88,11 @@ import MenuGroup from './sidebar/MenuGroup.vue';
{
icon: ComputerDesktopIcon,
text: 'Système',
- to: '/system',
+ to: '/system/status',
id: 'system',
subItems: [
{ icon: null, text: 'Status', to: '/system/status' },
- { icon: null, text: 'Backup', to: '/system/backup' },
{ icon: null, text: 'Logs', to: '/system/logs' },
- { icon: null, text: 'Updates', to: '/system/updates' }
]
}
];
diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml
index d4be244..1299de5 100644
--- a/config/packages/api_platform.yaml
+++ b/config/packages/api_platform.yaml
@@ -34,5 +34,6 @@ api_platform:
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
+ - '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource'
patch_formats:
json: ['application/merge-patch+json']
diff --git a/config/services.yaml b/config/services.yaml
index 0b10b2b..7713181 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -200,3 +200,12 @@ services:
# Import Domain API Platform Services
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~
+
+ # System Domain
+ App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface:
+ alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository
+
+ App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler:
+ arguments:
+ $mangaDataPath: '%env(resolve:MANGA_DATA_PATH)%'
+ $imagesStoragePath: '%kernel.project_dir%/public/images'
diff --git a/src/Domain/System/Application/Query/GetSystemStatusQuery.php b/src/Domain/System/Application/Query/GetSystemStatusQuery.php
new file mode 100644
index 0000000..5120521
--- /dev/null
+++ b/src/Domain/System/Application/Query/GetSystemStatusQuery.php
@@ -0,0 +1,7 @@
+modify('-24 hours');
+ $last7d = $now->modify('-7 days');
+
+ $totalJobs = $this->jobRepository->countByCriteria([]);
+ $completedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED]);
+ $failedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED]);
+ $pendingJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::PENDING]);
+ $inProgressJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::IN_PROGRESS]);
+
+ $totalJobsLast24h = $this->jobRepository->countByCriteria(['createdAfter' => $last24h]);
+ $completedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last24h]);
+ $failedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last24h]);
+
+ $totalJobsLast7d = $this->jobRepository->countByCriteria(['createdAfter' => $last7d]);
+ $completedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last7d]);
+ $failedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last7d]);
+
+ $storagePath = $this->imagesStoragePath;
+ $storageTotalBytes = (int) (@disk_total_space($storagePath) ?: 0);
+ $storageFreeBytes = (int) (@disk_free_space($storagePath) ?: 0);
+ $storageUsedBytes = $this->computeDirectorySize($storagePath);
+
+ return new SystemStatus(
+ totalMangas: $this->systemStatusRepository->countMangas(),
+ monitoredMangas: $this->systemStatusRepository->countMonitoredMangas(),
+ mangasByStatus: $this->systemStatusRepository->countMangasByStatus(),
+ totalChapters: $this->systemStatusRepository->countChapters(),
+ downloadedChapters: $this->systemStatusRepository->countDownloadedChapters(),
+ totalJobs: $totalJobs,
+ completedJobs: $completedJobs,
+ failedJobs: $failedJobs,
+ pendingJobs: $pendingJobs,
+ inProgressJobs: $inProgressJobs,
+ totalJobsLast24h: $totalJobsLast24h,
+ completedJobsLast24h: $completedJobsLast24h,
+ failedJobsLast24h: $failedJobsLast24h,
+ totalJobsLast7d: $totalJobsLast7d,
+ completedJobsLast7d: $completedJobsLast7d,
+ failedJobsLast7d: $failedJobsLast7d,
+ storagePath: $this->mangaDataPath,
+ storageTotalBytes: $storageTotalBytes,
+ storageFreeBytes: $storageFreeBytes,
+ storageUsedBytes: $storageUsedBytes,
+ totalSources: $this->systemStatusRepository->countContentSources(),
+ sourcesByHealth: $this->systemStatusRepository->countContentSourcesByHealth(),
+ phpVersion: PHP_VERSION,
+ generatedAt: $now,
+ );
+ }
+
+ private function computeDirectorySize(string $path): int
+ {
+ if (!is_dir($path)) {
+ return 0;
+ }
+
+ $size = 0;
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $size += $file->getSize();
+ }
+ }
+
+ return $size;
+ }
+}
diff --git a/src/Domain/System/Domain/Contract/Repository/SystemStatusRepositoryInterface.php b/src/Domain/System/Domain/Contract/Repository/SystemStatusRepositoryInterface.php
new file mode 100644
index 0000000..2b48390
--- /dev/null
+++ b/src/Domain/System/Domain/Contract/Repository/SystemStatusRepositoryInterface.php
@@ -0,0 +1,22 @@
+ */
+ public function countMangasByStatus(): array;
+
+ public function countChapters(): int;
+
+ public function countDownloadedChapters(): int;
+
+ public function countContentSources(): int;
+
+ /** @return array */
+ public function countContentSourcesByHealth(): array;
+}
diff --git a/src/Domain/System/Domain/Model/SystemStatus.php b/src/Domain/System/Domain/Model/SystemStatus.php
new file mode 100644
index 0000000..f85573b
--- /dev/null
+++ b/src/Domain/System/Domain/Model/SystemStatus.php
@@ -0,0 +1,44 @@
+ */
+ public array $mangasByStatus,
+ // Chapitres
+ public int $totalChapters,
+ public int $downloadedChapters,
+ // Jobs global
+ public int $totalJobs,
+ public int $completedJobs,
+ public int $failedJobs,
+ public int $pendingJobs,
+ public int $inProgressJobs,
+ // Jobs 24h
+ public int $totalJobsLast24h,
+ public int $completedJobsLast24h,
+ public int $failedJobsLast24h,
+ // Jobs 7j
+ public int $totalJobsLast7d,
+ public int $completedJobsLast7d,
+ public int $failedJobsLast7d,
+ // Stockage
+ public string $storagePath,
+ public int $storageTotalBytes,
+ public int $storageFreeBytes,
+ public int $storageUsedBytes,
+ // Sources
+ public int $totalSources,
+ /** @var array */
+ public array $sourcesByHealth,
+ // Système
+ public string $phpVersion,
+ public \DateTimeImmutable $generatedAt,
+ ) {
+ }
+}
diff --git a/src/Domain/System/Infrastructure/ApiPlatform/Resource/GetSystemStatusResource.php b/src/Domain/System/Infrastructure/ApiPlatform/Resource/GetSystemStatusResource.php
new file mode 100644
index 0000000..b814004
--- /dev/null
+++ b/src/Domain/System/Infrastructure/ApiPlatform/Resource/GetSystemStatusResource.php
@@ -0,0 +1,56 @@
+ */
+ public array $mangasByStatus = [];
+ public int $totalChapters = 0;
+ public int $downloadedChapters = 0;
+ public int $pendingChapters = 0;
+ public int $totalJobs = 0;
+ public int $completedJobs = 0;
+ public int $failedJobs = 0;
+ public int $pendingJobs = 0;
+ public int $inProgressJobs = 0;
+ public int $totalJobsLast24h = 0;
+ public int $completedJobsLast24h = 0;
+ public int $failedJobsLast24h = 0;
+ public float $successRateLast24h = 0.0;
+ public int $totalJobsLast7d = 0;
+ public int $completedJobsLast7d = 0;
+ public int $failedJobsLast7d = 0;
+ public float $successRateLast7d = 0.0;
+ public string $storagePath = '';
+ public int $storageTotalBytes = 0;
+ public int $storageFreeBytes = 0;
+ public int $storageUsedBytes = 0;
+ public string $storageTotalHuman = '';
+ public string $storageFreeHuman = '';
+ public string $storageUsedHuman = '';
+ public int $totalSources = 0;
+ /** @var array */
+ public array $sourcesByHealth = [];
+ public string $phpVersion = '';
+ public string $generatedAt = '';
+}
diff --git a/src/Domain/System/Infrastructure/ApiPlatform/State/Provider/GetSystemStatusStateProvider.php b/src/Domain/System/Infrastructure/ApiPlatform/State/Provider/GetSystemStatusStateProvider.php
new file mode 100644
index 0000000..122dd90
--- /dev/null
+++ b/src/Domain/System/Infrastructure/ApiPlatform/State/Provider/GetSystemStatusStateProvider.php
@@ -0,0 +1,88 @@
+handler->handle(new GetSystemStatusQuery());
+
+ return $this->toResource($response);
+ }
+
+ private function toResource(SystemStatus $response): GetSystemStatusResource
+ {
+ $resource = new GetSystemStatusResource();
+ $resource->id = 'current';
+
+ $resource->totalMangas = $response->totalMangas;
+ $resource->monitoredMangas = $response->monitoredMangas;
+ $resource->mangasByStatus = $response->mangasByStatus;
+
+ $resource->totalChapters = $response->totalChapters;
+ $resource->downloadedChapters = $response->downloadedChapters;
+ $resource->pendingChapters = $response->totalChapters - $response->downloadedChapters;
+
+ $resource->totalJobs = $response->totalJobs;
+ $resource->completedJobs = $response->completedJobs;
+ $resource->failedJobs = $response->failedJobs;
+ $resource->pendingJobs = $response->pendingJobs;
+ $resource->inProgressJobs = $response->inProgressJobs;
+
+ $resource->totalJobsLast24h = $response->totalJobsLast24h;
+ $resource->completedJobsLast24h = $response->completedJobsLast24h;
+ $resource->failedJobsLast24h = $response->failedJobsLast24h;
+ $resource->successRateLast24h = $response->totalJobsLast24h > 0
+ ? round($response->completedJobsLast24h / $response->totalJobsLast24h * 100, 1)
+ : 0.0;
+
+ $resource->totalJobsLast7d = $response->totalJobsLast7d;
+ $resource->completedJobsLast7d = $response->completedJobsLast7d;
+ $resource->failedJobsLast7d = $response->failedJobsLast7d;
+ $resource->successRateLast7d = $response->totalJobsLast7d > 0
+ ? round($response->completedJobsLast7d / $response->totalJobsLast7d * 100, 1)
+ : 0.0;
+
+ $resource->storagePath = $response->storagePath;
+ $resource->storageTotalBytes = $response->storageTotalBytes;
+ $resource->storageFreeBytes = $response->storageFreeBytes;
+ $resource->storageUsedBytes = $response->storageUsedBytes;
+ $resource->storageTotalHuman = $this->formatBytes($response->storageTotalBytes);
+ $resource->storageFreeHuman = $this->formatBytes($response->storageFreeBytes);
+ $resource->storageUsedHuman = $this->formatBytes($response->storageUsedBytes);
+
+ $resource->totalSources = $response->totalSources;
+ $resource->sourcesByHealth = $response->sourcesByHealth;
+
+ $resource->phpVersion = $response->phpVersion;
+ $resource->generatedAt = $response->generatedAt->format(\DateTimeInterface::ATOM);
+
+ return $resource;
+ }
+
+ private function formatBytes(int $bytes): string
+ {
+ if ($bytes <= 0) {
+ return '0 B';
+ }
+
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ $exp = (int) floor(log($bytes, 1024));
+ $exp = min($exp, count($units) - 1);
+
+ return round($bytes / (1024 ** $exp), 2) . ' ' . $units[$exp];
+ }
+}
diff --git a/src/Domain/System/Infrastructure/Persistence/Repository/DoctrineSystemStatusRepository.php b/src/Domain/System/Infrastructure/Persistence/Repository/DoctrineSystemStatusRepository.php
new file mode 100644
index 0000000..0ccc5f2
--- /dev/null
+++ b/src/Domain/System/Infrastructure/Persistence/Repository/DoctrineSystemStatusRepository.php
@@ -0,0 +1,81 @@
+entityManager
+ ->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m')
+ ->getSingleScalarResult();
+ }
+
+ public function countMonitoredMangas(): int
+ {
+ return (int) $this->entityManager
+ ->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m WHERE m.monitored = true')
+ ->getSingleScalarResult();
+ }
+
+ public function countMangasByStatus(): array
+ {
+ $results = $this->entityManager
+ ->createQuery('SELECT m.status, COUNT(m) as cnt FROM ' . Manga::class . ' m GROUP BY m.status')
+ ->getResult();
+
+ $counts = [];
+ foreach ($results as $row) {
+ $status = $row['status'] ?? 'unknown';
+ $counts[$status] = (int) $row['cnt'];
+ }
+
+ return $counts;
+ }
+
+ public function countChapters(): int
+ {
+ return (int) $this->entityManager
+ ->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c')
+ ->getSingleScalarResult();
+ }
+
+ public function countDownloadedChapters(): int
+ {
+ return (int) $this->entityManager
+ ->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c WHERE c.cbzPath IS NOT NULL')
+ ->getSingleScalarResult();
+ }
+
+ public function countContentSources(): int
+ {
+ return (int) $this->entityManager
+ ->createQuery('SELECT COUNT(s) FROM ' . ContentSource::class . ' s')
+ ->getSingleScalarResult();
+ }
+
+ public function countContentSourcesByHealth(): array
+ {
+ $results = $this->entityManager
+ ->createQuery('SELECT s.healthStatus, COUNT(s) as cnt FROM ' . ContentSource::class . ' s GROUP BY s.healthStatus')
+ ->getResult();
+
+ $counts = [];
+ foreach ($results as $row) {
+ $counts[$row['healthStatus']] = (int) $row['cnt'];
+ }
+
+ return $counts;
+ }
+}