diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js
index a4b3ed792..d7544d4dc 100644
--- a/frontend/src/app/routes.js
+++ b/frontend/src/app/routes.js
@@ -34,6 +34,7 @@ import People from "page/people.vue";
import Library from "page/library.vue";
import Settings from "page/settings.vue";
import Admin from "page/admin.vue";
+import Cluster from "page/cluster.vue";
import Login from "page/auth/login.vue";
import Discover from "page/discover.vue";
import About from "page/about/about.vue";
@@ -113,6 +114,26 @@ export default [
}
},
},
+ {
+ name: "cluster",
+ path: "/cluster/:pathMatch(.*)*",
+ component: Cluster,
+ meta: {
+ title: $gettext("Cluster"),
+ requiresAuth: true,
+ settings: false,
+ background: "background",
+ },
+ beforeEnter: (to, from, next) => {
+ if ($session.loginRequired()) {
+ next({ name: loginRoute });
+ } else if ($config.deny("cluster", "access_all")) {
+ next({ name: $session.getDefaultRoute() });
+ } else {
+ next();
+ }
+ },
+ },
{
name: "upgrade",
path: "/upgrade",
diff --git a/frontend/src/common/config.js b/frontend/src/common/config.js
index ff5373125..a4203e274 100644
--- a/frontend/src/common/config.js
+++ b/frontend/src/common/config.js
@@ -668,6 +668,10 @@ export default class Config {
// getDefaultRoute returns the default route to use after login or in case of routing errors.
getDefaultRoute() {
+ if (this.isPortal()) {
+ return "cluster";
+ }
+
const albumsRoute = "albums";
const browseRoute = "browse";
const defaultRoute = this.deny("photos", "access_library") ? albumsRoute : browseRoute;
diff --git a/frontend/src/model/logs.js b/frontend/src/model/logs.js
new file mode 100644
index 000000000..a47aaf176
--- /dev/null
+++ b/frontend/src/model/logs.js
@@ -0,0 +1,179 @@
+import RestModel from "model/rest";
+import { $gettext } from "common/gettext";
+
+const SEGMENT_SPLIT = /\s*[›>]\s*/;
+const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/;
+const IPV6_PATTERN = /^[0-9a-f:]+$/i;
+
+function looksLikeIp(value) {
+ if (typeof value !== "string") {
+ return false;
+ }
+
+ const trimmed = value.trim();
+
+ if (!trimmed) {
+ return false;
+ }
+
+ if (IPV4_PATTERN.test(trimmed)) {
+ return true;
+ }
+
+ if (trimmed.includes(":") && IPV6_PATTERN.test(trimmed)) {
+ return true;
+ }
+
+ return false;
+}
+
+function splitSegments(message) {
+ if (!message) {
+ return [];
+ }
+
+ return String(message)
+ .split(SEGMENT_SPLIT)
+ .map((part) => part.trim())
+ .filter((part) => part.length > 0);
+}
+
+export const AuditSeverityNames = Object.freeze([
+ "emergency",
+ "alert",
+ "critical",
+ "error",
+ "warning",
+ "notice",
+ "info",
+ "debug",
+]);
+
+// LogEntry represents an audit log row returned by GET /api/v1/logs/audit.
+export class LogEntry extends RestModel {
+ getDefaults() {
+ return {
+ ID: 0,
+ Time: "",
+ Severity: 0,
+ IP: "",
+ Message: "",
+ Repeated: 0,
+ };
+ }
+
+ // severityName returns the severity label (e.g., "info") or "info" when unknown.
+ severityName() {
+ const value = this.Severity;
+
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+
+ if (AuditSeverityNames.includes(normalized)) {
+ return normalized;
+ }
+
+ const numeric = Number(normalized);
+
+ if (Number.isFinite(numeric) && numeric >= 0 && numeric < AuditSeverityNames.length) {
+ return AuditSeverityNames[numeric];
+ }
+ } else if (Number.isFinite(value)) {
+ const index = Number(value);
+
+ if (index >= 0 && index < AuditSeverityNames.length) {
+ return AuditSeverityNames[index];
+ }
+ }
+
+ return "info";
+ }
+
+ // severityTag returns the uppercase severity tag for badges and chips.
+ severityTag() {
+ return this.severityName().toUpperCase();
+ }
+
+ // hasRepeats indicates whether the log entry was repeated.
+ hasRepeats() {
+ return Number(this.Repeated) > 0;
+ }
+
+ // messageParts splits the log message into segments while removing IPs.
+ messageParts() {
+ const segments = splitSegments(this.Message);
+ const explicitIp = (this.IP || "").trim();
+
+ return segments.filter((segment) => {
+ if (!segment) {
+ return false;
+ }
+
+ if (explicitIp && segment === explicitIp) {
+ return false;
+ }
+
+ if (!explicitIp && looksLikeIp(segment)) {
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ // summary returns the first message part or the entire message if no separator is present.
+ summary() {
+ const parts = this.messageParts();
+
+ if (parts.length > 0) {
+ return parts[0];
+ }
+
+ const segments = splitSegments(this.Message);
+
+ if (segments.length > 0) {
+ return segments[0];
+ }
+
+ return this.Message || "";
+ }
+
+ // messageChain joins message parts with the provided separator, falling back to the summary.
+ messageChain(separator = " \u203A ") {
+ const parts = this.messageParts();
+
+ if (parts.length > 0) {
+ return parts.join(separator);
+ }
+
+ return this.summary();
+ }
+
+ // ipAddress returns the explicit IP or derives one from the message segments.
+ ipAddress() {
+ const explicitIp = (this.IP || "").trim();
+
+ if (explicitIp) {
+ return explicitIp;
+ }
+
+ const segments = splitSegments(this.Message);
+ const detected = segments.find((segment) => looksLikeIp(segment));
+
+ return detected || "";
+ }
+
+ static getCollectionResource() {
+ return "logs/audit";
+ }
+
+ static getModelName() {
+ return $gettext("Audit Log");
+ }
+
+ static limit() {
+ return 1000;
+ }
+}
+
+export default LogEntry;
diff --git a/frontend/src/page/cluster.vue b/frontend/src/page/cluster.vue
new file mode 100644
index 000000000..74467e37b
--- /dev/null
+++ b/frontend/src/page/cluster.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/frontend/tests/vitest/model/cluster-node.test.js b/frontend/tests/vitest/model/cluster-node.test.js
new file mode 100644
index 000000000..6cb903d6c
--- /dev/null
+++ b/frontend/tests/vitest/model/cluster-node.test.js
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import ClusterNode from "../../../../pro/portal/frontend/model/cluster-node.js";
+
+describe("pro/portal/model/cluster-node", () => {
+ it("derives the node identifier", () => {
+ const node = new ClusterNode({ UUID: "abc-123", Name: "portal-node" });
+
+ expect(node.getId()).toBe("abc-123");
+ });
+
+ it("formats role labels", () => {
+ const admin = new ClusterNode({ Role: "admin" });
+ const unknown = new ClusterNode({ Role: "custom" });
+
+ expect(admin.roleLabel()).toContain("Admin");
+ expect(unknown.roleLabel()).toBe("Custom");
+ });
+
+ it("converts labels into sorted key/value pairs", () => {
+ const node = new ClusterNode({
+ Labels: {
+ beta: "true",
+ alpha: "42",
+ },
+ });
+
+ expect(node.labelEntries()).toEqual([
+ { key: "alpha", value: "42" },
+ { key: "beta", value: "true" },
+ ]);
+ });
+
+ it("reports database metadata availability", () => {
+ const node = new ClusterNode({
+ Database: {
+ name: "photoprism",
+ user: "photoprism",
+ driver: "mysql",
+ },
+ });
+
+ expect(node.hasDatabase()).toBe(true);
+ });
+});
+
diff --git a/frontend/tests/vitest/model/logs.test.js b/frontend/tests/vitest/model/logs.test.js
new file mode 100644
index 000000000..f5b30dc97
--- /dev/null
+++ b/frontend/tests/vitest/model/logs.test.js
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import LogEntry, { AuditSeverityNames } from "model/logs";
+
+describe("model/logs", () => {
+ it("provides default values", () => {
+ const entry = new LogEntry();
+
+ expect(entry.ID).toBe(0);
+ expect(entry.Time).toBe("");
+ expect(entry.Message).toBe("");
+ expect(entry.hasRepeats()).toBe(false);
+ expect(LogEntry.limit()).toBe(1000);
+ });
+
+ it("maps severity names and tags", () => {
+ const entry = new LogEntry({ Severity: 4 });
+
+ expect(entry.severityName()).toBe("warning");
+ expect(entry.severityTag()).toBe("WARNING");
+
+ entry.Severity = 99;
+ expect(entry.severityName()).toBe("info");
+
+ entry.Severity = "warning";
+ expect(entry.severityName()).toBe("warning");
+ expect(entry.severityTag()).toBe("WARNING");
+
+ entry.Severity = "4";
+ expect(entry.severityName()).toBe("warning");
+
+ entry.Severity = "ALERT";
+ expect(entry.severityName()).toBe("alert");
+ });
+
+ it("splits audit messages into parts", () => {
+ const entry = new LogEntry({
+ Message: "172.18.0.1 › manage sessions › denied",
+ IP: "172.18.0.1",
+ });
+
+ expect(entry.messageParts()).toEqual(["manage sessions", "denied"]);
+ expect(entry.summary()).toBe("manage sessions");
+ expect(entry.messageChain(" > ")).toBe("manage sessions > denied");
+ expect(entry.ipAddress()).toBe("172.18.0.1");
+
+ entry.Message = "single message";
+ entry.IP = "";
+ expect(entry.messageParts()).toEqual(["single message"]);
+ expect(entry.summary()).toBe("single message");
+ expect(entry.messageChain(" > ")).toBe("single message");
+ expect(entry.ipAddress()).toBe("");
+ });
+
+ it("derives ip addresses from messages when missing", () => {
+ const entry = new LogEntry({
+ Message: "::1 › session sess123 › granted",
+ IP: "",
+ });
+
+ expect(entry.ipAddress()).toBe("::1");
+ expect(entry.messageParts()).toEqual(["session sess123", "granted"]);
+ });
+
+ it("exposes the severity catalogue", () => {
+ expect(Array.isArray(AuditSeverityNames)).toBe(true);
+ expect(AuditSeverityNames).toContain("info");
+ });
+});
diff --git a/internal/api/websocket_topics_test.go b/internal/api/websocket_topics_test.go
new file mode 100644
index 000000000..c1bc0fccf
--- /dev/null
+++ b/internal/api/websocket_topics_test.go
@@ -0,0 +1,21 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAppendWebsocketTopics(t *testing.T) {
+ original := append([]string(nil), WebsocketTopics...)
+
+ t.Cleanup(func() {
+ WebsocketTopics = original
+ })
+
+ AppendWebsocketTopics("audit.log.*", "custom.topic")
+
+ require.Len(t, WebsocketTopics, len(original)+2)
+ require.Contains(t, WebsocketTopics, "audit.log.*")
+ require.Contains(t, WebsocketTopics, "custom.topic")
+}
diff --git a/internal/api/websocket_writer.go b/internal/api/websocket_writer.go
index 74c3e063b..234f215e3 100644
--- a/internal/api/websocket_writer.go
+++ b/internal/api/websocket_writer.go
@@ -13,6 +13,43 @@ import (
"github.com/photoprism/photoprism/internal/event"
)
+// WebsocketTopics lists the event topics that are forwarded to connected websocket clients.
+// Extensions may append additional topics during package initialization so they are subscribed
+// as soon as the server starts accepting websocket connections.
+var WebsocketTopics = []string{
+ "user.*.*.*",
+ "session.*.*.*",
+ "log.fatal",
+ "log.error",
+ "log.warning",
+ "log.warn",
+ "log.info",
+ "notify.*",
+ "index.*",
+ "upload.*",
+ "import.*",
+ "config.*",
+ "count.*",
+ "photos.*",
+ "cameras.*",
+ "lenses.*",
+ "countries.*",
+ "albums.*",
+ "labels.*",
+ "subjects.*",
+ "people.*",
+ "sync.*",
+}
+
+// AppendWebsocketTopics adds the provided topics to the global websocket topic list.
+func AppendWebsocketTopics(topics ...string) {
+ if len(topics) == 0 {
+ return
+ }
+
+ WebsocketTopics = append(WebsocketTopics, topics...)
+}
+
// wsSendMessage sends a message to the WebSocket client.
func wsSendMessage(topic string, data interface{}, ws *websocket.Conn, writeMutex *sync.Mutex) {
if topic == "" || ws == nil || writeMutex == nil {
@@ -34,30 +71,8 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
pingTicker := time.NewTicker(15 * time.Second)
// Subscribe to events.
- e := event.Subscribe(
- "user.*.*.*",
- "session.*.*.*",
- "log.fatal",
- "log.error",
- "log.warning",
- "log.warn",
- "log.info",
- "notify.*",
- "index.*",
- "upload.*",
- "import.*",
- "config.*",
- "count.*",
- "photos.*",
- "cameras.*",
- "lenses.*",
- "countries.*",
- "albums.*",
- "labels.*",
- "subjects.*",
- "people.*",
- "sync.*",
- )
+ topics := append([]string(nil), WebsocketTopics...)
+ e := event.Subscribe(topics...)
defer func() {
pingTicker.Stop()
@@ -104,6 +119,11 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
// Send the message only to authorized recipients.
switch len(ch) {
+ case 3:
+ // Handle audit and other three-part topics (e.g. audit.log.info).
+ if res := acl.Resource(ch[0]); acl.Events.AllowAll(res, user.AclRole(), wsSubscribePerms) {
+ wsSendMessage(ev, msg.Fields, ws, writeMutex)
+ }
case 2:
// Send to everyone who is allowed to subscribe.
if res := acl.Resource(ch[0]); acl.Events.AllowAll(res, user.AclRole(), wsSubscribePerms) {
diff --git a/internal/auth/acl/events.go b/internal/auth/acl/events.go
index b6759a8a9..bb9c6b311 100644
--- a/internal/auth/acl/events.go
+++ b/internal/auth/acl/events.go
@@ -9,6 +9,10 @@ var Events = ACL{
ResourceDefault: Roles{
RoleAdmin: GrantFullAccess,
},
+ ChannelAudit: Roles{
+ RoleAdmin: GrantFullAccess,
+ RolePortal: GrantFullAccess,
+ },
ChannelLog: Roles{
RoleAdmin: GrantFullAccess,
},
diff --git a/internal/auth/acl/events_test.go b/internal/auth/acl/events_test.go
new file mode 100644
index 000000000..994a1b5f2
--- /dev/null
+++ b/internal/auth/acl/events_test.go
@@ -0,0 +1,19 @@
+package acl
+
+import "testing"
+
+func TestEventsChannelAuditPermissions(t *testing.T) {
+ perms := Permissions{ActionSubscribe}
+
+ if !Events.AllowAll(ChannelAudit, RoleAdmin, perms) {
+ t.Fatalf("expected admin to subscribe to audit events")
+ }
+
+ if !Events.AllowAll(ChannelAudit, RolePortal, perms) {
+ t.Fatalf("expected portal to subscribe to audit events")
+ }
+
+ if Events.AllowAll(ChannelAudit, RoleUser, perms) {
+ t.Fatalf("expected regular users to be denied audit event subscriptions")
+ }
+}