WP-11: package and OS updates from the UI
package_upgrade job streams apt-get output into the job log (streaming CommandRunner, DebianUpdater with dist-upgrade or --only-upgrade), refreshes the inventory afterwards and flags reboot-required. POST /api/system/upgrade (admin), package name validation, selectable package table with confirm dialog and live log on the Updates page. Fake updater for dev. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -356,7 +356,7 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect
|
|||||||
| WP-01 | M1 | done | 2026-09-02 |
|
| WP-01 | M1 | done | 2026-09-02 |
|
||||||
| WP-02 | M1 (shell) / M2 (rest) | done | shell 2026-09-02; encrypted settings, SMTP, job runner, scheduler 2026-09-02 |
|
| WP-02 | M1 (shell) / M2 (rest) | done | shell 2026-09-02; encrypted settings, SMTP, job runner, scheduler 2026-09-02 |
|
||||||
| WP-10 | M2 | done | 2026-09-02; snaps inventoried, no upstream check |
|
| WP-10 | M2 | done | 2026-09-02; snaps inventoried, no upstream check |
|
||||||
| WP-11 | M2 | todo | |
|
| WP-11 | M2 | done | 2026-09-02; runs as root via systemd, sudoers scoping deferred to WP-41; log via polling |
|
||||||
| WP-12 | M2 | todo | |
|
| WP-12 | M2 | todo | |
|
||||||
| WP-20 | M3 | todo | |
|
| WP-20 | M3 | todo | |
|
||||||
| WP-21 | M3 | todo | |
|
| WP-21 | M3 | todo | |
|
||||||
|
|||||||
@ -15,16 +15,17 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use application::scheduler::Scheduler;
|
use application::scheduler::Scheduler;
|
||||||
use application::{
|
use application::{
|
||||||
AuthService, InventoryService, JobRunner, PackageRefreshJob, SettingsService, UserService,
|
AuthService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob,
|
||||||
|
SettingsService, UserService,
|
||||||
};
|
};
|
||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use domain::jobs::JobKind;
|
use domain::jobs::JobKind;
|
||||||
use domain::ports::HostInspector;
|
|
||||||
use domain::ports::Mailer;
|
use domain::ports::Mailer;
|
||||||
|
use domain::ports::{HostInspector, HostUpdater};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, FakeHostInspector, JwtIssuer,
|
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeHostInspector,
|
||||||
LettreMailer, SqliteAuditLog, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens,
|
FakeHostUpdater, JwtIssuer, LettreMailer, SqliteAuditLog, SqliteInventory, SqliteJobRuns,
|
||||||
SqliteSettings, SqliteUsers, SystemCommandRunner,
|
SqliteRefreshTokens, SqliteSettings, SqliteUsers, SystemCommandRunner,
|
||||||
};
|
};
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
@ -45,12 +46,17 @@ pub struct AppState {
|
|||||||
impl AppState {
|
impl AppState {
|
||||||
/// Wire the services on top of a connected database.
|
/// Wire the services on top of a connected database.
|
||||||
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
|
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
|
||||||
let inspector: Arc<dyn HostInspector> = if cfg.fake_host {
|
let runner = Arc::new(SystemCommandRunner);
|
||||||
Arc::new(FakeHostInspector)
|
let (inspector, updater): (Arc<dyn HostInspector>, Arc<dyn HostUpdater>) = if cfg.fake_host
|
||||||
|
{
|
||||||
|
(Arc::new(FakeHostInspector), Arc::new(FakeHostUpdater))
|
||||||
} else {
|
} else {
|
||||||
Arc::new(DebianInspector::new(Arc::new(SystemCommandRunner)))
|
(
|
||||||
|
Arc::new(DebianInspector::new(runner.clone())),
|
||||||
|
Arc::new(DebianUpdater::new(runner)),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
Self::with_adapters(cfg, pool, Arc::new(LettreMailer), inspector, |r| r)
|
Self::with_adapters(cfg, pool, Arc::new(LettreMailer), inspector, updater, |r| r)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wiring with replaceable adapters (used by tests and the fake-host mode).
|
/// Wiring with replaceable adapters (used by tests and the fake-host mode).
|
||||||
@ -59,6 +65,7 @@ impl AppState {
|
|||||||
pool: DbPool,
|
pool: DbPool,
|
||||||
mailer: Arc<dyn Mailer>,
|
mailer: Arc<dyn Mailer>,
|
||||||
inspector: Arc<dyn HostInspector>,
|
inspector: Arc<dyn HostInspector>,
|
||||||
|
updater: Arc<dyn HostUpdater>,
|
||||||
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
|
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let users = Arc::new(SqliteUsers(pool.clone()));
|
let users = Arc::new(SqliteUsers(pool.clone()));
|
||||||
@ -80,10 +87,18 @@ impl AppState {
|
|||||||
inspector,
|
inspector,
|
||||||
Arc::new(SqliteInventory(pool.clone())),
|
Arc::new(SqliteInventory(pool.clone())),
|
||||||
));
|
));
|
||||||
let runner = JobRunner::new(Arc::new(SqliteJobRuns(pool))).register(
|
let runner = JobRunner::new(Arc::new(SqliteJobRuns(pool)))
|
||||||
JobKind::PackageRefresh,
|
.register(
|
||||||
Arc::new(PackageRefreshJob(inventory.clone())),
|
JobKind::PackageRefresh,
|
||||||
);
|
Arc::new(PackageRefreshJob(inventory.clone())),
|
||||||
|
)
|
||||||
|
.register(
|
||||||
|
JobKind::PackageUpgrade,
|
||||||
|
Arc::new(PackageUpgradeJob {
|
||||||
|
updater,
|
||||||
|
inventory: inventory.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
let jobs = Arc::new(register_jobs(runner));
|
let jobs = Arc::new(register_jobs(runner));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@ -26,7 +26,7 @@ impl Modify for BearerAuth {
|
|||||||
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
|
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
|
||||||
crate::settings::get_smtp, crate::settings::put_smtp, crate::settings::test_smtp, crate::settings::list_schedules, crate::settings::put_schedule,
|
crate::settings::get_smtp, crate::settings::put_smtp, crate::settings::test_smtp, crate::settings::list_schedules, crate::settings::put_schedule,
|
||||||
crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run,
|
crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run,
|
||||||
crate::system::inventory,
|
crate::system::inventory, crate::system::upgrade,
|
||||||
),
|
),
|
||||||
modifiers(&BearerAuth)
|
modifiers(&BearerAuth)
|
||||||
)]
|
)]
|
||||||
|
|||||||
@ -1,17 +1,51 @@
|
|||||||
//! /api/system: host inventory.
|
//! /api/system: host inventory.
|
||||||
|
use application::UpgradeParams;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::routing::get;
|
use axum::http::StatusCode;
|
||||||
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use domain::host::{OsInfo, Package};
|
use domain::host::{OsInfo, Package};
|
||||||
use serde::Serialize;
|
use domain::jobs::JobKind;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::extract::AuthUser;
|
use crate::extract::{AdminUser, AuthUser};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new().route("/inventory", get(inventory))
|
Router::new()
|
||||||
|
.route("/inventory", get(inventory))
|
||||||
|
.route("/upgrade", post(upgrade))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct UpgradeRequest {
|
||||||
|
/// Empty upgrades all packages.
|
||||||
|
#[serde(default)]
|
||||||
|
pub packages: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/system/upgrade", tag = "system", security(("bearer" = [])), request_body = UpgradeRequest,
|
||||||
|
responses((status = 202, body = crate::jobs::JobRunDto), (status = 409), (status = 422)))]
|
||||||
|
async fn upgrade(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AdminUser(admin): AdminUser,
|
||||||
|
Json(req): Json<UpgradeRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<crate::jobs::JobRunDto>), ApiError> {
|
||||||
|
let params = UpgradeParams {
|
||||||
|
packages: req.packages,
|
||||||
|
};
|
||||||
|
params.validate()?;
|
||||||
|
let run = state
|
||||||
|
.jobs
|
||||||
|
.start(
|
||||||
|
JobKind::PackageUpgrade,
|
||||||
|
Some(params.to_json()),
|
||||||
|
&admin.email,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::ACCEPTED, Json(run.into())))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
|||||||
@ -84,6 +84,7 @@ async fn build_test_app_with(cfg: Config) -> Router {
|
|||||||
pool,
|
pool,
|
||||||
Arc::new(RecordingMailer),
|
Arc::new(RecordingMailer),
|
||||||
Arc::new(infrastructure::FakeHostInspector),
|
Arc::new(infrastructure::FakeHostInspector),
|
||||||
|
Arc::new(infrastructure::FakeHostUpdater),
|
||||||
register_test_jobs,
|
register_test_jobs,
|
||||||
)
|
)
|
||||||
.expect("state");
|
.expect("state");
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::host::validate_package_name;
|
use domain::host::validate_package_name;
|
||||||
use domain::ports::HostUpdater;
|
use domain::ports::{HostUpdater, LineSink};
|
||||||
use domain::DomainError;
|
use domain::DomainError;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -30,8 +30,13 @@ impl UpgradeParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_json(s: Option<&str>) -> Result<Self, DomainError> {
|
pub fn from_json(s: Option<&str>) -> Result<Self, DomainError> {
|
||||||
let _ = s;
|
let params = match s.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
todo!()
|
None => Self::default(),
|
||||||
|
Some(s) => serde_json::from_str(s)
|
||||||
|
.map_err(|e| DomainError::Validation(format!("invalid params: {e}")))?,
|
||||||
|
};
|
||||||
|
params.validate()?;
|
||||||
|
Ok(params)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,9 +45,52 @@ pub struct PackageUpgradeJob {
|
|||||||
pub inventory: Arc<InventoryService>,
|
pub inventory: Arc<InventoryService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
/// Bridges the synchronous `LineSink` of the updater to the async job log.
|
||||||
impl JobHandler for PackageUpgradeJob {
|
struct ChannelSink(tokio::sync::mpsc::UnboundedSender<String>);
|
||||||
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
|
|
||||||
todo!()
|
impl LineSink for ChannelSink {
|
||||||
|
fn line(&self, text: &str) {
|
||||||
|
let _ = self.0.send(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JobHandler for PackageUpgradeJob {
|
||||||
|
async fn run(&self, params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
|
||||||
|
let params = UpgradeParams::from_json(params.as_deref()).map_err(|e| e.to_string())?;
|
||||||
|
if params.packages.is_empty() {
|
||||||
|
log.line("upgrading all packages").await;
|
||||||
|
} else {
|
||||||
|
log.line(&format!("upgrading: {}", params.packages.join(" ")))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let sink = ChannelSink(tx);
|
||||||
|
let upgrade = async {
|
||||||
|
let r = self.updater.upgrade(¶ms.packages, &sink).await;
|
||||||
|
drop(sink);
|
||||||
|
r
|
||||||
|
};
|
||||||
|
let drain = async {
|
||||||
|
while let Some(line) = rx.recv().await {
|
||||||
|
log.line(&line).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (result, _) = tokio::join!(upgrade, drain);
|
||||||
|
result.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
log.line("refreshing inventory").await;
|
||||||
|
let inv = self.inventory.refresh().await.map_err(|e| e.to_string())?;
|
||||||
|
log.line(&format!(
|
||||||
|
"{} packages, {} upgradable",
|
||||||
|
inv.packages.len(),
|
||||||
|
inv.upgradable()
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
if inv.os.reboot_required {
|
||||||
|
log.line("NOTE: reboot required to complete the update")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,9 +19,36 @@ impl DebianUpdater {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl HostUpdater for DebianUpdater {
|
impl HostUpdater for DebianUpdater {
|
||||||
async fn upgrade(&self, _packages: &[String], _out: &dyn LineSink) -> Result<(), DomainError> {
|
async fn upgrade(&self, packages: &[String], out: &dyn LineSink) -> Result<(), DomainError> {
|
||||||
let _ = &self.runner;
|
out.line("$ apt-get update");
|
||||||
todo!()
|
if !self
|
||||||
|
.runner
|
||||||
|
.run_streaming("apt-get", &["update", "-q"], out)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
out.line("warning: apt-get update failed, continuing with the current package index");
|
||||||
|
}
|
||||||
|
let mut args: Vec<&str> = vec![
|
||||||
|
"-y",
|
||||||
|
"-q",
|
||||||
|
"-o",
|
||||||
|
"Dpkg::Options::=--force-confdef",
|
||||||
|
"-o",
|
||||||
|
"Dpkg::Options::=--force-confold",
|
||||||
|
];
|
||||||
|
if packages.is_empty() {
|
||||||
|
args.push("dist-upgrade");
|
||||||
|
} else {
|
||||||
|
args.extend(["install", "--only-upgrade"]);
|
||||||
|
args.extend(packages.iter().map(String::as_str));
|
||||||
|
}
|
||||||
|
out.line(&format!("$ apt-get {}", args.join(" ")));
|
||||||
|
if !self.runner.run_streaming("apt-get", &args, out).await? {
|
||||||
|
return Err(DomainError::Unavailable(
|
||||||
|
"apt-get exited with a non-zero status".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,16 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Package } from '../api/types'
|
import type { Package } from '../api/types'
|
||||||
|
|
||||||
const props = defineProps<{ packages: Package[] }>()
|
const props = defineProps<{ packages: Package[]; selectable?: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'update:selected': [names: string[]] }>()
|
||||||
|
const selected = ref<Set<string>>(new Set())
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
|
|
||||||
|
function toggle(name: string, on: boolean) {
|
||||||
|
if (on) selected.value.add(name)
|
||||||
|
else selected.value.delete(name)
|
||||||
|
emit('update:selected', [...selected.value])
|
||||||
|
}
|
||||||
const upgradableOnly = ref(false)
|
const upgradableOnly = ref(false)
|
||||||
const source = ref<'all' | 'apt' | 'snap'>('all')
|
const source = ref<'all' | 'apt' | 'snap'>('all')
|
||||||
|
|
||||||
@ -52,6 +60,7 @@ const filtered = computed(() =>
|
|||||||
<table class="mt-4 w-full text-left text-sm">
|
<table class="mt-4 w-full text-left text-sm">
|
||||||
<thead class="border-b border-gray-200 text-gray-500">
|
<thead class="border-b border-gray-200 text-gray-500">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th v-if="selectable" class="w-8"></th>
|
||||||
<th class="py-2">Package</th>
|
<th class="py-2">Package</th>
|
||||||
<th>Source</th>
|
<th>Source</th>
|
||||||
<th>Installed</th>
|
<th>Installed</th>
|
||||||
@ -66,6 +75,16 @@ const filtered = computed(() =>
|
|||||||
class="border-b border-gray-100"
|
class="border-b border-gray-100"
|
||||||
:class="p.candidate ? 'bg-amber-50' : ''"
|
:class="p.candidate ? 'bg-amber-50' : ''"
|
||||||
>
|
>
|
||||||
|
<td v-if="selectable">
|
||||||
|
<input
|
||||||
|
v-if="p.candidate"
|
||||||
|
name="select"
|
||||||
|
type="checkbox"
|
||||||
|
:aria-label="`Select ${p.name}`"
|
||||||
|
:checked="selected.has(p.name)"
|
||||||
|
@change="toggle(p.name, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td class="py-1.5 font-mono">{{ p.name }}</td>
|
<td class="py-1.5 font-mono">{{ p.name }}</td>
|
||||||
<td>{{ p.source }}</td>
|
<td>{{ p.source }}</td>
|
||||||
<td class="font-mono text-gray-600">{{ p.installed }}</td>
|
<td class="font-mono text-gray-600">{{ p.installed }}</td>
|
||||||
@ -79,7 +98,9 @@ const filtered = computed(() =>
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="filtered.length === 0">
|
<tr v-if="filtered.length === 0">
|
||||||
<td colspan="5" class="py-6 text-center text-gray-500">No packages match.</td>
|
<td :colspan="selectable ? 6 : 5" class="py-6 text-center text-gray-500">
|
||||||
|
No packages match.
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@ -10,7 +10,11 @@ const auth = useAuthStore()
|
|||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const inv = ref<InventoryResponse | null>(null)
|
const inv = ref<InventoryResponse | null>(null)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
const selected = ref<string[]>([])
|
||||||
|
const confirm = ref<{ packages: string[] } | null>(null)
|
||||||
|
const upgradeRun = ref<JobRun | null>(null)
|
||||||
let timer: ReturnType<typeof setInterval> | undefined
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let upgradeTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
inv.value = await api.get<InventoryResponse>('/api/system/inventory')
|
inv.value = await api.get<InventoryResponse>('/api/system/inventory')
|
||||||
@ -19,7 +23,10 @@ async function load() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
load().catch((e) => toast.error(e instanceof ApiError ? e.message : 'Request failed'))
|
load().catch((e) => toast.error(e instanceof ApiError ? e.message : 'Request failed'))
|
||||||
})
|
})
|
||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => {
|
||||||
|
clearInterval(timer)
|
||||||
|
clearInterval(upgradeTimer)
|
||||||
|
})
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
@ -44,6 +51,29 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startUpgrade() {
|
||||||
|
if (!confirm.value) return
|
||||||
|
const packages = confirm.value.packages
|
||||||
|
confirm.value = null
|
||||||
|
try {
|
||||||
|
upgradeRun.value = await api.post<JobRun>('/api/system/upgrade', { packages })
|
||||||
|
upgradeTimer = setInterval(async () => {
|
||||||
|
if (!upgradeRun.value) return
|
||||||
|
const r = await api.get<JobRun>(`/api/jobs/${upgradeRun.value.id}`)
|
||||||
|
upgradeRun.value = r
|
||||||
|
if (r.status !== 'running') {
|
||||||
|
clearInterval(upgradeTimer)
|
||||||
|
selected.value = []
|
||||||
|
if (r.status === 'success') toast.success('Update finished')
|
||||||
|
else toast.error('Update failed, see the log')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Request failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uptime = (s: number) => {
|
const uptime = (s: number) => {
|
||||||
const d = Math.floor(s / 86400)
|
const d = Math.floor(s / 86400)
|
||||||
const h = Math.floor((s % 86400) / 3600)
|
const h = Math.floor((s % 86400) / 3600)
|
||||||
@ -99,7 +129,75 @@ const uptime = (s: number) => {
|
|||||||
No inventory yet. Run a refresh to query the host.
|
No inventory yet. Run a refresh to query the host.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div v-if="upgradeRun" class="mt-6 rounded-md border border-gray-200 bg-white">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2 text-sm">
|
||||||
|
<span>
|
||||||
|
Update
|
||||||
|
<span class="font-medium" :class="upgradeRun.status === 'failed' ? 'text-red-700' : ''">{{
|
||||||
|
upgradeRun.status
|
||||||
|
}}</span>
|
||||||
|
· started {{ new Date(upgradeRun.started_at).toLocaleTimeString() }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="upgradeRun.status !== 'running'"
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
@click="upgradeRun = null"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre data-testid="upgrade-log" class="max-h-80 overflow-auto p-4 text-xs">{{
|
||||||
|
upgradeRun.log || '(waiting for output)'
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="inv && inv.packages.length" class="mt-8">
|
<div v-if="inv && inv.packages.length" class="mt-8">
|
||||||
<PackageTable :packages="inv.packages" />
|
<div v-if="auth.isAdmin && inv.summary.upgradable" class="mb-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
:disabled="selected.length === 0 || upgradeRun?.status === 'running'"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="confirm = { packages: selected }"
|
||||||
|
>
|
||||||
|
Update selected ({{ selected.length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="upgradeRun?.status === 'running'"
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
@click="confirm = { packages: [] }"
|
||||||
|
>
|
||||||
|
Update all ({{ inv.summary.upgradable }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<PackageTable
|
||||||
|
:packages="inv.packages"
|
||||||
|
:selectable="auth.isAdmin"
|
||||||
|
@update:selected="selected = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="confirm" class="fixed inset-0 flex items-center justify-center bg-black/30">
|
||||||
|
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-lg" role="dialog">
|
||||||
|
<h2 class="text-lg font-semibold">Apply updates?</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
|
<template v-if="confirm.packages.length">
|
||||||
|
apt-get will upgrade: <span class="font-mono">{{ confirm.packages.join(', ') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else
|
||||||
|
>apt-get dist-upgrade will upgrade all {{ inv?.summary.upgradable }} packages.</template
|
||||||
|
>
|
||||||
|
A reboot may be required afterwards.
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button class="rounded-md border border-gray-300 px-4 py-2 text-sm" @click="confirm = null">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||||
|
@click="startUpgrade"
|
||||||
|
>
|
||||||
|
Confirm update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user