mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Portal: Add cluster admin UI #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -34,6 +34,7 @@ import People from "page/people.vue";
|
|||||||
import Library from "page/library.vue";
|
import Library from "page/library.vue";
|
||||||
import Settings from "page/settings.vue";
|
import Settings from "page/settings.vue";
|
||||||
import Admin from "page/admin.vue";
|
import Admin from "page/admin.vue";
|
||||||
|
import Cluster from "page/cluster.vue";
|
||||||
import Login from "page/auth/login.vue";
|
import Login from "page/auth/login.vue";
|
||||||
import Discover from "page/discover.vue";
|
import Discover from "page/discover.vue";
|
||||||
import About from "page/about/about.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",
|
name: "upgrade",
|
||||||
path: "/upgrade",
|
path: "/upgrade",
|
||||||
|
|||||||
@@ -668,6 +668,10 @@ export default class Config {
|
|||||||
|
|
||||||
// getDefaultRoute returns the default route to use after login or in case of routing errors.
|
// getDefaultRoute returns the default route to use after login or in case of routing errors.
|
||||||
getDefaultRoute() {
|
getDefaultRoute() {
|
||||||
|
if (this.isPortal()) {
|
||||||
|
return "cluster";
|
||||||
|
}
|
||||||
|
|
||||||
const albumsRoute = "albums";
|
const albumsRoute = "albums";
|
||||||
const browseRoute = "browse";
|
const browseRoute = "browse";
|
||||||
const defaultRoute = this.deny("photos", "access_library") ? albumsRoute : browseRoute;
|
const defaultRoute = this.deny("photos", "access_library") ? albumsRoute : browseRoute;
|
||||||
|
|||||||
179
frontend/src/model/logs.js
Normal file
179
frontend/src/model/logs.js
Normal 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;
|
||||||
24
frontend/src/page/cluster.vue
Normal file
24
frontend/src/page/cluster.vue
Normal 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>
|
||||||
45
frontend/tests/vitest/model/cluster-node.test.js
Normal file
45
frontend/tests/vitest/model/cluster-node.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
68
frontend/tests/vitest/model/logs.test.js
Normal file
68
frontend/tests/vitest/model/logs.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
21
internal/api/websocket_topics_test.go
Normal file
21
internal/api/websocket_topics_test.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -13,6 +13,43 @@ import (
|
|||||||
"github.com/photoprism/photoprism/internal/event"
|
"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.
|
// wsSendMessage sends a message to the WebSocket client.
|
||||||
func wsSendMessage(topic string, data interface{}, ws *websocket.Conn, writeMutex *sync.Mutex) {
|
func wsSendMessage(topic string, data interface{}, ws *websocket.Conn, writeMutex *sync.Mutex) {
|
||||||
if topic == "" || ws == nil || writeMutex == nil {
|
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)
|
pingTicker := time.NewTicker(15 * time.Second)
|
||||||
|
|
||||||
// Subscribe to events.
|
// Subscribe to events.
|
||||||
e := event.Subscribe(
|
topics := append([]string(nil), WebsocketTopics...)
|
||||||
"user.*.*.*",
|
e := event.Subscribe(topics...)
|
||||||
"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.*",
|
|
||||||
)
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
pingTicker.Stop()
|
pingTicker.Stop()
|
||||||
@@ -104,6 +119,11 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
|
|||||||
|
|
||||||
// Send the message only to authorized recipients.
|
// Send the message only to authorized recipients.
|
||||||
switch len(ch) {
|
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:
|
case 2:
|
||||||
// Send to everyone who is allowed to subscribe.
|
// Send to everyone who is allowed to subscribe.
|
||||||
if res := acl.Resource(ch[0]); acl.Events.AllowAll(res, user.AclRole(), wsSubscribePerms) {
|
if res := acl.Resource(ch[0]); acl.Events.AllowAll(res, user.AclRole(), wsSubscribePerms) {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ var Events = ACL{
|
|||||||
ResourceDefault: Roles{
|
ResourceDefault: Roles{
|
||||||
RoleAdmin: GrantFullAccess,
|
RoleAdmin: GrantFullAccess,
|
||||||
},
|
},
|
||||||
|
ChannelAudit: Roles{
|
||||||
|
RoleAdmin: GrantFullAccess,
|
||||||
|
RolePortal: GrantFullAccess,
|
||||||
|
},
|
||||||
ChannelLog: Roles{
|
ChannelLog: Roles{
|
||||||
RoleAdmin: GrantFullAccess,
|
RoleAdmin: GrantFullAccess,
|
||||||
},
|
},
|
||||||
|
|||||||
19
internal/auth/acl/events_test.go
Normal file
19
internal/auth/acl/events_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user