diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8cf8a9..4276b62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,6 +245,8 @@ jobs: FORGUST_SERVER__LISTEN: 127.0.0.1:18491 FORGUST_SERVER__EXTERNAL_URL: http://127.0.0.1:18491 FORGUST_SERVER__SSH_LISTEN: "" + # no release check: what the pages show must not depend on GitHub's latest release + FORGUST_SERVER__RELEASE_CHECK_URL: "" FORGUST_SERVER__DATA_DIR: /tmp/forgust-e2e FORGUST_SERVER__STATIC_DIR: web/dist steps: diff --git a/.sqlx/query-58ad72128c7c93a49ace7393bd0d716d61dcb4b55e7e4033f707ed207635e15d.json b/.sqlx/query-58ad72128c7c93a49ace7393bd0d716d61dcb4b55e7e4033f707ed207635e15d.json new file mode 100644 index 0000000..172d225 --- /dev/null +++ b/.sqlx/query-58ad72128c7c93a49ace7393bd0d716d61dcb4b55e7e4033f707ed207635e15d.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT latest_version, latest_release_url FROM instance_settings WHERE update_check_enabled", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "latest_version", + "type_info": "Text", + "origin": { + "Table": { + "table": "instance_settings", + "name": "latest_version" + } + } + }, + { + "ordinal": 1, + "name": "latest_release_url", + "type_info": "Text", + "origin": { + "Table": { + "table": "instance_settings", + "name": "latest_release_url" + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true, + true + ] + }, + "hash": "58ad72128c7c93a49ace7393bd0d716d61dcb4b55e7e4033f707ed207635e15d" +} diff --git a/.sqlx/query-d770beffb5ece419cc0b0f57670e415f880e069c40bf1af44108a0c92b158154.json b/.sqlx/query-7f4fb1f58914f8a37d8f163336cd44c40a34e7e6f6371478d19de9c846b3f503.json similarity index 74% rename from .sqlx/query-d770beffb5ece419cc0b0f57670e415f880e069c40bf1af44108a0c92b158154.json rename to .sqlx/query-7f4fb1f58914f8a37d8f163336cd44c40a34e7e6f6371478d19de9c846b3f503.json index bca416c..ffdf26a 100644 --- a/.sqlx/query-d770beffb5ece419cc0b0f57670e415f880e069c40bf1af44108a0c92b158154.json +++ b/.sqlx/query-7f4fb1f58914f8a37d8f163336cd44c40a34e7e6f6371478d19de9c846b3f503.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled FROM instance_settings", + "query": "SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled, update_check_enabled FROM instance_settings", "describe": { "columns": [ { @@ -46,6 +46,17 @@ "name": "mcp_enabled" } } + }, + { + "ordinal": 4, + "name": "update_check_enabled", + "type_info": "Bool", + "origin": { + "Table": { + "table": "instance_settings", + "name": "update_check_enabled" + } + } } ], "parameters": { @@ -55,8 +66,9 @@ false, false, false, + false, false ] }, - "hash": "d770beffb5ece419cc0b0f57670e415f880e069c40bf1af44108a0c92b158154" + "hash": "7f4fb1f58914f8a37d8f163336cd44c40a34e7e6f6371478d19de9c846b3f503" } diff --git a/.sqlx/query-90119ce23884009cab9c0e6f6bb822e6aca2ac97a89dce80447daf4bf31aa364.json b/.sqlx/query-90119ce23884009cab9c0e6f6bb822e6aca2ac97a89dce80447daf4bf31aa364.json new file mode 100644 index 0000000..7a6ec11 --- /dev/null +++ b/.sqlx/query-90119ce23884009cab9c0e6f6bb822e6aca2ac97a89dce80447daf4bf31aa364.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled, update_check_enabled FROM instance_settings FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "signup_enabled", + "type_info": "Bool", + "origin": { + "Table": { + "table": "instance_settings", + "name": "signup_enabled" + } + } + }, + { + "ordinal": 1, + "name": "allow_personal_projects", + "type_info": "Bool", + "origin": { + "Table": { + "table": "instance_settings", + "name": "allow_personal_projects" + } + } + }, + { + "ordinal": 2, + "name": "default_branch_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "instance_settings", + "name": "default_branch_name" + } + } + }, + { + "ordinal": 3, + "name": "mcp_enabled", + "type_info": "Bool", + "origin": { + "Table": { + "table": "instance_settings", + "name": "mcp_enabled" + } + } + }, + { + "ordinal": 4, + "name": "update_check_enabled", + "type_info": "Bool", + "origin": { + "Table": { + "table": "instance_settings", + "name": "update_check_enabled" + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "90119ce23884009cab9c0e6f6bb822e6aca2ac97a89dce80447daf4bf31aa364" +} diff --git a/.sqlx/query-c1b3e3b250ee976e4f29d3cbeb069d9cd2d313a1c11837a66c2a28929bcba4b4.json b/.sqlx/query-c1b3e3b250ee976e4f29d3cbeb069d9cd2d313a1c11837a66c2a28929bcba4b4.json new file mode 100644 index 0000000..f4507a3 --- /dev/null +++ b/.sqlx/query-c1b3e3b250ee976e4f29d3cbeb069d9cd2d313a1c11837a66c2a28929bcba4b4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE instance_settings SET latest_version = $1, latest_release_url = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c1b3e3b250ee976e4f29d3cbeb069d9cd2d313a1c11837a66c2a28929bcba4b4" +} diff --git a/.sqlx/query-cc8b6716a081fed99f217434b050b1ff47765cfad24f6af3bf8682a5921e86ed.json b/.sqlx/query-dc797579e0925fa8ac3fc342a5e33784f6cd4a1c63f047fc0cb0701c904bea7e.json similarity index 69% rename from .sqlx/query-cc8b6716a081fed99f217434b050b1ff47765cfad24f6af3bf8682a5921e86ed.json rename to .sqlx/query-dc797579e0925fa8ac3fc342a5e33784f6cd4a1c63f047fc0cb0701c904bea7e.json index 57a5a4a..48c2404 100644 --- a/.sqlx/query-cc8b6716a081fed99f217434b050b1ff47765cfad24f6af3bf8682a5921e86ed.json +++ b/.sqlx/query-dc797579e0925fa8ac3fc342a5e33784f6cd4a1c63f047fc0cb0701c904bea7e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE instance_settings SET signup_enabled = $1, allow_personal_projects = $2, default_branch_name = $3, mcp_enabled = $4, updated_at = now()", + "query": "UPDATE instance_settings SET signup_enabled = $1, allow_personal_projects = $2, default_branch_name = $3, mcp_enabled = $4, update_check_enabled = $5, updated_at = now()", "describe": { "columns": [], "parameters": { @@ -8,10 +8,11 @@ "Bool", "Bool", "Text", + "Bool", "Bool" ] }, "nullable": [] }, - "hash": "cc8b6716a081fed99f217434b050b1ff47765cfad24f6af3bf8682a5921e86ed" + "hash": "dc797579e0925fa8ac3fc342a5e33784f6cd4a1c63f047fc0cb0701c904bea7e" } diff --git a/Cargo.lock b/Cargo.lock index c779cf2..cec381c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2233,6 +2233,7 @@ dependencies = [ "reqwest 0.13.5", "ring", "russh", + "semver", "serde", "serde_json", "sha2 0.11.1", diff --git a/README.md b/README.md index 1844431..bb862fa 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ volumes: { forgust-data: {}, db-data: {} } - Overview with activity and CI statistics - Users: block, reset two-factor, impersonate (audited) - Storage by kind and by project, quotas per group and project -- Background jobs, health checks, backups from the UI, an instance banner +- Background jobs, health checks, backups from the UI, an instance banner, a notice to administrators when a new release is out (can be turned off) ### 🔌 Integrations - Import from GitLab and GitHub: repositories, merge requests, issues, labels, diff --git a/bench/run.sh b/bench/run.sh index 6e64144..2150f47 100755 --- a/bench/run.sh +++ b/bench/run.sh @@ -28,7 +28,7 @@ cleanup() { trap cleanup EXIT export FORGUST_DATABASE__URL="$BASE/$DB" FORGUST_SERVER__DATA_DIR="$WORK/data" FORGUST_SERVER__LISTEN="127.0.0.1:$PORT" \ - FORGUST_SERVER__EXTERNAL_URL="http://127.0.0.1:$PORT" FORGUST_SERVER__SSH_LISTEN= FORGUST_TELEMETRY__LOG_FILTER=warn + FORGUST_SERVER__EXTERNAL_URL="http://127.0.0.1:$PORT" FORGUST_SERVER__SSH_LISTEN= FORGUST_SERVER__RELEASE_CHECK_URL= FORGUST_TELEMETRY__LOG_FILTER=warn $BIN/forgust admin create-user --username bench --email bench@example.com --password benchpassword --admin $BIN/forgust serve > "$WORK/server.log" 2>&1 & SERVER=$! diff --git a/crates/forgust-db/migrations/20261114193156_update_check.sql b/crates/forgust-db/migrations/20261114193156_update_check.sql new file mode 100644 index 0000000..b5d9692 --- /dev/null +++ b/crates/forgust-db/migrations/20261114193156_update_check.sql @@ -0,0 +1,6 @@ +-- Administrators hear of new Forgust releases (the latest GitHub release), +-- unless they turn the check off. +ALTER TABLE instance_settings + ADD COLUMN update_check_enabled boolean NOT NULL DEFAULT true, + ADD COLUMN latest_version text, + ADD COLUMN latest_release_url text; diff --git a/crates/forgust-db/src/settings.rs b/crates/forgust-db/src/settings.rs index bfb5d3e..1af719e 100644 --- a/crates/forgust-db/src/settings.rs +++ b/crates/forgust-db/src/settings.rs @@ -9,18 +9,26 @@ pub struct InstanceSettings { pub allow_personal_projects: bool, pub default_branch_name: String, /// The MCP server answers. - #[serde(default = "on")] pub mcp_enabled: bool, + /// Ask GitHub for new Forgust releases, to tell administrators. + pub update_check_enabled: bool, } -fn on() -> bool { - true +pub async fn get(e: impl PgExecutor<'_>) -> Result { + sqlx::query_as!( + InstanceSettings, + "SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled, update_check_enabled FROM instance_settings" + ) + .fetch_one(e) + .await } -pub async fn get(e: impl PgExecutor<'_>) -> Result { +/// The settings, locked until the transaction ends: saves made of them (a +/// switch left out keeps its value) then go one at a time. +pub async fn get_for_update(e: impl PgExecutor<'_>) -> Result { sqlx::query_as!( InstanceSettings, - "SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled FROM instance_settings" + "SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled, update_check_enabled FROM instance_settings FOR UPDATE" ) .fetch_one(e) .await @@ -28,11 +36,12 @@ pub async fn get(e: impl PgExecutor<'_>) -> Result { pub async fn update(e: impl PgExecutor<'_>, s: &InstanceSettings) -> Result<()> { sqlx::query!( - "UPDATE instance_settings SET signup_enabled = $1, allow_personal_projects = $2, default_branch_name = $3, mcp_enabled = $4, updated_at = now()", + "UPDATE instance_settings SET signup_enabled = $1, allow_personal_projects = $2, default_branch_name = $3, mcp_enabled = $4, update_check_enabled = $5, updated_at = now()", s.signup_enabled, s.allow_personal_projects, s.default_branch_name, - s.mcp_enabled + s.mcp_enabled, + s.update_check_enabled ) .execute(e) .await?; @@ -43,3 +52,22 @@ pub async fn update(e: impl PgExecutor<'_>, s: &InstanceSettings) -> Result<()> pub async fn mcp_enabled(e: impl PgExecutor<'_>) -> Result { sqlx::query_scalar!("SELECT mcp_enabled FROM instance_settings").fetch_one(e).await } + +/// The latest Forgust release found, and its page. +pub struct LatestRelease { + pub version: String, + pub url: Option, +} + +/// The latest release found, while the release check is on. +pub async fn latest_release(e: impl PgExecutor<'_>) -> Result> { + let row = sqlx::query!("SELECT latest_version, latest_release_url FROM instance_settings WHERE update_check_enabled") + .fetch_optional(e) + .await?; + Ok(row.and_then(|r| Some(LatestRelease { version: r.latest_version?, url: r.latest_release_url }))) +} + +pub async fn set_latest_release(e: impl PgExecutor<'_>, version: &str, url: Option<&str>) -> Result<()> { + sqlx::query!("UPDATE instance_settings SET latest_version = $1, latest_release_url = $2", version, url).execute(e).await?; + Ok(()) +} diff --git a/crates/forgust-server/Cargo.toml b/crates/forgust-server/Cargo.toml index 927484d..25ef8e9 100644 --- a/crates/forgust-server/Cargo.toml +++ b/crates/forgust-server/Cargo.toml @@ -79,6 +79,7 @@ regex = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } percent-encoding = "2" url = "2" +semver = "1" reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "stream"] } hmac = "0.13" lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls", "ring", "webpki-roots"] } diff --git a/crates/forgust-server/src/api/admin.rs b/crates/forgust-server/src/api/admin.rs index cae4f8c..bb7d5d6 100644 --- a/crates/forgust-server/src/api/admin.rs +++ b/crates/forgust-server/src/api/admin.rs @@ -339,6 +339,7 @@ const RUNNABLE: &[(&str, &str)] = &[ ("maintenance_sweep", "Repository maintenance (full repack of repositories pushed to) and repository sizes"), ("cleanup", "Expired sessions and tokens, idle git processes"), ("measure_repositories", "Repository sizes for Storage"), + ("update_check", "Ask GitHub for the latest Forgust release (unless turned off in Settings)"), ]; #[derive(Serialize, ToSchema)] @@ -830,17 +831,66 @@ pub async fn settings(State(state): State, Admin(..): Admin) -> ApiRes Ok(Json(forgust_db::settings::get(&state.db).await?)) } +/// The instance settings to save. A switch left out keeps its value: a client +/// that predates it (or a script) does not turn it back on. +#[derive(Deserialize, ToSchema)] +pub struct UpdateInstanceSettings { + pub signup_enabled: bool, + pub allow_personal_projects: bool, + pub default_branch_name: String, + pub mcp_enabled: Option, + pub update_check_enabled: Option, +} + pub async fn update_settings( State(state): State, Admin(_, p): Admin, - Json(req): Json, + Json(req): Json, ) -> ApiResult> { if !forgust_storage::validate_rev(&req.default_branch_name) { return Err(ApiError::bad("invalid default branch name")); } - forgust_db::settings::update(&state.db, &req).await?; - services::audit(&state, &p, "instance_settings_updated", None, serde_json::to_value(&req).unwrap_or_default(), None).await; - Ok(Json(forgust_db::settings::get(&state.db).await?)) + // one save at a time: a switch left out keeps what the last save wrote, + // not what this one read before another save landed + let mut tx = state.db.begin().await?; + let before = forgust_db::settings::get_for_update(&mut *tx).await?; + let after = InstanceSettings { + signup_enabled: req.signup_enabled, + allow_personal_projects: req.allow_personal_projects, + default_branch_name: req.default_branch_name, + mcp_enabled: req.mcp_enabled.unwrap_or(before.mcp_enabled), + update_check_enabled: req.update_check_enabled.unwrap_or(before.update_check_enabled), + }; + forgust_db::settings::update(&mut *tx, &after).await?; + tx.commit().await?; + services::audit(&state, &p, "instance_settings_updated", None, serde_json::to_value(&after).unwrap_or_default(), None).await; + // turned (back) on: hear of the latest release now, not at the next 6-hourly check + if after.update_check_enabled + && !before.update_check_enabled + && let Err(e) = services::updates::queue_check(&state).await + { + tracing::warn!(error = %e, "could not queue the release check"); + } + Ok(Json(after)) +} + +#[derive(Serialize, ToSchema)] +pub struct UpdateStatus { + pub current: String, + /// The latest release on GitHub, when it is newer than this one and the check is on. + pub available: Option, + /// Its release notes, when GitHub gave them. + pub url: Option, +} + +/// Whether a newer Forgust release is out. +pub async fn update_status(State(state): State, Admin(..): Admin) -> ApiResult> { + let current = env!("CARGO_PKG_VERSION"); + let (available, url) = match services::updates::latest(&state).await? { + Some(l) if services::updates::newer(current, &l.version) => (Some(l.version), l.url), + _ => (None, None), + }; + Ok(Json(UpdateStatus { current: current.into(), available, url })) } #[derive(Deserialize)] diff --git a/crates/forgust-server/src/api/mod.rs b/crates/forgust-server/src/api/mod.rs index 04df185..25406ed 100644 --- a/crates/forgust-server/src/api/mod.rs +++ b/crates/forgust-server/src/api/mod.rs @@ -316,6 +316,7 @@ pub fn router() -> Router { .route("/admin/jobs/{job_id}/retry", post(admin::retry_job)) .route("/admin/jobs/{job_id}", delete(admin::drop_job)) .route("/admin/tasks", get(admin::runnable)) + .route("/admin/update", get(admin::update_status)) .route("/admin/tasks/{task}", post(admin::run_task)) .route("/admin/health/email", post(admin::test_email)) .route("/admin/banner", get(admin::banner_settings).put(admin::set_banner)) diff --git a/crates/forgust-server/src/api/openapi.rs b/crates/forgust-server/src/api/openapi.rs index eb26491..c2121c7 100644 --- a/crates/forgust-server/src/api/openapi.rs +++ b/crates/forgust-server/src/api/openapi.rs @@ -57,14 +57,14 @@ use super::{ projects::CreateProject, projects::UpdateProject, projects::NewPath, projects::TransferProject, projects::ProtectBranch, projects::AddDeployKey, repo::RefResolved, repo::TreeResponse, repo::PathCommit, repo::LastCommits, repo::RepoStats, crate::languages::LanguageShare, repo::BlobResponse, repo::LfsInfo, repo::ReadmeResponse, repo::CompareResponse, repo::BranchDto, repo::TagDto, repo::BlameResponse, repo::FileList, repo::MarkdownBody, repo::MarkdownHtml, repo::DescriptionTemplate, repo::NewBranch, repo::CommitBody, crate::services::edits::FileAction, crate::services::edits::ActionKind, crate::services::edits::Committed, super::admin::Overview, super::admin::CreateUser, super::admin::UpdateUser, - super::admin::StorageOverview, super::admin::SetQuota, super::admin::Backups, super::admin::BackupEntry, forgust_db::admin::AdminProject, forgust_db::admin::AdminGroup, super::admin::Runnable, super::admin::TestEmail, forgust_db::admin::AdminUser, forgust_db::admin::Banner, super::admin::Health, super::admin::HealthCheck, super::admin::JobsOverview, + super::admin::StorageOverview, super::admin::SetQuota, super::admin::Backups, super::admin::BackupEntry, forgust_db::admin::AdminProject, forgust_db::admin::AdminGroup, super::admin::Runnable, super::admin::TestEmail, forgust_db::admin::AdminUser, forgust_db::admin::Banner, super::admin::Health, super::admin::HealthCheck, super::admin::JobsOverview, super::admin::UpdateStatus, forgust_db::admin::InstanceCounts, forgust_db::admin::CiActivity, forgust_db::admin::PipelineDay, forgust_db::admin::BusyProject, forgust_db::admin::StorageKinds, forgust_db::admin::ProjectStorage, forgust_db::admin::GroupStorage, forgust_db::admin::BlobScan, forgust_db::admin::PrefixUsage, forgust_db::admin::TaskQueue, forgust_db::admin::FailedJob, dto::Resolved, dto::UserDto, dto::GroupDto, dto::GroupDetail, dto::OrgPolicyDto, dto::Breadcrumb, dto::ProjectDto, dto::ProjectDetail, dto::MemberDto, dto::ShareDto, dto::ProtectedBranchDto, dto::NewToken, dto::AccessTokenDto, forgust_db::ssh_keys::SshKey, forgust_db::ssh_keys::DeployKey, forgust_db::tokens::AccessToken, forgust_db::tokens::DeployToken, - forgust_db::audit::AuditEvent, forgust_db::settings::InstanceSettings, + forgust_db::audit::AuditEvent, forgust_db::settings::InstanceSettings, super::admin::UpdateInstanceSettings, forgust_storage::TreeEntry, forgust_storage::EntryKind, forgust_storage::CommitInfo, forgust_storage::CommitSummary, forgust_storage::Person, forgust_storage::BlameHunk, forgust_storage::Diff, forgust_storage::DiffFile, forgust_storage::DiffHunk, forgust_storage::DiffLine, forgust_storage::DiffLineKind, forgust_storage::FileStat, diff --git a/crates/forgust-server/src/config.rs b/crates/forgust-server/src/config.rs index d69d190..7c7fb43 100644 --- a/crates/forgust-server/src/config.rs +++ b/crates/forgust-server/src/config.rs @@ -218,6 +218,9 @@ pub struct ServerConfig { /// client IP (rate limits, audit, sessions) is the last hop not added by /// one of these; from anyone else the headers are ignored. pub trusted_proxies: Vec, + /// Where the worker asks for the latest Forgust release (GitHub's API, the + /// shape of `releases/latest`); empty: never ask. Admin > Settings turns it off too. + pub release_check_url: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -384,6 +387,7 @@ impl Default for ServerConfig { commit_name: "Forgust".into(), commit_email: "noreply@forgust.local".into(), trusted_proxies: vec!["127.0.0.0/8".parse().unwrap(), "::1/128".parse().unwrap()], + release_check_url: "https://api.github.com/repos/forgust-dev/forgust/releases/latest".into(), } } } diff --git a/crates/forgust-server/src/jobs.rs b/crates/forgust-server/src/jobs.rs index 1e6f666..560bd0e 100644 --- a/crates/forgust-server/src/jobs.rs +++ b/crates/forgust-server/src/jobs.rs @@ -627,9 +627,57 @@ impl TaskHandler for CiSweep { } } +/// Ask GitHub for the latest Forgust release (every 6 hours, at start, when +/// turned on, or from Admin > Jobs; unless turned off). +#[derive(Debug, Serialize)] +pub struct UpdateCheck { + /// Fail when the check does. Scheduled runs say `false`: an offline instance + /// would fill the failed jobs, and the next run tries again. Admin > Jobs + /// queues no payload, so an administrator's run shows why it failed. + pub report: bool, +} + +impl<'de> Deserialize<'de> for UpdateCheck { + fn deserialize>(d: D) -> Result { + let payload = serde_json::Value::deserialize(d)?; + Ok(UpdateCheck { report: payload.get("report").and_then(|r| r.as_bool()).unwrap_or(true) }) + } +} + +impl UpdateCheck { + /// Soon, once (it replaces one still waiting). + pub fn request(self) -> JobRequest { + JobRequest { + task: Self::IDENTIFIER, + payload: serde_json::to_value(&self).unwrap_or_default(), + job_key: Some(Self::IDENTIFIER.into()), + max_attempts: Some(1), + ..Default::default() + } + } +} + +impl TaskHandler for UpdateCheck { + const IDENTIFIER: &'static str = "update_check"; + + async fn run(self, ctx: WorkerContext) -> impl IntoTaskHandlerResult { + let state = ctx.get_ext::().ok_or("no app state")?; + match crate::services::updates::check(state).await { + Err(e) if self.report => Err(format!("release check failed: {e:#}")), + Err(e) => { + // with its causes: "error sending request" alone says nothing of DNS, TLS or a refused connection + tracing::warn!(error = %format!("{e:#}"), "release check failed"); + Ok(()) + } + Ok(()) => Ok(()), + } + } +} + /// Start the worker (role `worker`). Also installs the graphile schema. #[allow(deprecated)] pub async fn start_worker(state: AppState, concurrency: usize) -> anyhow::Result> { + let check_state = state.clone(); let worker = WorkerOptions::default() .concurrency(concurrency) .schema(SCHEMA) @@ -649,9 +697,10 @@ pub async fn start_worker(state: AppState, concurrency: usize) -> anyhow::Result .define_job::() .define_job::() .define_job::() + .define_job::() .define_job::() .define_job::() - .with_crontab("*/5 * * * * cleanup\n17 * * * * maintenance_sweep\n* * * * * ci_sweep")? + .with_crontab("*/5 * * * * cleanup\n17 * * * * maintenance_sweep\n* * * * * ci_sweep\n43 */6 * * * update_check {report:false}")? .listen_os_shutdown_signals(false) // a worker that stops answering (a restart, a crash) gives its jobs back // within minutes: else they stay locked to it for hours, and so does @@ -660,6 +709,10 @@ pub async fn start_worker(state: AppState, concurrency: usize) -> anyhow::Result .add_extension(state) .init() .await?; + // a fresh install, or one that was down, hears of a release now rather than in up to 6 hours + if let Err(e) = crate::services::updates::queue_check(&check_state).await { + tracing::warn!(error = %e, "could not queue the release check"); + } Ok(tokio::spawn(async move { if let Err(e) = worker.run().await { tracing::error!(error = %e, "job worker stopped"); @@ -689,4 +742,17 @@ mod payload_tests { serde_json::from_value::(payload).unwrap(); } } + + #[test] + fn only_runs_asked_for_report_a_failed_release_check() { + // Admin > Jobs queues null, which the queue stores as `{}` + assert!(serde_json::from_value::(serde_json::Value::Null).unwrap().report); + assert!(serde_json::from_value::(serde_json::json!({})).unwrap().report); + // the crontab line and `queue_check` say so + let cron = serde_json::json!({ "report": false, "_cron": { "ts": "x", "backfilled": false } }); + assert!(!serde_json::from_value::(cron).unwrap().report); + let queued = UpdateCheck { report: false }.request(); + assert_eq!(queued.payload, serde_json::json!({ "report": false })); + assert!(!serde_json::from_value::(queued.payload).unwrap().report); + } } diff --git a/crates/forgust-server/src/services/mod.rs b/crates/forgust-server/src/services/mod.rs index c0411ce..854bc47 100644 --- a/crates/forgust-server/src/services/mod.rs +++ b/crates/forgust-server/src/services/mod.rs @@ -32,6 +32,7 @@ pub mod security; pub mod signatures; pub mod stack; pub mod suggestions; +pub mod updates; pub mod uploads; pub mod users; pub mod webhooks; diff --git a/crates/forgust-server/src/services/updates.rs b/crates/forgust-server/src/services/updates.rs new file mode 100644 index 0000000..7871809 --- /dev/null +++ b/crates/forgust-server/src/services/updates.rs @@ -0,0 +1,97 @@ +//! New Forgust releases: the worker asks GitHub for the latest release every +//! 6 hours, at start and when the check is turned on (Admin > Settings turns it +//! off, an empty `server.release_check_url` too), and administrators see it when +//! it is newer than this one. + +use std::cmp::Ordering; +use std::time::Duration; + +use semver::Version; +use serde::Deserialize; + +use crate::state::AppState; + +/// Whether this instance asks for new releases at all. +pub async fn enabled(state: &AppState) -> anyhow::Result { + Ok(!state.cfg.server.release_check_url.is_empty() && forgust_db::settings::get(&state.db).await?.update_check_enabled) +} + +/// The latest release found, while the check is on: turned off (in the config +/// too), what an earlier check kept is not refreshed any more. +pub async fn latest(state: &AppState) -> anyhow::Result> { + if !enabled(state).await? { + return Ok(None); + } + Ok(forgust_db::settings::latest_release(&state.db).await?) +} + +/// Fetch and keep the latest release, if the check is on. +pub async fn check(state: &AppState) -> anyhow::Result<()> { + if !enabled(state).await? { + return Ok(()); + } + #[derive(Deserialize)] + struct Release { + tag_name: String, + html_url: Option, + } + let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build()?; + let release: Release = client + .get(&state.cfg.server.release_check_url) + .header("user-agent", concat!("Forgust/", env!("CARGO_PKG_VERSION"))) + .header("accept", "application/vnd.github+json") + .send() + .await? + .error_for_status()? + .json() + .await?; + let version = parse(&release.tag_name).ok_or_else(|| anyhow::anyhow!("unexpected release tag {:?}", release.tag_name))?; + // the page administrators open: a web address only + let url = release.html_url.filter(|u| u.starts_with("https://")); + forgust_db::settings::set_latest_release(&state.db, &version.to_string(), url.as_deref()).await?; + Ok(()) +} + +/// Check soon, in the background (at start, when turned on): a failure is only logged. +pub async fn queue_check(state: &AppState) -> anyhow::Result<()> { + if !enabled(state).await? { + return Ok(()); + } + let mut conn = state.db.acquire().await?; + state.jobs.enqueue(&mut conn, crate::jobs::UpdateCheck { report: false }.request()).await +} + +fn parse(tag: &str) -> Option { + Version::parse(tag.strip_prefix('v').unwrap_or(tag)).ok() +} + +/// Whether `latest` is newer than `current` (build metadata, `+…`, does not count). +pub fn newer(current: &str, latest: &str) -> bool { + match (parse(current), parse(latest)) { + (Some(current), Some(latest)) => latest.cmp_precedence(¤t) == Ordering::Greater, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_newer_releases_count() { + assert!(newer("0.1.0", "v0.2.0")); + assert!(newer("0.1.0", "0.1.1")); + assert!(newer("0.9.0", "v0.10.0")); + assert!(newer("1.0.0-rc.1", "v1.0.0")); + assert!(!newer("0.2.0", "v0.2.0")); + assert!(!newer("0.3.0", "v0.2.0")); + assert!(!newer("0.1.0", "nightly")); + assert!(!newer("nightly", "v0.2.0")); + // the same release, built differently + assert!(!newer("0.2.0", "v0.2.0+build.1")); + assert!(!newer("0.2.0+build.1", "v0.2.0")); + assert!(newer("0.2.0+build.1", "v0.2.1")); + // kept as found: no `v` + assert_eq!(parse("v0.2.0").map(|v| v.to_string()), Some("0.2.0".into())); + } +} diff --git a/crates/forgust-server/tests/common/mod.rs b/crates/forgust-server/tests/common/mod.rs index 9f630e1..2c5d08e 100644 --- a/crates/forgust-server/tests/common/mod.rs +++ b/crates/forgust-server/tests/common/mod.rs @@ -36,6 +36,8 @@ impl TestServer { base.server.static_dir = tmp.path().join("no-web"); base.server.hooks_exe = Some(PathBuf::from(env!("CARGO_BIN_EXE_forgust"))); base.telemetry.metrics = false; + // never ask GitHub for releases: a test that wants it serves its own + base.server.release_check_url = String::new(); tweak(&mut base); forgust_server::jobs::migrate(&pool).await.unwrap(); // the free port can be taken by a parallel test before we bind it: try another diff --git a/crates/forgust-server/tests/m13_admin.rs b/crates/forgust-server/tests/m13_admin.rs index efa8f3c..2bca66e 100644 --- a/crates/forgust-server/tests/m13_admin.rs +++ b/crates/forgust-server/tests/m13_admin.rs @@ -559,3 +559,114 @@ async fn run_now_tasks_run(pool: sqlx::PgPool) { .unwrap(); assert!(failed.is_empty(), "{failed:?}"); } + +#[sqlx::test(migrator = "forgust_db::MIGRATOR")] +async fn administrators_hear_of_new_releases(pool: sqlx::PgPool) { + // the check is on; nothing listens on port 1, so the one at start finds nothing + let srv = TestServer::start_with(pool, |c| c.server.release_check_url = "http://127.0.0.1:1/releases/latest".into()).await; + let admin = srv.user("root", true).await; + let owner = srv.user("olivia", false).await; + assert_eq!(owner.get("/api/v1/admin/update").await.0, 403); + + let u = admin.ok_get("/api/v1/admin/update").await; + assert_eq!(u["current"], env!("CARGO_PKG_VERSION")); + assert!(u["available"].is_null(), "nothing checked yet: {u}"); + + // what the worker keeps from GitHub + forgust_db::settings::set_latest_release(&srv.state.db, "99.0.0", Some("https://example.com/v99")).await.unwrap(); + let u = admin.ok_get("/api/v1/admin/update").await; + assert_eq!(u["available"], "99.0.0"); + assert_eq!(u["url"], "https://example.com/v99"); + + // turned off: nothing to tell + let mut settings = admin.ok_get("/api/v1/admin/settings").await; + assert_eq!(settings["update_check_enabled"], true); + settings["update_check_enabled"] = json!(false); + admin.ok_put("/api/v1/admin/settings", settings.clone()).await; + assert!(admin.ok_get("/api/v1/admin/update").await["available"].is_null()); + + // a client that does not know the switch leaves it off + let older = json!({ + "signup_enabled": settings["signup_enabled"], + "allow_personal_projects": settings["allow_personal_projects"], + "default_branch_name": settings["default_branch_name"], + }); + let saved = admin.ok_put("/api/v1/admin/settings", older).await; + assert_eq!(saved["update_check_enabled"], false); + assert_eq!(saved["mcp_enabled"], settings["mcp_enabled"]); +} + +/// An empty `server.release_check_url` turns the check off as Admin > Settings +/// does: what an earlier check kept is never refreshed, so it is not shown. +#[sqlx::test(migrator = "forgust_db::MIGRATOR")] +async fn a_release_check_off_in_the_config_tells_nothing(pool: sqlx::PgPool) { + let srv = TestServer::start(pool).await; + let admin = srv.user("root", true).await; + forgust_db::settings::set_latest_release(&srv.state.db, "99.0.0", Some("https://example.com/v99")).await.unwrap(); + assert_eq!(admin.ok_get("/api/v1/admin/settings").await["update_check_enabled"], true); + let u = admin.ok_get("/api/v1/admin/update").await; + assert!(u["available"].is_null(), "{u}"); + assert!(u["url"].is_null(), "{u}"); +} + +/// A GitHub stand-in answering `releases/latest` with `tag`. +async fn releases(tag: &'static str) -> String { + let app = axum::Router::new().route( + "/releases/latest", + axum::routing::get(move || async move { + axum::Json(json!({ "tag_name": tag, "html_url": format!("https://github.com/forgust-dev/forgust/releases/tag/{tag}") })) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}/releases/latest") +} + +/// The worker asks at start (no 6 hours to wait), keeps the release's own page, +/// and asks again when the check is turned back on. +#[sqlx::test(migrator = "forgust_db::MIGRATOR")] +async fn the_release_check_asks_at_start_and_when_turned_on(pool: sqlx::PgPool) { + let url = releases("v99.0.0").await; + let srv = TestServer::start_with(pool, |c| c.server.release_check_url = url).await; + let admin = srv.user("root", true).await; + wait_jobs(&srv.state).await; + let u = admin.ok_get("/api/v1/admin/update").await; + assert_eq!(u["available"], "99.0.0", "{u}"); + assert_eq!(u["url"], "https://github.com/forgust-dev/forgust/releases/tag/v99.0.0"); + + let mut settings = admin.ok_get("/api/v1/admin/settings").await; + settings["update_check_enabled"] = json!(false); + admin.ok_put("/api/v1/admin/settings", settings.clone()).await; + sqlx::query("UPDATE instance_settings SET latest_version = NULL, latest_release_url = NULL").execute(&srv.state.db).await.unwrap(); + settings["update_check_enabled"] = json!(true); + admin.ok_put("/api/v1/admin/settings", settings).await; + wait_jobs(&srv.state).await; + assert_eq!(admin.ok_get("/api/v1/admin/update").await["available"], "99.0.0"); +} + +/// A release check an administrator runs from Admin > Jobs fails when it +/// cannot reach GitHub; the one at start only logs it. +#[sqlx::test(migrator = "forgust_db::MIGRATOR")] +async fn a_release_check_run_now_reports_failure(pool: sqlx::PgPool) { + // nothing listens on port 1 + let srv = TestServer::start_with(pool, |c| c.server.release_check_url = "http://127.0.0.1:1/releases/latest".into()).await; + let admin = srv.user("root", true).await; + wait_jobs(&srv.state).await; + let failed = || async { + sqlx::query_scalar::<_, String>( + "SELECT j.last_error FROM graphile_worker._private_jobs j JOIN graphile_worker._private_tasks t ON t.id = j.task_id + WHERE t.identifier = 'update_check' AND j.last_error IS NOT NULL", + ) + .fetch_all(&srv.state.db) + .await + .unwrap() + }; + assert!(failed().await.is_empty(), "the check at start only logs"); + + admin.ok_post("/api/v1/admin/tasks/update_check", json!({})).await; + wait_jobs(&srv.state).await; + let errors = failed().await; + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("release check failed"), "{errors:?}"); +} diff --git a/forgust.example.toml b/forgust.example.toml index ec7ea99..cfbcc07 100644 --- a/forgust.example.toml +++ b/forgust.example.toml @@ -16,6 +16,9 @@ commit_email = "noreply@forgust.local" # add your load balancer's network when it is not on this host (else every client # looks like the proxy and shares one budget of failed sign-ins) trusted_proxies = ["127.0.0.0/8", "::1/128"] +# where the worker asks for the latest Forgust release, to tell administrators +# (Admin > Settings turns it off); empty: never ask +release_check_url = "https://api.github.com/repos/forgust-dev/forgust/releases/latest" [database] url = "postgres://forgust:forgust@localhost:5432/forgust" diff --git a/web/openapi.json b/web/openapi.json index 11108f0..5da646f 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -8405,7 +8405,9 @@ "required": [ "signup_enabled", "allow_personal_projects", - "default_branch_name" + "default_branch_name", + "mcp_enabled", + "update_check_enabled" ], "properties": { "allow_personal_projects": { @@ -8420,6 +8422,10 @@ }, "signup_enabled": { "type": "boolean" + }, + "update_check_enabled": { + "type": "boolean", + "description": "Ask GitHub for new Forgust releases, to tell administrators." } } }, @@ -14537,6 +14543,38 @@ } } }, + "UpdateInstanceSettings": { + "type": "object", + "description": "The instance settings to save. A switch left out keeps its value: a client\nthat predates it (or a script) does not turn it back on.", + "required": [ + "signup_enabled", + "allow_personal_projects", + "default_branch_name" + ], + "properties": { + "allow_personal_projects": { + "type": "boolean" + }, + "default_branch_name": { + "type": "string" + }, + "mcp_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "signup_enabled": { + "type": "boolean" + }, + "update_check_enabled": { + "type": [ + "boolean", + "null" + ] + } + } + }, "UpdateIssueBody": { "type": "object", "properties": { @@ -14729,6 +14767,31 @@ } } }, + "UpdateStatus": { + "type": "object", + "required": [ + "current" + ], + "properties": { + "available": { + "type": [ + "string", + "null" + ], + "description": "The latest release on GitHub, when it is newer than this one and the check is on." + }, + "current": { + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ], + "description": "Its release notes, when GitHub gave them." + } + } + }, "UpdateUser": { "type": "object", "properties": { diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 7729cb0..86e0e30 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -3660,8 +3660,10 @@ export interface components { allow_personal_projects: boolean; default_branch_name: string; /** @description The MCP server answers. */ - mcp_enabled?: boolean; + mcp_enabled: boolean; signup_enabled: boolean; + /** @description Ask GitHub for new Forgust releases, to tell administrators. */ + update_check_enabled: boolean; }; Issue: { /** Format: int64 */ @@ -5625,6 +5627,17 @@ export interface components { name: string; visibility: string; }; + /** + * @description The instance settings to save. A switch left out keeps its value: a client + * that predates it (or a script) does not turn it back on. + */ + UpdateInstanceSettings: { + allow_personal_projects: boolean; + default_branch_name: string; + mcp_enabled?: boolean | null; + signup_enabled: boolean; + update_check_enabled?: boolean | null; + }; UpdateIssueBody: { /** @description Replace the assignees (user ids). */ assignee_ids?: number[] | null; @@ -5679,6 +5692,13 @@ export interface components { /** Format: date-time */ released_at?: string | null; }; + UpdateStatus: { + /** @description The latest release on GitHub, when it is newer than this one and the check is on. */ + available?: string | null; + current: string; + /** @description Its release notes, when GitHub gave them. */ + url?: string | null; + }; UpdateUser: { is_admin?: boolean | null; /** @description External people see only what they are a member of. */ diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx index af6753a..1d9e04f 100644 --- a/web/src/pages/Admin.tsx +++ b/web/src/pages/Admin.tsx @@ -243,6 +243,7 @@ function Settings() { } function SettingsForm({ initial }: { initial: S["InstanceSettings"] }) { + const qc = useQueryClient(); const [s, setS] = useState(initial); const [msg, setMsg] = useState(null); const [error, setError] = useState(null); @@ -253,7 +254,14 @@ function SettingsForm({ initial }: { initial: S["InstanceSettings"] }) { onSubmit={async (e) => { e.preventDefault(); try { - await api.put("/admin/settings", s); + const saved = await api.put("/admin/settings", s); + // opened again, the form starts from what was saved, not from before + qc.setQueryData(["admin-settings"], saved); + qc.invalidateQueries({ queryKey: ["admin-update"] }); + // turned on, the check runs in the background: ask again once it has answered + if (saved.update_check_enabled && !initial.update_check_enabled) { + setTimeout(() => qc.invalidateQueries({ queryKey: ["admin-update"] }), 5000); + } setMsg("Saved."); } catch (err) { setError(err); @@ -276,6 +284,10 @@ function SettingsForm({ initial }: { initial: S["InstanceSettings"] }) { MCP server — AI assistants connect to /api/v4/mcp with a token or by signing in + setS({ ...s, default_branch_name: e.target.value })} /> diff --git a/web/src/pages/AdminTools.tsx b/web/src/pages/AdminTools.tsx index f007078..b65b3b5 100644 --- a/web/src/pages/AdminTools.tsx +++ b/web/src/pages/AdminTools.tsx @@ -342,7 +342,7 @@ export function AdminJobs() { )}

