Admin: per-kanal warmup-konfigurasjon

- channels.config får warmup_mode (all/messages/days/none) og warmup_value
- Migrasjon setter default til "all" for eksisterende kanaler
- Admin-side /admin/channels med oversikt og inline-redigering
- API PATCH /api/channels/:id/config for å oppdatere konfig
- Worker respekterer per-kanal konfig ved warmup
- Sidebar-lenke til kanaler-admin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 02:26:02 +01:00
parent 8b58d434e9
commit 0d8521855f
6 changed files with 508 additions and 27 deletions

View file

@ -0,0 +1,8 @@
-- Legg til warmup-innstillinger i channels.config
-- Default: warmup_mode = "all", warmup_value = null (last alt)
-- Andre moduser: "messages" (siste N), "days" (siste N dager), "none" (ikke last)
UPDATE channels
SET config = config
|| '{"warmup_mode": "all", "warmup_value": null}'::jsonb
WHERE NOT (config ? 'warmup_mode');

View file

@ -145,6 +145,7 @@
{/each}
<li class="nav-divider"></li>
<li><a href="/admin/pages" onclick={() => (open = false)}>Rediger sider</a></li>
<li><a href="/admin/channels" onclick={() => (open = false)}>Kanaler</a></li>
</ul>
<div class="sidebar-footer">

View file

@ -0,0 +1,33 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { sql } from '$lib/server/db';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.workspace) error(404);
const channels = await sql`
SELECT
c.id,
c.name,
c.config,
n.workspace_id,
p.name AS parent_name,
(SELECT count(*)::int FROM messages m WHERE m.channel_id = c.id) AS message_count,
(SELECT max(m.created_at) FROM messages m WHERE m.channel_id = c.id) AS last_message_at
FROM channels c
JOIN nodes n ON n.id = c.id
LEFT JOIN nodes pn ON pn.id = c.parent_id
LEFT JOIN (
SELECT id, COALESCE(
(SELECT name FROM channels WHERE id = pn2.id),
(SELECT title FROM factoids WHERE id = pn2.id),
pn2.node_type
) AS name
FROM nodes pn2
) p ON p.id = c.parent_id
WHERE n.workspace_id = ${locals.workspace.id}
ORDER BY c.name
`;
return { channels };
};

View file

