[iOS] Admin Dashboard - User Activity (#1485)

* Very Very WIP

* Details page.

TODOs:

- Duplicate ViewModels are initialized.
- Routing Cleanup
- Localizations for fields
- Get Played Item Details (See ActiveSessionDetails)
- Move all details to ActivityDetailsViewModel for Users & Items
- Localizations for enums
- Enum the types if possible

* Details View complete. TODO:

- Filters
- Default with No Filters

* Ready

* Fix localization

* cleanup

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-04-12 22:42:48 -06:00 committed by GitHub
parent 715ddca793
commit d4330f130b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 835 additions and 63 deletions

View File

@ -17,6 +17,13 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
@Root
var start = makeStart
// MARK: - Route: User Activity
@Route(.push)
var activity = makeActivityLogs
@Route(.push)
var activityDetails = makeActivityDetails
// MARK: - Route: Active Sessions
@Route(.push)
@ -84,6 +91,18 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
@Route(.push)
var apiKeys = makeAPIKeys
// MARK: - Views: User Activity
@ViewBuilder
func makeActivityLogs() -> some View {
ServerActivityView()
}
@ViewBuilder
func makeActivityDetails(viewModel: ServerActivityDetailViewModel) -> some View {
ServerActivityDetailsView(viewModel: viewModel)
}
// MARK: - Views: Active Sessions
@ViewBuilder

View File

@ -0,0 +1,24 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension ActivityLogEntry: Poster {
var displayTitle: String {
name ?? L10n.unknown
}
var unwrappedIDHashOrZero: Int {
id?.hashValue ?? 0
}
var systemImage: String {
"text.document"
}
}

View File

@ -0,0 +1,54 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension LogLevel: SystemImageable, Displayable {
public var color: Color {
switch self {
case .trace:
return .gray.opacity(0.7)
case .debug:
return .gray
case .information:
return .blue
case .warning:
return .orange
case .error:
return .red
case .critical:
return .purple
case .none:
return .secondary
}
}
public var systemImage: String {
switch self {
case .trace:
return "ant"
case .debug:
return "ladybug"
case .information:
return "info.circle"
case .warning:
return "exclamationmark.triangle"
case .error:
return "exclamationmark.circle"
case .critical:
return "xmark.octagon"
case .none:
return "questionmark.circle"
}
}
public var displayTitle: String {
rawValue
}
}

View File

@ -14,6 +14,10 @@ extension Sequence {
filter { $0[keyPath: keyPath] != nil }
}
func first<V: Equatable>(property: (Element) -> V, equalTo value: V) -> Element? {
first { property($0) == value }
}
func intersection<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
filter { other.contains($0[keyPath: keyPath]) }
}
@ -58,6 +62,10 @@ extension Sequence {
extension Sequence where Element: Equatable {
func first(equalTo other: Element) -> Element? {
first { $0 == other }
}
/// Returns an array containing the elements of the sequence that
/// are also within the given sequence.
func intersection(_ other: some Sequence<Element>) -> [Element] {

View File

@ -40,6 +40,8 @@ internal enum L10n {
internal static let active = L10n.tr("Localizable", "active", fallback: "Active")
/// Activity
internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity")
/// Activity log
internal static let activityLog = L10n.tr("Localizable", "activityLog", fallback: "Activity log")
/// Actor
internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor")
/// Add
@ -392,6 +394,8 @@ internal enum L10n {
internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard")
/// Perform administrative tasks for your Jellyfin server.
internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.")
/// Date
internal static let date = L10n.tr("Localizable", "date", fallback: "Date")
/// Date Added
internal static let dateAdded = L10n.tr("Localizable", "dateAdded", fallback: "Date Added")
/// Date created
@ -760,6 +764,8 @@ internal enum L10n {
internal static let letterer = L10n.tr("Localizable", "letterer", fallback: "Letterer")
/// Letter Picker
internal static let letterPicker = L10n.tr("Localizable", "letterPicker", fallback: "Letter Picker")
/// Level
internal static let level = L10n.tr("Localizable", "level", fallback: "Level")
/// Library
internal static let library = L10n.tr("Localizable", "library", fallback: "Library")
/// Light
@ -1130,6 +1136,8 @@ internal enum L10n {
internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset")
/// Reset all settings back to defaults.
internal static let resetAllSettings = L10n.tr("Localizable", "resetAllSettings", fallback: "Reset all settings back to defaults.")
/// Reset the filter values to none.
internal static let resetFilterFooter = L10n.tr("Localizable", "resetFilterFooter", fallback: "Reset the filter values to none.")
/// Reset Settings
internal static let resetSettings = L10n.tr("Localizable", "resetSettings", fallback: "Reset Settings")
/// Reset Swiftfin user settings

View File

@ -0,0 +1,104 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Combine
import JellyfinAPI
final class ServerActivityDetailViewModel: ViewModel, Stateful {
// MARK: - Action
enum Action: Equatable {
case refresh
}
// MARK: - State
enum State: Hashable {
case error(JellyfinAPIError)
case initial
}
// MARK: - Stateful Variables
@Published
var backgroundStates: Set<BackgroundState> = []
@Published
var state: State = .initial
// MARK: - Published Variables
@Published
var log: ActivityLogEntry
@Published
var user: UserDto?
@Published
var item: BaseItemDto?
// MARK: - Cancellable
private var getActivityCancellable: AnyCancellable?
// MARK: - Initialize
init(log: ActivityLogEntry, user: UserDto?) {
self.log = log
self.user = user
}
// MARK: - Respond
func respond(to action: Action) -> State {
switch action {
case .refresh:
getActivityCancellable?.cancel()
getActivityCancellable = Task {
do {
if let itemID = log.itemID {
self.item = try await getItem(for: itemID)
} else {
self.item = nil
}
if let userID = log.userID {
self.user = try await getUser(for: userID)
} else {
self.user = nil
}
} catch {
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}
}
.asAnyCancellable()
return .initial
}
}
// MARK: - Get the Activity's Item
private func getItem(for itemID: String) async throws -> BaseItemDto? {
let request = Paths.getItem(itemID: itemID)
let response = try await userSession.client.send(request)
return response.value
}
// MARK: - Get the Activity's User
private func getUser(for userID: String) async throws -> UserDto? {
let request = Paths.getUserByID(userID: userID)
let response = try await userSession.client.send(request)
return response.value
}
}

View File

@ -0,0 +1,83 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Combine
import Foundation
import IdentifiedCollections
import JellyfinAPI
@MainActor
final class ServerActivityViewModel: PagingLibraryViewModel<ActivityLogEntry> {
@Published
var hasUserId: Bool? {
didSet {
self.send(.refresh)
}
}
@Published
var minDate: Date? {
didSet {
self.send(.refresh)
}
}
private(set) var users: IdentifiedArrayOf<UserDto> = []
private var userTask: AnyCancellable?
override func respond(to action: Action) -> State {
switch action {
case .refresh:
userTask?.cancel()
userTask = Task {
do {
let users = try await getUsers()
print("here")
await MainActor.run {
self.users = users
_ = super.respond(to: action)
}
} catch {
await MainActor.run {
self.send(.error(.init(L10n.unknownError)))
}
}
}
.asAnyCancellable()
return .refreshing
default:
return super.respond(to: action)
}
}
override func get(page: Int) async throws -> [ActivityLogEntry] {
var parameters = Paths.GetLogEntriesParameters()
parameters.limit = pageSize
parameters.hasUserID = hasUserId
parameters.minDate = minDate
parameters.startIndex = page * pageSize
let request = Paths.getLogEntries(parameters: parameters)
let response = try await userSession.client.send(request)
return response.value.items ?? []
}
private func getUsers() async throws -> IdentifiedArrayOf<UserDto> {
let request = Paths.getUsers()
let response = try await userSession.client.send(request)
return IdentifiedArray(uniqueElements: response.value)
}
}

View File

@ -10,7 +10,6 @@ import Combine
import Foundation
import IdentifiedCollections
import JellyfinAPI
import OrderedCollections
import SwiftUI
final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {

View File

@ -59,6 +59,16 @@
4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; };
4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; };
4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; };
4E2CE3882DA424CE0004736A /* ServerActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */; };
4E2CE38A2DA426720004736A /* ActivityLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */; };
4E2CE38B2DA426720004736A /* ActivityLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */; };
4E2CE38E2DA427880004736A /* ServerActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE38D2DA427870004736A /* ServerActivityView.swift */; };
4E2CE3912DA42B320004736A /* ServerActivityEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */; };
4E2CE3932DA432C00004736A /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3922DA432BC0004736A /* LogLevel.swift */; };
4E2CE3942DA432C00004736A /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3922DA432BC0004736A /* LogLevel.swift */; };
4E2CE3982DA446900004736A /* ServerActivityDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */; };
4E2CE39B2DA479BC0004736A /* ServerActivityDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */; };
4E2CE39F2DA4962A0004736A /* MediaItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE39E2DA496270004736A /* MediaItemSection.swift */; };
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */; };
4E31EFA52CFFFB690053DFE7 /* EditItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */; };
4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE562CBED3F300DBD886 /* TimeRow.swift */; };
@ -1318,6 +1328,14 @@
4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = "<group>"; };
4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityViewModel.swift; sourceTree = "<group>"; };
4E2CE3892DA426710004736A /* ActivityLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogEntry.swift; sourceTree = "<group>"; };
4E2CE38D2DA427870004736A /* ServerActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityView.swift; sourceTree = "<group>"; };
4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityEntry.swift; sourceTree = "<group>"; };
4E2CE3922DA432BC0004736A /* LogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = "<group>"; };
4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityDetailsView.swift; sourceTree = "<group>"; };
4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityDetailViewModel.swift; sourceTree = "<group>"; };
4E2CE39E2DA496270004736A /* MediaItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItemSection.swift; sourceTree = "<group>"; };
4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementRow.swift; sourceTree = "<group>"; };
4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementView.swift; sourceTree = "<group>"; };
4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekRow.swift; sourceTree = "<group>"; };
@ -2308,6 +2326,8 @@
4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */,
E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */,
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */,
4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */,
4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */,
E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */,
4EC50D602C934B3A00FC3D0E /* ServerTasksViewModel.swift */,
4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */,
@ -2370,6 +2390,40 @@
path = MediaComponents;
sourceTree = "<group>";
};
4E2CE38C2DA427790004736A /* ServerActivity */ = {
isa = PBXGroup;
children = (
4E2CE3962DA446810004736A /* ServerActivityDetailsView */,
4E2CE3952DA446730004736A /* ServerActivityView */,
);
path = ServerActivity;
sourceTree = "<group>";
};
4E2CE38F2DA42B250004736A /* Components */ = {
isa = PBXGroup;
children = (
4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */,
);
path = Components;
sourceTree = "<group>";
};
4E2CE3952DA446730004736A /* ServerActivityView */ = {
isa = PBXGroup;
children = (
4E2CE38F2DA42B250004736A /* Components */,
4E2CE38D2DA427870004736A /* ServerActivityView.swift */,
);
path = ServerActivityView;
sourceTree = "<group>";
};
4E2CE3962DA446810004736A /* ServerActivityDetailsView */ = {
isa = PBXGroup;
children = (
4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */,
);
path = ServerActivityDetailsView;
sourceTree = "<group>";
};
4E31EF972CFFB9B70053DFE7 /* Components */ = {
isa = PBXGroup;
children = (
@ -2551,6 +2605,7 @@
4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */,
4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */,
E1DE64902CC6F06C00E423B6 /* Components */,
4E2CE38C2DA427790004736A /* ServerActivity */,
4EFE80852D3EF8270029CCB6 /* ServerDevices */,
4E35CE622CBED3FF00DBD886 /* ServerLogsView */,
4ECF5D8C2D0A780F00F066B1 /* ServerTasks */,
@ -5073,6 +5128,7 @@
isa = PBXGroup;
children = (
4E49DED12CE54D6900352DCD /* ActiveSessionsPolicy.swift */,
4E2CE3892DA426710004736A /* ActivityLogEntry.swift */,
E1D37F5B2B9CF02600343D2B /* BaseItemDto */,
E1343DAC2D4EE4C8003145A8 /* BaseItemKind.swift */,
E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */,
@ -5093,6 +5149,7 @@
E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */,
E12A9EF729499E0100731C3A /* JellyfinClient.swift */,
4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */,
4E2CE3922DA432BC0004736A /* LogLevel.swift */,
4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */,
E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */,
E122A9122788EAAD0060FA63 /* MediaStream.swift */,
@ -5383,6 +5440,7 @@
isa = PBXGroup;
children = (
E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */,
4E2CE39E2DA496270004736A /* MediaItemSection.swift */,
4E10C81C2CC0465F0012CC9F /* UserSection.swift */,
);
path = Components;
@ -6004,6 +6062,7 @@
E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */,
E187A60529AD2E25008387E6 /* StepperView.swift in Sources */,
E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */,
4E2CE38B2DA426720004736A /* ActivityLogEntry.swift in Sources */,
4EF36F652D962A430065BB79 /* ItemSortBy.swift in Sources */,
E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */,
E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
@ -6019,6 +6078,7 @@
E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */,
E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
E1DD55382B6EE533007501C0 /* Task.swift in Sources */,
4E2CE3942DA432C00004736A /* LogLevel.swift in Sources */,
E1575EA1293E7B1E001665B1 /* String.swift in Sources */,
4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */,
E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */,
@ -6503,6 +6563,7 @@
4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */,
4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */,
4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */,
4E2CE39B2DA479BC0004736A /* ServerActivityDetailViewModel.swift in Sources */,
4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */,
4E661A162CEFE46300025C99 /* ParentialRatingsSection.swift in Sources */,
4E661A172CEFE46300025C99 /* OverviewSection.swift in Sources */,
@ -6527,6 +6588,7 @@
4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */,
E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */,
E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */,
4E2CE3982DA446900004736A /* ServerActivityDetailsView.swift in Sources */,
E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */,
E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */,
E145EB452BE0AD4E003BF6F3 /* Set.swift in Sources */,
@ -6673,9 +6735,11 @@
E168BD10289A4162001A6922 /* HomeView.swift in Sources */,
4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */,
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */,
4E2CE3932DA432C00004736A /* LogLevel.swift in Sources */,
4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */,
E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */,
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */,
4E2CE39F2DA4962A0004736A /* MediaItemSection.swift in Sources */,
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */,
E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */,
E1401CB129386C9200E8B599 /* UIColor.swift in Sources */,
@ -6716,6 +6780,7 @@
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
E1CB75822C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */,
4ECF5D8A2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */,
4E2CE38E2DA427880004736A /* ServerActivityView.swift in Sources */,
E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */,
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */,
@ -6783,6 +6848,7 @@
4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */,
4E49DED32CE54D6D00352DCD /* ActiveSessionsPolicy.swift in Sources */,
4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */,
4E2CE3882DA424CE0004736A /* ServerActivityViewModel.swift in Sources */,
4E35CE5F2CBED3F300DBD886 /* IntervalRow.swift in Sources */,
4E35CE602CBED3F300DBD886 /* DayOfWeekRow.swift in Sources */,
4E35CE612CBED3F300DBD886 /* TimeLimitSection.swift in Sources */,
@ -6922,6 +6988,7 @@
E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */,
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */,
4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */,
4E2CE38A2DA426720004736A /* ActivityLogEntry.swift in Sources */,
535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */,
E1D27EE72BBC955F00152D16 /* UnmaskSecureField.swift in Sources */,
E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */,
@ -6963,6 +7030,7 @@
E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */,
E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */,
4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */,
4E2CE3912DA42B320004736A /* ServerActivityEntry.swift in Sources */,
E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */,
E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */,
E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */,

View File

@ -53,7 +53,7 @@ struct ActiveSessionDetailView: View {
) -> some View {
List {
nowPlayingSection(item: nowPlayingItem)
AdminDashboardView.MediaItemSection(item: nowPlayingItem)
Section(L10n.progress) {
ActiveSessionsView.ProgressSection(
@ -101,67 +101,6 @@ struct ActiveSessionDetailView: View {
}
}
// MARK: Now Playing Section
@ViewBuilder
private func nowPlayingSection(item: BaseItemDto) -> some View {
Section {
HStack(alignment: .bottom, spacing: 12) {
Group {
if item.type == .audio {
ZStack {
Color.clear
ImageView(item.squareImageSources(maxWidth: 60))
.failure {
SystemImageContentView(systemName: item.systemImage)
}
}
.squarePosterStyle()
} else {
ZStack {
Color.clear
ImageView(item.portraitImageSources(maxWidth: 60))
.failure {
SystemImageContentView(systemName: item.systemImage)
}
}
.posterStyle(.portrait)
}
}
.frame(width: 100)
.accessibilityIgnoresInvertColors()
VStack(alignment: .leading) {
if let parent = item.parentTitle {
Text(parent)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Text(item.displayTitle)
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.lineLimit(2)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(.bottom)
}
}
.listRowBackground(Color.clear)
.listRowCornerRadius(0)
.listRowInsets(.zero)
}
var body: some View {
ZStack {
if let session = box.value {

View File

@ -28,6 +28,9 @@ struct AdminDashboardView: View {
}
Section(L10n.activity) {
ChevronButton(L10n.activity) {
router.route(to: \.activity)
}
ChevronButton(L10n.devices) {
router.route(to: \.devices)
}

View File

@ -0,0 +1,76 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension AdminDashboardView {
struct MediaItemSection: View {
let item: BaseItemDto
var body: some View {
Section {
HStack(alignment: .bottom, spacing: 12) {
Group {
if item.type == .audio {
ZStack {
Color.clear
ImageView(item.squareImageSources(maxWidth: 60))
.failure {
SystemImageContentView(systemName: item.systemImage)
}
}
.squarePosterStyle()
} else {
ZStack {
Color.clear
ImageView(item.portraitImageSources(maxWidth: 60))
.failure {
SystemImageContentView(systemName: item.systemImage)
}
}
.posterStyle(.portrait)
}
}
.frame(width: 100)
.accessibilityIgnoresInvertColors()
VStack(alignment: .leading) {
if let parent = item.parentTitle {
Text(parent)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Text(item.displayTitle)
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.lineLimit(2)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(.bottom)
}
}
.listRowBackground(Color.clear)
.listRowCornerRadius(0)
.listRowInsets(.zero)
}
}
}

View File

@ -0,0 +1,87 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct ServerActivityDetailsView: View {
// MARK: - Environment Objects
@EnvironmentObject
private var router: AdminDashboardCoordinator.Router
// MARK: - Activity Log Entry Variable
@StateObject
var viewModel: ServerActivityDetailViewModel
// MARK: - Body
var body: some View {
List {
/// Item (If Available)
if let item = viewModel.item {
AdminDashboardView.MediaItemSection(item: item)
}
/// User (If Available)
if let user = viewModel.user {
AdminDashboardView.UserSection(
user: user,
lastActivityDate: viewModel.log.date
) {
router.route(to: \.userDetails, user)
}
}
/// Event Name & Overview
Section(L10n.overview) {
if let name = viewModel.log.name, name.isNotEmpty {
Text(name)
}
if let overview = viewModel.log.overview, overview.isNotEmpty {
Text(overview)
} else if let shortOverview = viewModel.log.shortOverview, shortOverview.isNotEmpty {
Text(shortOverview)
}
}
/// Event Details
Section(L10n.details) {
if let severity = viewModel.log.severity {
TextPairView(
leading: L10n.level,
trailing: severity.displayTitle
)
}
if let type = viewModel.log.type {
TextPairView(
leading: L10n.type,
trailing: type
)
}
if let date = viewModel.log.date {
TextPairView(
leading: L10n.date,
trailing: date.formatted(date: .long, time: .shortened)
)
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(
L10n.activityLog
.localizedCapitalized
)
.navigationBarTitleDisplayMode(.inline)
.onFirstAppear {
viewModel.send(.refresh)
}
}
}

View File

@ -0,0 +1,102 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension ServerActivityView {
struct LogEntry: View {
// MARK: - Activity Log Entry Variable
@StateObject
var viewModel: ServerActivityDetailViewModel
// MARK: - Action Variable
let action: () -> Void
// MARK: - Body
var body: some View {
ListRow {
userImage
} content: {
rowContent
.padding(.bottom, 8)
}
.onSelect(perform: action)
}
// MARK: - User Image
@ViewBuilder
private var userImage: some View {
let imageSource = viewModel.user?.profileImageSource(client: viewModel.userSession.client, maxWidth: 60) ?? .init()
UserProfileImage(
userID: viewModel.log.userID ?? viewModel.userSession?.user.id,
source: imageSource
) {
SystemImageContentView(
systemName: viewModel.user != nil ? "person.fill" : "gearshape.fill",
ratio: 0.5
)
}
.frame(width: 60, height: 60)
}
// MARK: - User Image
@ViewBuilder
private var rowContent: some View {
HStack {
VStack(alignment: .leading) {
/// Event Severity & Username / System
HStack(spacing: 8) {
Image(systemName: viewModel.log.severity?.systemImage ?? "questionmark.circle")
.foregroundStyle(viewModel.log.severity?.color ?? .gray)
if viewModel.user != nil {
Text(viewModel.user?.name ?? L10n.unknown)
} else {
Text(L10n.system)
}
}
.font(.headline)
/// Event Name
Text(viewModel.log.name ?? .emptyDash)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
Group {
if let eventDate = viewModel.log.date {
Text(eventDate.formatted(date: .abbreviated, time: .standard))
} else {
Text(String.emptyTime)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.padding()
.font(.body.weight(.regular))
.foregroundStyle(.secondary)
}
}
}
}

View File

@ -0,0 +1,198 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import CollectionVGrid
import JellyfinAPI
import SwiftUI
struct ServerActivityView: View {
// MARK: - Environment Objects
@EnvironmentObject
private var router: AdminDashboardCoordinator.Router
// MARK: - State Objects
@StateObject
private var viewModel = ServerActivityViewModel()
// MARK: - Dialog States
@State
private var isDatePickerShowing: Bool = false
@State
private var tempDate: Date?
// MARK: - Body
var body: some View {
ZStack {
switch viewModel.state {
case .content:
contentView
case let .error(error):
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
case .initial, .refreshing:
DelayedProgressView()
}
}
.animation(.linear(duration: 0.2), value: viewModel.state)
.navigationTitle(L10n.activity)
.navigationBarTitleDisplayMode(.inline)
.navigationBarMenuButton(
isLoading: viewModel.backgroundStates.contains(.gettingNextPage)
) {
Section(L10n.filters) {
startDateButton
userFilterButton
}
}
.onFirstAppear {
viewModel.send(.refresh)
}
.sheet(isPresented: $isDatePickerShowing, onDismiss: { isDatePickerShowing = false }) {
startDatePickerSheet
}
}
// MARK: - Content View
@ViewBuilder
private var contentView: some View {
if viewModel.elements.isEmpty {
Text(L10n.none)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.listRowSeparator(.hidden)
.listRowInsets(.zero)
} else {
CollectionVGrid(
uniqueElements: viewModel.elements,
id: \.unwrappedIDHashOrZero,
layout: .columns(1)
) { log in
let user = viewModel.users.first(
property: \.id,
equalTo: log.userID
)
let logViewModel = ServerActivityDetailViewModel(
log: log,
user: user
)
LogEntry(viewModel: logViewModel) {
router.route(to: \.activityDetails, logViewModel)
}
}
.onReachedBottomEdge(offset: .offset(300)) {
viewModel.send(.getNextPage)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - User Filter Button
@ViewBuilder
private var userFilterButton: some View {
Menu(
L10n.type,
systemImage: viewModel.hasUserId == true ? "person.fill" :
viewModel.hasUserId == false ? "gearshape.fill" : "line.3.horizontal"
) {
Picker(L10n.type, selection: $viewModel.hasUserId) {
Section {
Label(
L10n.all,
systemImage: "line.3.horizontal"
)
.tag(nil as Bool?)
}
Label(
L10n.users,
systemImage: "person"
)
.tag(true as Bool?)
Label(
L10n.system,
systemImage: "gearshape"
)
.tag(false as Bool?)
}
}
}
// MARK: - Start Date Button
@ViewBuilder
private var startDateButton: some View {
Button(L10n.startDate, systemImage: "calendar") {
if let minDate = viewModel.minDate {
tempDate = minDate
} else {
tempDate = .now
}
isDatePickerShowing = true
}
}
// MARK: - Start Date Picker Sheet
@ViewBuilder
private var startDatePickerSheet: some View {
NavigationView {
List {
Section {
DatePicker(
L10n.date,
selection: $tempDate.coalesce(.now),
in: ...Date.now,
displayedComponents: .date
)
.datePickerStyle(.graphical)
.labelsHidden()
}
/// Reset button to remove the filter
if viewModel.minDate != nil {
Section {
ListRowButton(L10n.reset, role: .destructive) {
viewModel.minDate = nil
isDatePickerShowing = false
}
} footer: {
Text(L10n.resetFilterFooter)
}
}
}
.navigationTitle(L10n.startDate.localizedCapitalized)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
isDatePickerShowing = false
}
.topBarTrailing {
let startOfDay = Calendar.current
.startOfDay(for: tempDate ?? .now)
Button(L10n.save) {
viewModel.minDate = startOfDay
isDatePickerShowing = false
}
.buttonStyle(.toolbarPill)
.disabled(viewModel.minDate != nil && startOfDay == viewModel.minDate)
}
}
}
}