Portal: Add cluster admin UI #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-16 16:21:56 +02:00
parent 76f372f8f8
commit 2227aa57b2
10 changed files with 429 additions and 24 deletions

View File

@@ -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",

View File

@@ -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;

179
frontend/src/model/logs.js Normal file
View File

@@ -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;

View File

@@ -0,0 +1,24 @@
<template>
<p-page-about></p-page-about>
</template>
<script>
import PPageAbout from "./about/about.vue";
export default {
name: "PPageCluster",
components: { PPageAbout },
data() {
return {
rtl: this.$isRtl,
};
},
mounted() {
this.$view.enter(this);
},
unmounted() {
this.$view.leave(this);
},
methods: {},
};
</script>

View File

@@ -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);
});
});

View File

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

View File

@@ -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")
}

View File

@@ -13,28 +13,10 @@ import (
"github.com/photoprism/photoprism/internal/event"
)
// 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 {
return
}
writeMutex.Lock()
defer writeMutex.Unlock()
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
return
} else if err := ws.WriteJSON(gin.H{"event": topic, "data": data}); err != nil {
return
}
}
// wsWriter initializes a WebSocket writer for sending messages.
func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
pingTicker := time.NewTicker(15 * time.Second)
// Subscribe to events.
e := event.Subscribe(
// 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",
@@ -57,7 +39,40 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
"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 {
return
}
writeMutex.Lock()
defer writeMutex.Unlock()
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
return
} else if err := ws.WriteJSON(gin.H{"event": topic, "data": data}); err != nil {
return
}
}
// wsWriter initializes a WebSocket writer for sending messages.
func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
pingTicker := time.NewTicker(15 * time.Second)
// Subscribe to events.
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) {

View File

@@ -9,6 +9,10 @@ var Events = ACL{
ResourceDefault: Roles{
RoleAdmin: GrantFullAccess,
},
ChannelAudit: Roles{
RoleAdmin: GrantFullAccess,
RolePortal: GrantFullAccess,
},
ChannelLog: Roles{
RoleAdmin: GrantFullAccess,
},

View File

@@ -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")
}
}