feat: bug fixes, layout improvements, and new features

Bug fixes:
- Remove non-existent `role` column from admin users SQL query (A1)
- Fix Nextcloud Talk chat API path v4 → v1 for messages/send/read (A2)
- Fix ServiceModeTab sync: useState → useEffect to reflect DB state (A3)
- Guard BookStack book_slug with book_id fallback to avoid broken URLs (A4)

Layout & UI:
- Chat panel: sticky full-height positioning, main content scrolls independently (B1)
- Vehicle booking datetime inputs: explicit text color for dark mode (B2)
- AnnouncementBanner moved into grid with full-width span (B3)

Features:
- Per-user widget visibility preferences stored in users.preferences JSONB (C1)
- Link collections: grouped external links in admin UI and dashboard widget (C2)
- Admin ping history: migration 026, checked_at timestamps, expandable history rows (C4)
- Service mode end date picker with scheduled deactivation display (C5)
- Vikunja startup config logging and configured:false warnings (C7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-12 14:57:54 +01:00
parent 81174c2498
commit a5cd78f01f
29 changed files with 593 additions and 105 deletions

View File

@@ -98,7 +98,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({
...p,
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`,
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug || p.book_id}/page/${p.slug}`,
}));
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -134,7 +134,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
slug: item.slug,
book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '',
url: item.url || `${bookstack.url}/books/${item.book_slug}/page/${item.slug}`,
url: item.url || `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`,
preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [],
}));
@@ -189,7 +189,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
html: page.html ?? '',
created_at: page.created_at,
updated_at: page.updated_at,
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`,
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug || page.book_id}/page/${page.slug}`,
book: page.book,
createdBy: page.created_by,
updatedBy: page.updated_by,

View File

@@ -202,7 +202,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
try {
const response = await axios.get(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
{
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
headers: {
@@ -251,7 +251,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
try {
await axios.post(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
{ message },
{
headers: {
@@ -288,7 +288,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
try {
await axios.delete(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`,
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}/read`,
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,

View File

@@ -18,6 +18,7 @@ export interface PingResult {
status: 'up' | 'down';
latencyMs: number;
error?: string;
checked_at: string;
}
export interface StatusSummary {
@@ -92,6 +93,7 @@ class ServiceMonitorService {
url,
status: 'up',
latencyMs: Date.now() - start,
checked_at: new Date().toISOString(),
};
} catch (error) {
// Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints
@@ -105,6 +107,7 @@ class ServiceMonitorService {
url,
status: 'up',
latencyMs: Date.now() - start,
checked_at: new Date().toISOString(),
};
}
}
@@ -113,6 +116,7 @@ class ServiceMonitorService {
url,
status: 'down',
latencyMs: Date.now() - start,
checked_at: new Date().toISOString(),
error: axios.isAxiosError(error)
? `${error.code ?? 'ERROR'}: ${error.message}`
: String(error),
@@ -140,9 +144,37 @@ class ServiceMonitorService {
})
);
// Store ping results in history (fire-and-forget)
this.storePingResults(results).catch(() => {});
return results;
}
async getPingHistory(serviceId: string): Promise<Array<{ id: number; service_id: string; status: string; response_time_ms: number | null; checked_at: string }>> {
const result = await pool.query(
'SELECT * FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20',
[serviceId]
);
return result.rows;
}
private async storePingResults(results: PingResult[]): Promise<void> {
for (const r of results) {
const serviceId = r.name || r.url;
await pool.query(
'INSERT INTO service_ping_history (service_id, status, response_time_ms, checked_at) VALUES ($1, $2, $3, $4)',
[serviceId, r.status, r.latencyMs, r.checked_at]
);
// Keep only last 20 per service
await pool.query(
`DELETE FROM service_ping_history WHERE service_id = $1 AND id NOT IN (
SELECT id FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20
)`,
[serviceId]
);
}
}
async getStatusSummary(): Promise<StatusSummary> {
const results = await this.pingAll();
return {

View File

@@ -34,9 +34,20 @@ class SettingsService {
return (result.rowCount ?? 0) > 0;
}
async getExternalLinks(): Promise<Array<{name: string; url: string}>> {
async getExternalLinks(): Promise<Array<{id: string; name: string; links: Array<{name: string; url: string}>}>> {
const setting = await this.get('external_links');
return Array.isArray(setting?.value) ? setting.value : [];
const value = setting?.value;
if (!Array.isArray(value)) return [];
// Already in LinkCollection[] format
if (value.length === 0) return [];
if (value[0] && 'links' in value[0]) return value;
// Old flat format: Array<{name, url}> — migrate to single collection
const migrated = [{ id: 'default', name: 'Links', links: value }];
// Persist migrated format (use system user id)
await this.set('external_links', migrated, 'system');
return migrated;
}
}