@ -0,0 +1,348 @@
<script lang="ts">
import type { PageData } from './$types';
let { data } = $props<{ data: PageData }>();
type WarmupMode = 'all' | 'messages' | 'days' | 'none';
interface ChannelRow {
id: string;
name: string;
config: Record<string, unknown>;
parent_name: string | null;
message_count: number;
last_message_at: string | null;
}
let channels = $state<ChannelRow[]>(data.channels as ChannelRow[]);
let saving = $state<string | null>(null);
let saved = $state<string | null>(null);
let errorMsg = $state('');
function getWarmupMode(config: Record<string, unknown>): WarmupMode {
return (config.warmup_mode as WarmupMode) ?? 'all';
}
function getWarmupValue(config: Record<string, unknown>): number | null {
const v = config.warmup_value;
return typeof v === 'number' ? v : null;
}
function formatDate(iso: string | null): string {
if (!iso) return 'Aldri';
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'I dag';
if (diffDays === 1) return 'I går';
if (diffDays < 30) return `${diffDays} dager siden`;
return d.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short', year: 'numeric' });
}
function modeLabel(mode: WarmupMode): string {
switch (mode) {
case 'all': return 'Alt';
case 'messages': return 'Siste N meldinger';
case 'days': return 'Siste N dager';
case 'none': return 'Ingen';
}
}
async function saveConfig(channel: ChannelRow) {
saving = channel.id;
errorMsg = '';
try {
const res = await fetch(`/api/channels/${channel.id}/config`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
warmup_mode: getWarmupMode(channel.config),
warmup_value: getWarmupValue(channel.config)
})
});
if (!res.ok) throw new Error('Feil ved lagring');
channel.config = await res.json();
saved = channel.id;
setTimeout(() => { if (saved === channel.id) saved = null; }, 2000);
} catch {
errorMsg = `Kunne ikke lagre konfig for ${channel.name}`;
} finally {
saving = null;
}
}
function setMode(channel: ChannelRow, mode: WarmupMode) {
channel.config = { ...channel.config, warmup_mode: mode };
if (mode === 'messages' && getWarmupValue(channel.config) === null) {
channel.config = { ...channel.config, warmup_value: 100 };
} else if (mode === 'days' && getWarmupValue(channel.config) === null) {
channel.config = { ...channel.config, warmup_value: 30 };
} else if (mode === 'all' || mode === 'none') {
channel.config = { ...channel.config, warmup_value: null };
}
saveConfig(channel);
}
function setValue(channel: ChannelRow, value: number) {
channel.config = { ...channel.config, warmup_value: value };
saveConfig(channel);
}
let totalMessages = $derived(channels.reduce((sum, c) => sum + c.message_count, 0));
let activeChannels = $derived(channels.filter(c => getWarmupMode(c.config) !== 'none').length);
</script>
<div class="admin-channels">
<div class="header">
<h2>Kanaler</h2>
<span class="header-stats">{activeChannels} aktive / {channels.length} totalt / {totalMessages} meldinger</span>
</div>
{#if errorMsg}
<div class="error-msg">{errorMsg}</div>
{/if}
<div class="channel-list">
<div class="channel-row channel-row--header">
<span class="col-name">Kanal</span>
<span class="col-parent">Tilhører</span>
<span class="col-count">Meldinger</span>
<span class="col-last">Siste aktivitet</span>
<span class="col-warmup">Warmup</span>
<span class="col-value">Verdi</span>
<span class="col-status"></span>
</div>
{#each channels as channel (channel.id)}
{@const mode = getWarmupMode(channel.config)}
{@const value = getWarmupValue(channel.config)}
<div class="channel-row" class:channel-row--inactive={mode === 'none'}>
<span class="col-name" title={channel.id}>{channel.name}</span>
<span class="col-parent">{channel.parent_name ?? '—'}</span>
<span class="col-count">{channel.message_count}</span>
<span class="col-last">{formatDate(channel.last_message_at)}</span>
<span class="col-warmup">
<select
value={mode}
onchange={(e) => setMode(channel, (e.target as HTMLSelectElement).value as WarmupMode)}
>
<option value="all">Alt</option>
<option value="messages">Siste N meldinger</option>
<option value="days">Siste N dager</option>
<option value="none">Ingen</option>
</select>
</span>
<span class="col-value">
{#if mode === 'messages' || mode === 'days'}
<input
type="number"
min="1"
value={value ?? ''}
onchange={(e) => setValue(channel, parseInt((e.target as HTMLInputElement).value) || 100)}
/>
{:else}
<span class="col-value-placeholder">{mode === 'all' ? 'Alt' : '—'}</span>
{/if}
</span>
<span class="col-status">
{#if saving === channel.id}
<span class="status-saving">...</span>
{:else if saved === channel.id}
<span class="status-saved">OK</span>
{/if}
</span>
</div>
{/each}
{#if channels.length === 0}
<p class="hint">Ingen kanaler funnet i dette workspacet.</p>
{/if}
</div>
<div class="info-box">
<strong>Warmup-moduser</strong>
<ul>
<li><strong>Alt</strong> — laster alle meldinger i kanalen inn i SpacetimeDB ved oppstart</li>
<li><strong>Siste N meldinger</strong> — laster kun de N nyeste meldingene</li>
<li><strong>Siste N dager</strong> — laster meldinger fra de siste N dagene</li>
<li><strong>Ingen</strong> — kanalen lastes ikke inn (arkivert/inaktiv)</li>
</ul>
<p>Endringer trer i kraft ved neste restart av worker.</p>
</div>
</div>
<style>
.admin-channels {
max-width: 900px;
}
.header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 1.5rem;
}
h2 {
font-size: 1.4rem;
}
.header-stats {
font-size: 0.8rem;
color: #8b92a5;
}
.error-msg {
background: #3b1219;
border: 1px solid #6b2028;
color: #f87171;
padding: 0.5rem 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.channel-list {
display: flex;
flex-direction: column;
gap: 1px;
background: #2d3148;
border: 1px solid #2d3148;
border-radius: 6px;
overflow: hidden;
}
.channel-row {
display: grid;
grid-template-columns: 2fr 1.5fr 80px 120px 160px 80px 40px;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #161822;
font-size: 0.8rem;
}
.channel-row--header {
background: #1a1d2e;
font-weight: 600;
color: #8b92a5;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.channel-row--inactive {
opacity: 0.5;
}
.col-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-parent {
color: #8b92a5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-count {
text-align: right;
font-variant-numeric: tabular-nums;
}
.col-last {
color: #8b92a5;
font-size: 0.75rem;
}
.col-value-placeholder {
color: #8b92a5;
font-size: 0.75rem;
}
select {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.25rem 0.35rem;
font-size: 0.75rem;
width: 100%;
}
input[type="number"] {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.25rem 0.35rem;
font-size: 0.75rem;
width: 100%;
}
select:focus, input:focus {
outline: none;
border-color: #3b82f6;
}
.status-saving {
color: #8b92a5;
}
.status-saved {
color: #4ade80;
}
.hint {
color: #8b92a5;
font-size: 0.85rem;
padding: 1.5rem;
text-align: center;
background: #161822;
}
.info-box {
margin-top: 1.5rem;
background: #161822;
border: 1px solid #2d3148;
border-radius: 6px;
padding: 1rem;
font-size: 0.8rem;
color: #8b92a5;
}
.info-box strong {
color: #e1e4e8;
}
.info-box ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.info-box li {
margin-bottom: 0.25rem;
}
.info-box p {
margin: 0.5rem 0 0;
font-style: italic;
}
@media (max-width: 768px) {
.channel-row {
grid-template-columns: 1fr 1fr;
gap: 0.25rem;
}
.channel-row--header {
display: none;
}
.col-parent, .col-last {
display: none;
}
}
</style>

View file

@ -0,0 +1,30 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** PATCH /api/channels/:id/config — Oppdater kanal-konfig (merge) */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const updates = await request.json();
if (!updates || typeof updates !== 'object') error(400, 'Ugyldig config');
// Verifiser at kanalen tilhører workspace
const [channel] = await sql`
SELECT c.id, c.config
FROM channels c
JOIN nodes n ON n.id = c.id
WHERE c.id = ${params.id}::uuid AND n.workspace_id = ${locals.workspace.id}
`;
if (!channel) error(404, 'Kanal ikke funnet');
// Merge oppdateringer inn i eksisterende config
await sql`
UPDATE channels
SET config = config || ${sql.json(updates)}
WHERE id = ${params.id}::uuid
`;
const [updated] = await sql`SELECT config FROM channels WHERE id = ${params.id}::uuid`;
return json(updated.config);
};

View file

@ -1,45 +1,92 @@
use reqwest::Client;
use serde::Deserialize;
use sqlx::PgPool;
use tracing::{info, warn};
/// Oppvarming: les siste N meldinger per aktive kanal fra PG og last inn i SpacetimeDB.
#[derive(Deserialize)]
struct ChannelConfig {
#[serde(default = "default_warmup_mode")]
warmup_mode: String,
warmup_value: Option<i64>,
}
fn default_warmup_mode() -> String {
"all".to_string()
}
struct ChannelInfo {
id: String,
name: String,
config: ChannelConfig,
}
/// Oppvarming: les meldinger per kanal fra PG og last inn i SpacetimeDB.
/// Respekterer per-kanal warmup-konfigurasjon.
pub async fn run(
pool: &PgPool,
http: &Client,
spacetimedb_url: &str,
module: &str,
limit: i64,
default_limit: i64,
) -> anyhow::Result<()> {
info!(limit, "Starter oppvarming (PG → SpacetimeDB)");
info!("Starter oppvarming (PG → SpacetimeDB)");
// Finn aktive kanaler (kanaler med meldinger)
let channels: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT channel_id::text FROM messages WHERE channel_id IS NOT NULL"
// Hent kanaler med konfig
let rows: Vec<(String, String, serde_json::Value)> = sqlx::query_as(
"SELECT c.id::text, c.name, c.config FROM channels c JOIN nodes n ON n.id = c.id"
)
.fetch_all(pool)
.await?;
let channels: Vec<ChannelInfo> = rows.into_iter().map(|(id, name, config_val)| {
let config: ChannelConfig = serde_json::from_value(config_val)
.unwrap_or(ChannelConfig { warmup_mode: default_warmup_mode(), warmup_value: None });
ChannelInfo { id, name, config }
}).collect();
if channels.is_empty() {
info!("Ingen aktive kanaler funnet — oppvarming fullført");
info!("Ingen kanaler funnet — oppvarming fullført");
return Ok(());
}
info!(channels = channels.len(), "Aktive kanaler funnet");
let active: Vec<&ChannelInfo> = channels.iter()
.filter(|c| c.config.warmup_mode != "none")
.collect();
info!(
total = channels.len(),
active = active.len(),
skipped = channels.len() - active.len(),
"Kanaler funnet"
);
let mut total_messages = 0u64;
let mut total_reactions = 0u64;
for (channel_id,) in &channels {
// Rydd kanalen i SpacetimeDB først for å unngå duplikater
for ch in &active {
// Rydd kanalen i SpacetimeDB først
if let Err(e) = call_reducer(http, spacetimedb_url, module, "clear_channel", &serde_json::json!({
"channel_id": channel_id
"channel_id": ch.id
})).await {
warn!(channel_id, error = %e, "Kunne ikke rydde kanal — hopper over");
warn!(channel = %ch.name, error = %e, "Kunne ikke rydde kanal — hopper over");
continue;
}
// Hent meldinger med forfatterinfo
let rows: Vec<(String, String, String, String, String, String, String, Option<String>, String)> = sqlx::query_as(
// Bygg WHERE-clause basert på warmup-modus
let (where_clause, limit) = match ch.config.warmup_mode.as_str() {
"messages" => {
let n = ch.config.warmup_value.unwrap_or(default_limit);
(String::new(), n)
},
"days" => {
let days = ch.config.warmup_value.unwrap_or(30);
(format!("AND m.created_at >= now() - interval '{} days'", days), i64::MAX)
},
// "all" og alt annet
_ => (String::new(), i64::MAX),
};
let query = format!(
r#"
SELECT
m.id::text,
@ -54,17 +101,24 @@ pub async fn run(
FROM messages m
JOIN nodes n ON n.id = m.id
LEFT JOIN users u ON u.authentik_id = m.author_id
WHERE m.channel_id = $1::uuid
WHERE m.channel_id = $1::uuid {}
ORDER BY m.created_at DESC
LIMIT $2
"#
)
.bind(channel_id)
"#,
where_clause
);
let rows: Vec<(String, String, String, String, String, String, String, Option<String>, String)> =
sqlx::query_as(&query)
.bind(&ch.id)
.bind(limit)
.fetch_all(pool)
.await?;
if rows.is_empty() { continue; }
if rows.is_empty() {
info!(channel = %ch.name, mode = %ch.config.warmup_mode, "Ingen meldinger å laste");
continue;
}
// Bygg JSON-array
let messages: Vec<serde_json::Value> = rows.iter().map(|r| {
@ -87,7 +141,7 @@ pub async fn run(
if let Err(e) = call_reducer(http, spacetimedb_url, module, "load_messages", &serde_json::json!({
"messages_json": json_str
})).await {
warn!(channel_id, error = %e, "Feil ved lasting av meldinger");
warn!(channel = %ch.name, error = %e, "Feil ved lasting av meldinger");
continue;
}
@ -107,7 +161,7 @@ pub async fn run(
WHERE m.channel_id = $1::uuid
"#
)
.bind(channel_id)
.bind(&ch.id)
.fetch_all(pool)
.await?;
@ -125,17 +179,24 @@ pub async fn run(
if let Err(e) = call_reducer(http, spacetimedb_url, module, "load_reactions", &serde_json::json!({
"reactions_json": reactions_json
})).await {
warn!(channel_id, error = %e, "Feil ved lasting av reaksjoner");
warn!(channel = %ch.name, error = %e, "Feil ved lasting av reaksjoner");
} else {
total_reactions += reaction_rows.len() as u64;
}
}
info!(channel_id, messages = count, reactions = reaction_rows.len(), "Kanal oppvarmet");
info!(
channel = %ch.name,
mode = %ch.config.warmup_mode,
value = ?ch.config.warmup_value,
messages = count,
reactions = reaction_rows.len(),
"Kanal oppvarmet"
);
}
info!(
channels = channels.len(),
channels = active.len(),
messages = total_messages,
reactions = total_reactions,
"Oppvarming fullført"