- Scheduled tasks (every minute: CI housekeeping; every 5 minutes: cleanup; hourly: repository maintenance) come back once done. + Scheduled tasks (every minute: CI housekeeping; every 5 minutes: cleanup; hourly: repository maintenance; every 6 hours: the release check) come back once done.

diff --git a/web/src/shell/Shell.tsx b/web/src/shell/Shell.tsx index 03f6043..2d84800 100644 --- a/web/src/shell/Shell.tsx +++ b/web/src/shell/Shell.tsx @@ -244,6 +244,7 @@ function Sidebar({ collapsed, setCollapsed, mobileOpen, closeMobile }: { collaps {collapsed ? : } {!c && Collapse} + {!c && } ); } @@ -379,6 +380,75 @@ function SiteBanner() { ); } +/** This server's version and the latest release, for administrators (Admin > Settings turns the check off). */ +function useUpdateStatus() { + const { user } = useSession(); + const q = useQuery({ + queryKey: ["admin-update"], + queryFn: () => api.get("/admin/update"), + enabled: !!user?.is_admin, + staleTime: 3_600_000, + }); + return user?.is_admin ? q.data : undefined; +} + +/** For administrators: a newer Forgust release is out, in a notice that stays until dismissed (then until the next one). */ +function UpdateNotice() { + const status = useUpdateStatus(); + const { announce, withdraw } = useToast(); + const v = status?.available; + const url = status?.url; + useEffect(() => { + if (!v) return; + // remembered per release, as the site banner is per text: a notice left + // open in another tab, for an older release, cannot bring this one back + const seen = `forgust.update.dismissed:${v}`; + try { + if (localStorage.getItem(seen) === "1") return; + } catch { + // storage blocked: shown on each load + } + const key = `update:${v}`; + announce({ + key, + title: "Update available", + text: `Forgust ${v} is available.`, + action: url ? { label: "View release notes", href: url } : undefined, + onDismiss: () => { + try { + localStorage.setItem(seen, "1"); + } catch { + // not remembered: back on the next load + } + }, + }); + // gone with what it says: signed out, the check turned off, a newer release + return () => withdraw(key); + }, [v, url, announce, withdraw]); + return null; +} + +/** The bottom of the sidebar, for administrators: this server's version, and a newer one. */ +function SideVersion() { + const status = useUpdateStatus(); + if (!status) return null; + const title = `Forgust ${status.available} is available`; + return ( +
+ v{status.current} + {status.available && + (status.url ? ( + + + Update available + + ) : ( + Update available + ))} +
+ ); +} + function EmailBanner() { const { user } = useSession(); if (!user || user.email_verified !== false) return null; @@ -482,6 +552,7 @@ export function Shell({ children }: { children: ReactNode }) { setMobileOpen(false)} />
+ {children} diff --git a/web/src/shell/toast.tsx b/web/src/shell/toast.tsx index 96c760f..b318d4a 100644 --- a/web/src/shell/toast.tsx +++ b/web/src/shell/toast.tsx @@ -1,23 +1,37 @@ import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Link } from "react-router-dom"; -import { Bell, CheckCircle2, CircleAlert, X } from "lucide-react"; +import { Bell, CheckCircle2, CircleAlert, Info, X } from "lucide-react"; import { ApiError } from "../api/client"; /** Where a notice leads, and what opening it does (marks it read). */ type Open = { to: string; onOpen?: () => void }; type Toast = { id: number; kind: "ok" | "error" | "notice"; text: string; open?: Open }; -const Ctx = createContext<{ ok: (t: string) => void; error: (e: unknown) => void; notice: (t: string, open?: Open) => void }>({ +/** Something to read and act on, which stays until dismissed or withdrawn (one per `key`). */ +export type Announcement = { key: string; title: string; text: string; action?: { label: string; href: string }; onDismiss?: () => void }; + +const Ctx = createContext<{ + ok: (t: string) => void; + error: (e: unknown) => void; + notice: (t: string, open?: Open) => void; + announce: (a: Announcement) => void; + withdraw: (key: string) => void; +}>({ ok: () => {}, error: () => {}, notice: () => {}, + announce: () => {}, + withdraw: () => {}, }); let seq = 0; export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); + // apart from the toasts, which go by themselves: these stay until dismissed, + // or withdrawn by what announced them once it no longer holds + const [announcements, setAnnouncements] = useState([]); const dismiss = useCallback((id: number) => setToasts((t) => t.filter((x) => x.id !== id)), []); const push = useCallback( (kind: Toast["kind"], text: string, open?: Open) => { @@ -31,11 +45,37 @@ export function ToastProvider({ children }: { children: ReactNode }) { const notice = useCallback((t: string, open?: Open) => push("notice", t, open), [push]); const ok = useCallback((t: string) => push("ok", t), [push]); const error = useCallback((e: unknown) => push("error", e instanceof ApiError ? e.message : String(e)), [push]); + const announce = useCallback((a: Announcement) => setAnnouncements((all) => (all.some((x) => x.key === a.key) ? all : [...all, a])), []); + const withdraw = useCallback((key: string) => setAnnouncements((all) => all.filter((x) => x.key !== key)), []); return ( - + {children} {createPortal(
+ {announcements.map((a) => ( +
+ +
+ {a.title} + {a.text} + {a.action && ( + + {a.action.label} + + )} +
+ +
+ ))} {toasts.map((t) => (
{t.kind === "ok" ? : t.kind === "notice" ? : } diff --git a/web/src/styles.css b/web/src/styles.css index 8e58353..d8f896f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1576,6 +1576,28 @@ kbd { .toast.error > svg:first-child { color: var(--danger); } +/* a notice: title, text and a link, until dismissed */ +.toast.info { + align-items: flex-start; + padding: 10px 8px 10px 12px; +} +.toast.info > svg:first-child { + color: var(--accent); + margin-top: 2px; +} +.toast-body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} +.toast-body > span { + color: var(--text-muted); + font-size: 13px; +} +.toast-body > .btn { + margin-top: 6px; +} @keyframes toast-in { from { transform: translateY(8px); @@ -1617,6 +1639,25 @@ kbd { .site-banner.info > svg { color: var(--accent); } +/* the bottom of the sidebar, for administrators: the version, and a newer one */ +.side-version { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 4px 0; + color: var(--text-faint); + font-size: 11px; +} +.side-version a { + display: inline-flex; + align-items: center; + gap: 4px; +} +.side-version .dot { + width: 6px; + height: 6px; +} /* ---- repository overview (Repo v1) */