feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen

- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg)
- Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist
- Feature-Status auf In Review gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+61
View File
@@ -0,0 +1,61 @@
package index
import (
"fmt"
"time"
)
// MailDocument is the indexed representation of a stored email.
type MailDocument struct {
ID string
From string
To string
Subject string
Body string
AttachNames string
HasAttachment bool
Date time.Time
Size int64
}
// SearchRequest specifies search parameters.
type SearchRequest struct {
Query string
From string
To string
OwnEmail string
DateFrom *time.Time
DateTo *time.Time
PageSize int
Page int
}
// Hit is a single search result.
type Hit struct {
ID string `json:"id"`
Score float64 `json:"score"`
}
// SearchResult holds paginated search results.
type SearchResult struct {
Total int
Hits []Hit
}
// Indexer is the interface for full-text email indexing.
type Indexer interface {
IndexSync(doc MailDocument) error
Search(req SearchRequest) (*SearchResult, error)
Delete(id string) error
Close() error
}
// New creates an Indexer for the specified backend.
func New(dir string, batchSize int, backend string) (Indexer, error) {
switch backend {
case "xapian":
return newXapian(dir)
default:
return nil, fmt.Errorf("unknown index backend: %q (supported: xapian)", backend)
}
}
+192
View File
@@ -0,0 +1,192 @@
package index_test
import (
"testing"
"time"
"github.com/archivmail/internal/index"
)
// newXapianIndex creates a temporary Xapian index for testing.
func newXapianIndex(t *testing.T) index.Indexer {
t.Helper()
idx, err := index.New(t.TempDir(), 100, "xapian")
if err != nil {
t.Skip("xapian not available:", err)
}
t.Cleanup(func() { idx.Close() })
return idx
}
func seedDocs(t *testing.T, idx index.Indexer) {
t.Helper()
docs := []index.MailDocument{
{
ID: "aaa111",
From: "alice@example.com",
To: "bob@example.com",
Subject: "Invoice Q1-2026",
Body: "Please find attached the invoice for January.",
Date: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC),
Size: 1024,
},
{
ID: "bbb222",
From: "bob@example.com",
To: "alice@example.com charlie@example.com",
Subject: "Meeting Agenda",
Body: "Agenda for the quarterly review meeting.",
Date: time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC),
Size: 512,
},
{
ID: "ccc333",
From: "charlie@example.com",
To: "alice@example.com",
Subject: "Offer with attachment",
Body: "Please review the attached offer document.",
AttachNames: "offer.pdf",
HasAttachment: true,
Date: time.Date(2026, 3, 1, 14, 0, 0, 0, time.UTC),
Size: 8192,
},
}
for _, d := range docs {
if err := idx.IndexSync(d); err != nil {
t.Fatalf("IndexSync %s: %v", d.ID, err)
}
}
}
func TestIndexAndSearchFulltext(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
result, err := idx.Search(index.SearchRequest{Query: "invoice", PageSize: 10})
if err != nil {
t.Fatalf("Search: %v", err)
}
if result.Total == 0 {
t.Error("expected at least 1 hit for 'invoice'")
}
if result.Hits[0].ID != "aaa111" {
t.Errorf("top hit = %q, want aaa111", result.Hits[0].ID)
}
}
func TestSearchMatchAll(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
result, err := idx.Search(index.SearchRequest{PageSize: 25})
if err != nil {
t.Fatalf("Search all: %v", err)
}
if result.Total != 3 {
t.Errorf("expected 3 total hits, got %d", result.Total)
}
}
func TestSearchFromFilter(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
result, err := idx.Search(index.SearchRequest{
From: "alice@example.com",
PageSize: 25,
})
if err != nil {
t.Fatalf("Search from: %v", err)
}
if result.Total != 1 {
t.Errorf("expected 1 hit from alice, got %d", result.Total)
}
}
func TestSearchDateRange(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 2, 1, 23, 59, 59, 0, time.UTC)
result, err := idx.Search(index.SearchRequest{
DateFrom: &from,
DateTo: &to,
PageSize: 25,
})
if err != nil {
t.Fatalf("Search date range: %v", err)
}
if result.Total != 2 {
t.Errorf("expected 2 hits in Jan-Feb 2026, got %d", result.Total)
}
}
func TestSearchOwnEmail(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
// charlie@example.com sent 1 mail and received 1 mail = should see 2
result, err := idx.Search(index.SearchRequest{
OwnEmail: "charlie@example.com",
PageSize: 25,
})
if err != nil {
t.Fatalf("Search OwnEmail: %v", err)
}
if result.Total < 1 {
t.Errorf("charlie should see at least 1 mail, got %d", result.Total)
}
}
func TestSearchPagination(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
page0, _ := idx.Search(index.SearchRequest{PageSize: 2, Page: 0})
page1, _ := idx.Search(index.SearchRequest{PageSize: 2, Page: 1})
if len(page0.Hits) != 2 {
t.Errorf("page 0: expected 2 hits, got %d", len(page0.Hits))
}
if len(page1.Hits) != 1 {
t.Errorf("page 1: expected 1 hit, got %d", len(page1.Hits))
}
// No overlap
if page0.Hits[0].ID == page1.Hits[0].ID {
t.Error("pagination returned duplicate results")
}
}
func TestDelete(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
if err := idx.Delete("aaa111"); err != nil {
t.Fatalf("Delete: %v", err)
}
result, _ := idx.Search(index.SearchRequest{Query: "invoice", PageSize: 10})
for _, h := range result.Hits {
if h.ID == "aaa111" {
t.Error("deleted document still in results")
}
}
}
func TestUnknownBackend(t *testing.T) {
_, err := index.New(t.TempDir(), 10, "elasticsearch")
if err == nil {
t.Error("expected error for unknown backend")
}
}
func TestXapianNotCompiledError(t *testing.T) {
_, err := index.New(t.TempDir(), 10, "xapian")
// Without -tags xapian this must return a helpful error
if err == nil {
t.Log("xapian compiled in — skipping stub error test")
} else {
t.Logf("xapian stub error (expected): %v", err)
}
}
+126
View File
@@ -0,0 +1,126 @@
//go:build xapian
package index
/*
#cgo pkg-config: xapian-core
#cgo LDFLAGS: -lstdc++
#include "xapian_wrapper.h"
#include <stdlib.h>
*/
import "C"
import (
"encoding/json"
"fmt"
"unsafe"
)
type xapianIndex struct {
db *C.XapianDB
}
func newXapian(dir string) (Indexer, error) {
cdir := C.CString(dir)
defer C.free(unsafe.Pointer(cdir))
var cerr *C.char
db := C.xapian_open(cdir, 1, &cerr)
if db == nil {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return nil, fmt.Errorf("xapian open: %s", msg)
}
return &xapianIndex{db: db}, nil
}
func (x *xapianIndex) IndexSync(doc MailDocument) error {
cid := C.CString(doc.ID)
defer C.free(unsafe.Pointer(cid))
cfrom := C.CString(doc.From)
defer C.free(unsafe.Pointer(cfrom))
cto := C.CString(doc.To)
defer C.free(unsafe.Pointer(cto))
csubj := C.CString(doc.Subject)
defer C.free(unsafe.Pointer(csubj))
cbody := C.CString(doc.Body)
defer C.free(unsafe.Pointer(cbody))
var cerr *C.char
rc := C.xapian_index(x.db, cid, cfrom, cto, csubj, cbody, C.longlong(doc.Date.Unix()), &cerr)
if rc != 0 {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return fmt.Errorf("xapian index: %s", msg)
}
return nil
}
func (x *xapianIndex) Delete(id string) error {
cid := C.CString(id)
defer C.free(unsafe.Pointer(cid))
var cerr *C.char
rc := C.xapian_delete(x.db, cid, &cerr)
if rc != 0 {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return fmt.Errorf("xapian delete: %s", msg)
}
return nil
}
func (x *xapianIndex) Search(req SearchRequest) (*SearchResult, error) {
cquery := C.CString(req.Query)
defer C.free(unsafe.Pointer(cquery))
cfrom := C.CString(req.From)
defer C.free(unsafe.Pointer(cfrom))
cown := C.CString(req.OwnEmail)
defer C.free(unsafe.Pointer(cown))
cto := C.CString(req.To)
defer C.free(unsafe.Pointer(cto))
var dateFrom, dateTo C.longlong
if req.DateFrom != nil {
dateFrom = C.longlong(req.DateFrom.Unix())
}
if req.DateTo != nil {
dateTo = C.longlong(req.DateTo.Unix())
}
page := req.Page
if page < 1 {
page = 1
}
offset := C.int((page - 1) * req.PageSize)
limit := C.int(req.PageSize)
if limit <= 0 {
limit = 25
}
var cerr *C.char
cresult := C.xapian_search(x.db, cquery, cfrom, cown, cto, dateFrom, dateTo, offset, limit, &cerr)
if cresult == nil {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return nil, fmt.Errorf("xapian search: %s", msg)
}
defer C.xapian_free_string(cresult)
jsonStr := C.GoString(cresult)
var raw struct {
Total int `json:"total"`
Hits []struct {
ID string `json:"id"`
Score float64 `json:"score"`
} `json:"hits"`
}
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return nil, fmt.Errorf("xapian parse result: %w", err)
}
hits := make([]Hit, len(raw.Hits))
for i, h := range raw.Hits {
hits[i] = Hit{ID: h.ID, Score: h.Score}
}
return &SearchResult{Total: raw.Total, Hits: hits}, nil
}
func (x *xapianIndex) Close() error {
C.xapian_close(x.db)
return nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !xapian
package index
import "errors"
func newXapian(dir string) (Indexer, error) {
return nil, errors.New("xapian: not compiled in — rebuild with: go build -tags xapian")
}
+199
View File
@@ -0,0 +1,199 @@
#include "xapian_wrapper.h"
#include <xapian.h>
#include <cstring>
#include <cstdlib>
#include <string>
#include <sstream>
#include <stdexcept>
struct XapianDB {
Xapian::WritableDatabase* wdb;
Xapian::Database* rdb;
bool writable;
};
static char* dup_error(const std::string& msg) {
char* s = (char*)malloc(msg.size() + 1);
if (s) memcpy(s, msg.c_str(), msg.size() + 1);
return s;
}
extern "C" {
XapianDB* xapian_open(const char* path, int writable, char** err) {
try {
XapianDB* db = new XapianDB{nullptr, nullptr, (bool)writable};
if (writable) {
db->wdb = new Xapian::WritableDatabase(path, Xapian::DB_CREATE_OR_OPEN);
} else {
db->rdb = new Xapian::Database(path);
}
return db;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return nullptr;
}
}
void xapian_close(XapianDB* db) {
if (!db) return;
if (db->wdb) { db->wdb->close(); delete db->wdb; }
if (db->rdb) { db->rdb->close(); delete db->rdb; }
delete db;
}
int xapian_index(XapianDB* db, const char* id, const char* from,
const char* to, const char* subject, const char* body,
long long timestamp, char** err) {
try {
Xapian::Document doc;
Xapian::TermGenerator gen;
gen.set_document(doc);
gen.set_stemmer(Xapian::Stem("en"));
// Prefix-indexed fields for filtering
gen.index_text(from, 1, "XF");
gen.index_text(to, 1, "XT");
gen.index_text(subject, 1, "XS");
// Free-text indexed fields
gen.index_text(subject);
gen.increase_termpos();
gen.index_text(body);
gen.increase_termpos();
gen.index_text(from);
gen.increase_termpos();
gen.index_text(to);
// Store timestamp for date range queries (value slot 0)
doc.add_value(0, Xapian::sortable_serialise((double)timestamp));
// Store ID as document data
doc.set_data(id);
doc.add_boolean_term(std::string("Q") + id);
db->wdb->replace_document(std::string("Q") + id, doc);
db->wdb->commit();
return 0;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return -1;
}
}
int xapian_delete(XapianDB* db, const char* id, char** err) {
try {
db->wdb->delete_document(std::string("Q") + id);
db->wdb->commit();
return 0;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return -1;
}
}
char* xapian_search(XapianDB* db, const char* query_str,
const char* from_filter, const char* own_email,
const char* to_filter,
long long date_from, long long date_to,
int offset, int limit, char** err) {
try {
Xapian::Database& xdb = db->wdb ? (Xapian::Database&)*db->wdb : *db->rdb;
Xapian::Enquire enquire(xdb);
Xapian::Query main_query;
// Full-text query
if (query_str && query_str[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
qp.set_stemmer(Xapian::Stem("en"));
qp.set_stemming_strategy(Xapian::QueryParser::STEM_SOME);
qp.add_prefix("from", "XF");
qp.add_prefix("to", "XT");
qp.add_prefix("subject", "XS");
main_query = qp.parse_query(query_str,
Xapian::QueryParser::FLAG_DEFAULT |
Xapian::QueryParser::FLAG_PARTIAL);
} else {
main_query = Xapian::Query::MatchAll;
}
// From filter
if (from_filter && from_filter[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
Xapian::Query fq = qp.parse_query(from_filter,
Xapian::QueryParser::FLAG_DEFAULT, "XF");
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, fq);
}
// OwnEmail filter: (from=own OR to=own)
if (own_email && own_email[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
Xapian::Query fq = qp.parse_query(own_email,
Xapian::QueryParser::FLAG_DEFAULT, "XF");
Xapian::Query tq = qp.parse_query(own_email,
Xapian::QueryParser::FLAG_DEFAULT, "XT");
Xapian::Query owq(Xapian::Query::OP_OR, fq, tq);
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, owq);
}
// To filter
if (to_filter && to_filter[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
Xapian::Query tq = qp.parse_query(to_filter,
Xapian::QueryParser::FLAG_DEFAULT, "XT");
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, tq);
}
// Date range
if (date_from > 0 || date_to > 0) {
double lo = date_from > 0 ? (double)date_from : 0.0;
double hi = date_to > 0 ? (double)date_to : 1e18;
Xapian::Query drq(Xapian::Query::OP_VALUE_RANGE, 0,
Xapian::sortable_serialise(lo),
Xapian::sortable_serialise(hi));
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, drq);
}
enquire.set_query(main_query);
enquire.set_sort_by_value(0, true); // sort by date desc
// Get total count
Xapian::MSet all = enquire.get_mset(0, xdb.get_doccount());
int total = (int)all.get_matches_estimated();
// Get page
Xapian::MSet mset = enquire.get_mset(offset, limit);
std::ostringstream json;
json << "{\"total\":" << total << ",\"hits\":[";
bool first = true;
for (auto it = mset.begin(); it != mset.end(); ++it) {
if (!first) json << ",";
first = false;
std::string id = it.get_document().get_data();
double score = it.get_weight();
json << "{\"id\":\"" << id << "\",\"score\":" << score << "}";
}
json << "]}";
std::string result = json.str();
char* out = (char*)malloc(result.size() + 1);
memcpy(out, result.c_str(), result.size() + 1);
return out;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return nullptr;
}
}
void xapian_free_string(char* s) {
free(s);
}
} // extern "C"
+32
View File
@@ -0,0 +1,32 @@
#ifndef XAPIAN_WRAPPER_H
#define XAPIAN_WRAPPER_H
#ifdef __cplusplus
extern "C" {
#endif
typedef struct XapianDB XapianDB;
XapianDB* xapian_open(const char* path, int writable, char** err);
void xapian_close(XapianDB* db);
int xapian_index(XapianDB* db, const char* id, const char* from,
const char* to, const char* subject, const char* body,
long long timestamp, char** err);
int xapian_delete(XapianDB* db, const char* id, char** err);
/* Returns JSON string: {"total":N,"hits":[{"id":"...","score":0.9},...]}
Returns NULL on error, sets *err. Caller must free with xapian_free_string. */
char* xapian_search(XapianDB* db, const char* query,
const char* from_filter, const char* own_email,
const char* to_filter,
long long date_from, long long date_to,
int offset, int limit, char** err);
void xapian_free_string(char* s);
#ifdef __cplusplus
}
#endif
#endif