feat: add backup CLI commands, AI correction/modifier services, and timezone-aware date display

- Add `devplace backups` CLI subcommands (list, run, prune, clear) with job enqueueing and orphan cleanup
- Introduce `BACKUPS_DIR` and `BACKUP_STAGING_DIR` config paths for backup storage
- Implement `schedule_correction` and `schedule_modification` calls in content creation, comment creation, and comment editing flows
- Add `DEFAULT_CORRECTION_PROMPT` and `DEFAULT_MODIFIER_PROMPT` config constants for AI content processing
- Document timezone-aware date display using `local_dt`/`dt_ago` Jinja globals with client-side `Intl` localization
- Update README with AI content correction/modifier support in direct messages via `@ai` inline instructions
- Add `track_action(user["uid"], "vote")` call on upvote in `apply_vote`
This commit is contained in:
2026-06-16 03:32:19 +00:00
parent e59bc2d34e
commit 15bd4ad87c
115 changed files with 5839 additions and 187 deletions
+16 -7
View File
@@ -9,6 +9,11 @@
.admin-content {
min-width: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.5rem;
}
.admin-toolbar {
@@ -41,28 +46,28 @@
gap: 0.4rem;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border-color);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 600;
}
.admin-tab:hover {
color: var(--text-color);
border-color: var(--accent-color);
color: var(--text-primary);
border-color: var(--accent);
}
.admin-tab.active {
color: var(--accent-color);
border-color: var(--accent-color);
background: var(--accent-soft, rgba(127, 127, 127, 0.08));
color: var(--accent);
border-color: var(--accent);
background: var(--accent-light);
}
.admin-tab-count {
font-size: 0.6875rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--bg-muted, rgba(127, 127, 127, 0.15));
background: var(--bg-card-hover);
color: var(--text-muted);
}
@@ -425,6 +430,10 @@
}
@media (max-width: 480px) {
.admin-content {
padding: 1rem;
}
.admin-toolbar {
flex-direction: column;
align-items: flex-start;
+109
View File
@@ -0,0 +1,109 @@
/* retoor <retoor@molodetz.nl> */
.backup-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-md);
}
.backup-controls label {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-secondary);
}
.backup-generated {
color: var(--text-muted);
font-size: 0.85rem;
}
.backups {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.backups-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-lg);
}
.backups-section h3 {
margin: 0 0 var(--space-md);
color: var(--text-primary);
}
.backups-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.backups-sha,
.backups-path {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
word-break: break-all;
}
.backups-missing {
color: var(--danger);
}
.backups-status {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: var(--radius);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
border: 1px solid var(--border);
}
.backups-status-done {
color: var(--success);
border-color: var(--success);
}
.backups-status-failed {
color: var(--danger);
border-color: var(--danger);
}
.backups-status-running,
.backups-status-pending {
color: var(--warning);
border-color: var(--warning);
}
.backup-schedule-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.backup-schedule-form label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
color: var(--text-secondary);
}
.backup-schedule-form input,
.backup-schedule-form select {
padding: var(--space-sm);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
}
.backup-schedule-form .modal-actions {
display: flex;
justify-content: flex-end;
}
+2 -3
View File
@@ -338,6 +338,7 @@ body {
color: var(--text-primary);
min-height: 100vh;
line-height: 1.5;
padding-top: var(--nav-height);
}
a {
@@ -934,7 +935,7 @@ body:has(.page-messages) {
}
.page:has(.page-messages) {
padding-top: var(--nav-height);
padding-top: 0.5rem;
padding-bottom: 0;
flex: 1 1 auto;
min-height: 0;
@@ -989,7 +990,6 @@ body:has(.page-messages) {
max-width: var(--max-content);
margin: 0 auto;
padding: 0.5rem 1rem;
padding-top: calc(var(--nav-height) + 0.5rem);
font-size: 0.8125rem;
}
@@ -1089,7 +1089,6 @@ body:has(.page-messages) {
@media (max-width: 1024px) {
.breadcrumb {
padding: 0.375rem 0.5rem;
padding-top: calc(var(--nav-height) + 0.375rem);
font-size: 0.75rem;
}
}
+4
View File
@@ -271,6 +271,10 @@ dp-upload[hidden] {
transform: translateY(0);
}
.dp-toast-link {
cursor: pointer;
}
.dp-toast-success {
border-color: var(--success);
}
+143
View File
@@ -442,6 +442,25 @@ a.profile-stat-value:hover {
color: var(--text-secondary);
}
.ai-correction-prompt {
width: 100%;
margin: 0.5rem 0;
resize: vertical;
}
.ai-correction-status {
font-size: 0.75rem;
color: var(--text-secondary);
}
.ai-correction-status[data-state="success"] {
color: var(--success, #2f9e44);
}
.ai-correction-status[data-state="error"] {
color: var(--danger, #e03131);
}
.follow-row {
display: flex;
align-items: center;
@@ -515,6 +534,37 @@ a.profile-stat-value:hover {
color: var(--text-secondary);
}
.correction-usage-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: var(--space-sm);
}
.correction-usage-stat {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.correction-usage-value {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
}
.correction-usage-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.correction-usage-cost .correction-usage-value {
color: var(--accent);
}
.ai-usage-empty {
color: var(--text-muted);
font-size: 0.85rem;
@@ -748,3 +798,96 @@ a.profile-stat-value:hover {
.notif-prefs-actions {
margin-top: 1rem;
}
.profile-achievements {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
}
.profile-achievements-summary {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: var(--text-primary);
list-style: none;
}
.profile-achievements-summary::-webkit-details-marker {
display: none;
}
.profile-achievements-count {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.achievement-group {
margin-top: var(--space-md);
}
.achievement-group-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
margin: 0 0 var(--space-sm) 0;
display: flex;
gap: var(--space-sm);
align-items: baseline;
}
.achievement-group-count {
color: var(--text-muted);
font-size: 0.8rem;
}
.achievement-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-sm);
}
.achievement {
display: flex;
gap: var(--space-sm);
align-items: center;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.achievement.locked {
opacity: 0.45;
filter: grayscale(1);
}
.achievement.earned {
border-color: var(--accent);
}
.achievement-icon {
font-size: 1.5rem;
line-height: 1;
}
.achievement-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.achievement-name {
font-weight: 600;
color: var(--text-primary);
}
.achievement-desc {
font-size: 0.8rem;
color: var(--text-muted);
}
-1
View File
@@ -14,7 +14,6 @@
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
padding-top: calc(var(--nav-height) + 0.5rem);
scroll-margin-top: calc(var(--nav-height) + 1rem);
}
+52
View File
@@ -0,0 +1,52 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class AiCorrection {
constructor() {
this.root = document.querySelector("[data-ai-correction]");
if (!this.root) return;
this.username = this.root.dataset.username;
this.toggle = this.root.querySelector("[data-ai-correction-toggle]");
this.mode = this.root.querySelector("[data-ai-correction-sync]");
this.prompt = this.root.querySelector("[data-ai-correction-prompt]");
this.status = this.root.querySelector("[data-ai-correction-status]");
this.save = this.root.querySelector("[data-ai-correction-save]");
if (this.save) this.save.addEventListener("click", () => this.submit());
}
async submit() {
if (!this.toggle || !this.prompt) return;
this.save.disabled = true;
this.setStatus("Saving...", "");
try {
await Http.send(`/profile/${this.username}/ai-correction`, {
enabled: this.toggle.checked ? "true" : "false",
sync: this.mode && this.mode.value === "sync" ? "true" : "false",
prompt: this.prompt.value,
});
this.setStatus("Saved", "success");
this.flash("AI correction settings saved", "success");
} catch (error) {
this.setStatus("Could not save", "error");
this.flash("Could not save AI correction settings", "error");
} finally {
this.save.disabled = false;
}
}
setStatus(message, type) {
if (!this.status) return;
this.status.textContent = message;
this.status.dataset.state = type;
}
flash(message, type) {
if (window.app && window.app.toast) {
window.app.toast.show(message, { type });
}
}
}
window.AiCorrection = AiCorrection;
export { AiCorrection };
+52
View File
@@ -0,0 +1,52 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class AiModifier {
constructor() {
this.root = document.querySelector("[data-ai-modifier]");
if (!this.root) return;
this.username = this.root.dataset.username;
this.toggle = this.root.querySelector("[data-ai-modifier-toggle]");
this.mode = this.root.querySelector("[data-ai-modifier-sync]");
this.prompt = this.root.querySelector("[data-ai-modifier-prompt]");
this.status = this.root.querySelector("[data-ai-modifier-status]");
this.save = this.root.querySelector("[data-ai-modifier-save]");
if (this.save) this.save.addEventListener("click", () => this.submit());
}
async submit() {
if (!this.toggle || !this.prompt) return;
this.save.disabled = true;
this.setStatus("Saving...", "");
try {
await Http.send(`/profile/${this.username}/ai-modifier`, {
enabled: this.toggle.checked ? "true" : "false",
sync: this.mode && this.mode.value === "sync" ? "true" : "false",
prompt: this.prompt.value,
});
this.setStatus("Saved", "success");
this.flash("AI modifier settings saved", "success");
} catch (error) {
this.setStatus("Could not save", "error");
this.flash("Could not save AI modifier settings", "error");
} finally {
this.save.disabled = false;
}
}
setStatus(message, type) {
if (!this.status) return;
this.status.textContent = message;
this.status.dataset.state = type;
}
flash(message, type) {
if (window.app && window.app.toast) {
window.app.toast.show(message, { type });
}
}
}
window.AiModifier = AiModifier;
export { AiModifier };
+10 -1
View File
@@ -19,6 +19,7 @@ class AiUsageMonitor {
if (this.windowSelect) {
this.windowSelect.addEventListener("change", () => {
this.hours = parseInt(this.windowSelect.value, 10) || 48;
this.subscribe();
this.poll();
});
}
@@ -27,7 +28,15 @@ class AiUsageMonitor {
this.root.classList.toggle("show-divided", this.dividedToggle.checked);
});
}
this.poller = new Poller(() => this.poll(), this.pollMs);
this.subscribe();
this.poller = new Poller(() => this.poll(), 60000);
}
subscribe() {
const pubsub = window.app && window.app.pubsub;
if (!pubsub) return;
if (this.unsubscribe) this.unsubscribe();
this.unsubscribe = pubsub.subscribe(`admin.ai-usage.${this.hours}`, (data) => this.render(data));
}
async poll() {
+10 -2
View File
@@ -20,6 +20,8 @@ import { PollManager } from "./PollManager.js";
import { ApiKeyManager } from "./ApiKeyManager.js";
import { CustomizationToggle } from "./CustomizationToggle.js";
import { NotificationPrefs } from "./NotificationPrefs.js";
import { AiCorrection } from "./AiCorrection.js";
import { AiModifier } from "./AiModifier.js";
import { AdminNotificationDefaults } from "./AdminNotificationDefaults.js";
import { UserAiUsage } from "./UserAiUsage.js";
import { Tabs } from "./Tabs.js";
@@ -32,6 +34,8 @@ import { MediaGallery } from "./MediaGallery.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
import { LiveNotifications } from "./LiveNotifications.js";
import { LocalTime } from "./LocalTime.js";
class Application {
constructor() {
@@ -52,13 +56,16 @@ class Application {
this.content = new ContentEnhancer();
this.dom = new DomUtils();
this.push = new PushManager();
this.counters = new CounterManager();
this.pubsub = new PubSubClient();
this.counters = new CounterManager(this.pubsub);
this.reactions = new ReactionBar();
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
this.apiKey = new ApiKeyManager();
this.customizationToggle = new CustomizationToggle();
this.notificationPrefs = new NotificationPrefs();
this.aiCorrection = new AiCorrection();
this.aiModifier = new AiModifier();
this.adminNotificationDefaults = new AdminNotificationDefaults();
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
@@ -70,7 +77,8 @@ class Application {
this.issueReporter = new IssueReporter();
this.planningGenerator = new PlanningGenerator();
this.mediaGallery = new MediaGallery();
this.pubsub = new PubSubClient();
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
this.localTime = new LocalTime();
}
}
+334
View File
@@ -0,0 +1,334 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { JobPoller } from "./JobPoller.js";
import { DateFormat } from "./DateFormat.js";
class BackupMonitor {
constructor() {
this.root = document.getElementById("backups-root");
this.generated = document.querySelector("[data-meta='generated']");
this.runForm = document.getElementById("backup-run-form");
this.scheduleForm = document.getElementById("backup-schedule-form");
this.scheduleModal = document.getElementById("backup-schedule-modal");
this.scheduleKind = document.getElementById("backup-schedule-kind");
this.scheduleTitle = document.getElementById("backup-schedule-title");
this.pollMs = 8000;
this.targets = [];
}
start() {
if (!this.root) return;
this.bindRunForm();
this.bindScheduleForm();
this.bindActions();
this.poller = new Poller(() => this.poll(), this.pollMs);
}
async poll() {
try {
const data = await Http.getJson("/admin/backups/data");
this.targets = data.targets || [];
this.render(data);
} catch {
// ignore transient poll errors
}
}
bindRunForm() {
if (!this.runForm) return;
this.runForm.addEventListener("submit", async (event) => {
event.preventDefault();
const target = this.runForm.querySelector("[name='target']").value;
const button = this.runForm.querySelector("button[type='submit']");
button.disabled = true;
try {
const result = await Http.send("/admin/backups/run", { target });
window.app.toast.show("Backup started", { type: "info" });
this.followJob(result.status_url);
} catch {
// Http.send already surfaced the error
} finally {
button.disabled = false;
}
});
}
followJob(statusUrl) {
if (!statusUrl) return;
this.poll();
JobPoller.run(statusUrl, {
onDone: () => {
window.app.toast.show("Backup completed", { type: "success" });
this.poll();
},
onFailed: (status) => {
Http.notifyError((status && status.error) || "Backup failed");
this.poll();
},
});
}
bindScheduleForm() {
if (!this.scheduleForm) return;
const newButton = document.getElementById("backup-schedule-new");
if (newButton) {
newButton.addEventListener("click", () => this.resetScheduleForm());
}
if (this.scheduleKind) {
this.scheduleKind.addEventListener("change", () => this.syncScheduleKind());
}
this.scheduleForm.addEventListener("submit", async (event) => {
event.preventDefault();
const params = Object.fromEntries(new FormData(this.scheduleForm).entries());
try {
await Http.send(this.scheduleForm.action, params);
this.scheduleModal.classList.remove("visible");
window.app.toast.show("Schedule saved", { type: "success" });
this.poll();
} catch {
// error already surfaced
}
});
}
syncScheduleKind() {
const kind = this.scheduleKind.value;
this.scheduleForm.querySelectorAll("[data-kind]").forEach((node) => {
node.hidden = node.dataset.kind !== kind;
});
}
resetScheduleForm() {
this.scheduleForm.reset();
this.scheduleForm.action = "/admin/backups/schedules/create";
this.scheduleTitle.textContent = "New backup schedule";
this.syncScheduleKind();
}
openScheduleEdit(schedule) {
const form = this.scheduleForm;
form.action = `/admin/backups/schedules/${schedule.uid}/edit`;
form.querySelector("[name='name']").value = schedule.name || "";
form.querySelector("[name='target']").value = schedule.target || "full";
form.querySelector("[name='kind']").value = schedule.kind || "interval";
form.querySelector("[name='every_seconds']").value = schedule.every_seconds || 86400;
form.querySelector("[name='cron']").value = schedule.cron || "";
form.querySelector("[name='keep_last']").value = schedule.keep_last || 0;
this.scheduleTitle.textContent = `Edit schedule: ${schedule.name || ""}`;
this.syncScheduleKind();
this.scheduleModal.classList.add("visible");
}
bindActions() {
this.root.addEventListener("click", async (event) => {
const button = event.target.closest("[data-action]");
if (!button) return;
const action = button.dataset.action;
const uid = button.dataset.uid;
if (action === "edit-schedule") {
const schedule = this.schedulesByUid[uid];
if (schedule) this.openScheduleEdit(schedule);
return;
}
const prompts = {
"delete-backup": "Permanently delete this backup archive?",
"delete-schedule": "Delete this backup schedule?",
"toggle-schedule": null,
"run-schedule": null,
};
const message = prompts[action];
if (message) {
const ok = await window.app.dialog.confirm({ message, danger: true });
if (!ok) return;
}
const urls = {
"delete-backup": `/admin/backups/${uid}/delete`,
"delete-schedule": `/admin/backups/schedules/${uid}/delete`,
"toggle-schedule": `/admin/backups/schedules/${uid}/toggle`,
"run-schedule": `/admin/backups/schedules/${uid}/run`,
};
try {
await Http.send(urls[action], {});
this.poll();
} catch {
// error already surfaced
}
});
}
el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = String(text);
return node;
}
cards(items) {
const grid = this.el("div", "metric-cards");
for (const item of items) {
const card = this.el("div", "metric-card");
card.appendChild(this.el("span", "metric-value", item.value));
card.appendChild(this.el("span", "metric-label", item.label));
grid.appendChild(card);
}
return grid;
}
section(title, ...nodes) {
const wrap = this.el("div", "backups-section");
wrap.appendChild(this.el("h3", null, title));
for (const node of nodes) if (node) wrap.appendChild(node);
return wrap;
}
storageSection(storage) {
const disk = storage.disk || {};
const totals = storage.backups_total || {};
const data = storage.data_dir || {};
const overview = this.cards([
{ label: "Disk used", value: `${disk.used_human || "0"} / ${disk.total_human || "0"}` },
{ label: "Disk free", value: disk.free_human || "0" },
{ label: "Disk usage", value: `${disk.used_percent || 0}%` },
{ label: "Data directory", value: data.size_human || "0" },
{ label: "Backups stored", value: totals.count || 0 },
{ label: "Backups size", value: totals.size_human || "0" },
]);
const table = this.el("table", "metric-table");
const head = this.el("thead");
const headRow = this.el("tr");
for (const col of ["Path", "Size", "Files", "Location"]) headRow.appendChild(this.el("th", null, col));
head.appendChild(headRow);
table.appendChild(head);
const body = this.el("tbody");
for (const path of storage.paths || []) {
const tr = this.el("tr");
tr.appendChild(this.el("td", null, path.label));
tr.appendChild(this.el("td", null, path.size_human));
tr.appendChild(this.el("td", null, path.file_count));
const location = this.el("td", "backups-path", path.path);
if (!path.exists) location.classList.add("backups-missing");
tr.appendChild(location);
body.appendChild(tr);
}
table.appendChild(body);
return this.section("Storage", overview, table);
}
backupsSection(backups, metrics) {
const overview = this.cards([
{ label: "Total", value: metrics.total || 0 },
{ label: "Done", value: metrics.done || 0 },
{ label: "Running", value: metrics.running || 0 },
{ label: "Pending", value: metrics.pending || 0 },
{ label: "Failed", value: metrics.failed || 0 },
{ label: "Archive size", value: metrics.size_human || "0" },
]);
if (!backups.length) {
return this.section("Backups", overview, this.el("p", "admin-empty", "No backups yet."));
}
const table = this.el("table", "metric-table backups-table");
const head = this.el("thead");
const headRow = this.el("tr");
for (const col of ["Created", "Target", "Status", "Size", "Files", "Checksum", "Actions"]) {
headRow.appendChild(this.el("th", null, col));
}
head.appendChild(headRow);
table.appendChild(head);
const body = this.el("tbody");
for (const backup of backups) {
const tr = this.el("tr");
tr.appendChild(this.el("td", null, DateFormat.format(backup.created_at, true)));
tr.appendChild(this.el("td", null, backup.label || backup.target));
const status = this.el("td");
status.appendChild(this.el("span", `backups-status backups-status-${backup.status}`, backup.status));
tr.appendChild(status);
tr.appendChild(this.el("td", null, backup.size_human || "-"));
tr.appendChild(this.el("td", null, backup.file_count || 0));
tr.appendChild(this.el("td", "backups-sha", backup.sha256 ? backup.sha256.slice(0, 12) : "-"));
tr.appendChild(this.backupActions(backup));
body.appendChild(tr);
}
table.appendChild(body);
return this.section("Backups", overview, table);
}
backupActions(backup) {
const cell = this.el("td", "backups-actions");
if (backup.download_url) {
const link = this.el("a", "admin-btn admin-btn-sm", "Download");
link.href = backup.download_url;
cell.appendChild(link);
}
const remove = this.el("button", "admin-btn admin-btn-sm admin-btn-danger", "Delete");
remove.dataset.action = "delete-backup";
remove.dataset.uid = backup.uid;
cell.appendChild(remove);
return cell;
}
schedulesSection(schedules) {
this.schedulesByUid = {};
for (const schedule of schedules) this.schedulesByUid[schedule.uid] = schedule;
if (!schedules.length) {
return this.section("Schedules", this.el("p", "admin-empty", "No backup schedules configured."));
}
const table = this.el("table", "metric-table backups-schedules");
const head = this.el("thead");
const headRow = this.el("tr");
for (const col of ["Name", "Target", "Trigger", "Keep", "Next run", "Last run", "Runs", "State", "Actions"]) {
headRow.appendChild(this.el("th", null, col));
}
head.appendChild(headRow);
table.appendChild(head);
const body = this.el("tbody");
for (const schedule of schedules) {
const tr = this.el("tr");
tr.appendChild(this.el("td", null, schedule.name));
tr.appendChild(this.el("td", null, schedule.target));
const trigger = schedule.kind === "cron" ? `cron ${schedule.cron}` : `every ${schedule.every_seconds}s`;
tr.appendChild(this.el("td", null, trigger));
tr.appendChild(this.el("td", null, schedule.keep_last || "all"));
tr.appendChild(this.el("td", null, schedule.next_run_at ? DateFormat.format(schedule.next_run_at, true) : "-"));
tr.appendChild(this.el("td", null, schedule.last_run_at ? DateFormat.format(schedule.last_run_at, true) : "-"));
tr.appendChild(this.el("td", null, schedule.run_count || 0));
const state = this.el("td");
state.appendChild(this.el("span", `backups-status backups-status-${schedule.enabled ? "done" : "failed"}`, schedule.enabled ? "enabled" : "disabled"));
tr.appendChild(state);
tr.appendChild(this.scheduleActions(schedule));
body.appendChild(tr);
}
table.appendChild(body);
return this.section("Schedules", table);
}
scheduleActions(schedule) {
const cell = this.el("td", "backups-actions");
const definitions = [
["run-schedule", "Run", ""],
["edit-schedule", "Edit", ""],
["toggle-schedule", schedule.enabled ? "Disable" : "Enable", ""],
["delete-schedule", "Delete", "admin-btn-danger"],
];
for (const [action, label, extra] of definitions) {
const button = this.el("button", `admin-btn admin-btn-sm ${extra}`.trim(), label);
button.dataset.action = action;
button.dataset.uid = schedule.uid;
cell.appendChild(button);
}
return cell;
}
render(data) {
if (this.generated && data.generated_at) {
this.generated.textContent = `Updated ${DateFormat.format(data.generated_at, true)}`;
}
this.root.textContent = "";
this.root.appendChild(this.storageSection(data.storage || {}));
this.root.appendChild(this.backupsSection(data.backups || [], data.metrics || {}));
this.root.appendChild(this.schedulesSection(data.schedules || []));
}
}
window.BackupMonitor = BackupMonitor;
+10 -3
View File
@@ -12,7 +12,7 @@ export class BotMonitor {
}
init() {
this.poller = new Poller(() => this.refresh(), this.pollMs, {
this.poller = new Poller(() => this.refresh(), 15000, {
immediate: false,
pauseHidden: true,
});
@@ -20,6 +20,10 @@ export class BotMonitor {
immediate: true,
pauseHidden: true,
});
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe("fleet.bots", (payload) => this.apply(payload));
}
}
escape(value) {
@@ -62,8 +66,11 @@ export class BotMonitor {
}
async refresh() {
const payload = await Http.getJson(this.endpoint);
const frames = payload.frames || [];
this.apply(await Http.getJson(this.endpoint));
}
apply(payload) {
const frames = (payload && payload.frames) || [];
if (this.count) {
this.count.textContent = `${frames.length} bots`;
}
+21 -9
View File
@@ -21,6 +21,14 @@ export class ContainerInstance {
this.bind();
this.startDetailPoll();
this.startLogPoll();
this.subscribe();
}
subscribe() {
const pubsub = window.app && window.app.pubsub;
if (!pubsub) return;
pubsub.subscribe(`container.${this.uid}.detail`, (data) => this.applyDetail(data));
pubsub.subscribe(`container.${this.uid}.logs`, (data) => this.renderLogs(data.logs || ""));
}
bind() {
@@ -97,7 +105,7 @@ export class ContainerInstance {
this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
}, 4000);
}, 20000);
}
applyDetail(detail) {
@@ -127,21 +135,25 @@ export class ContainerInstance {
}
startLogPoll() {
const pre = this.q("#ci-log");
this.logPoll = new Poller(async () => {
try {
const data = await Http.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
const logs = data.logs || "";
pre.classList.toggle("ci-empty", !logs);
pre.textContent = logs || (this.status === "running"
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
: "No logs. Start the instance to capture its main-process output.");
if (logs) pre.scrollTop = pre.scrollHeight;
this.renderLogs(data.logs || "");
} catch (error) {
const pre = this.q("#ci-log");
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
}, 3000);
}, 20000);
}
renderLogs(logs) {
const pre = this.q("#ci-log");
pre.classList.toggle("ci-empty", !logs);
pre.textContent = logs || (this.status === "running"
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
: "No logs. Start the instance to capture its main-process output.");
if (logs) pre.scrollTop = pre.scrollHeight;
}
// ---------------- exec ----------------
+9 -2
View File
@@ -15,7 +15,11 @@ export class ContainerList {
init() {
this.bindActions();
this.bindCreate();
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
this.poller = new Poller(() => this.refresh(), 20000, { immediate: false });
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe("container.list", (data) => this.render(data.instances || []));
}
}
toast(message, type) {
@@ -106,7 +110,10 @@ export class ContainerList {
}
async refresh() {
const instances = (await Http.getJson(this.endpoint)).instances || [];
this.render((await Http.getJson(this.endpoint)).instances || []);
}
render(instances) {
if (!this.body) return;
if (!instances.length) {
this.body.innerHTML = `<tr><td colspan="8" class="admin-empty">No container instances yet. Create one above or open a project's container manager.</td></tr>`;
+8 -1
View File
@@ -17,7 +17,14 @@ export class ContainerManager {
this.instances = data.instances || [];
this.bind();
this.renderInstances();
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
this.poller = new Poller(() => this.refresh(), 20000, { immediate: false });
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe(`project.${this.slug}.containers`, (payload) => {
this.instances = payload.instances || [];
this.renderInstances();
});
}
}
bind() {
+13 -4
View File
@@ -4,7 +4,7 @@ import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class CounterManager {
constructor() {
constructor(pubsub) {
if (!document.querySelector("[data-counter]")) {
return;
}
@@ -13,7 +13,17 @@ export class CounterManager {
this.poll();
}
});
this.poller = new Poller(() => this.poll(), 30000, { pauseHidden: true });
const uid = document.body.dataset.userUid || "";
if (pubsub && uid) {
pubsub.subscribe(`user.${uid}.counts`, (data) => this.applyCounts(data));
}
this.poller = new Poller(() => this.poll(), 60000, { pauseHidden: true });
}
applyCounts(counts) {
if (!counts) return;
this.apply("notifications", counts.notifications);
this.apply("messages", counts.messages);
}
async poll() {
@@ -24,8 +34,7 @@ export class CounterManager {
console.error("counter poll failed", error);
return;
}
this.apply("notifications", counts.notifications);
this.apply("messages", counts.messages);
this.applyCounts(counts);
}
apply(key, count) {
+28
View File
@@ -0,0 +1,28 @@
// retoor <retoor@molodetz.nl>
const TOAST_MS = 6000;
export class LiveNotifications {
constructor(pubsub, toast) {
this.pubsub = pubsub;
this.toast = toast;
this.uid = document.body.dataset.userUid || "";
if (!this.uid) return;
this.topic = `user.${this.uid}.notifications`;
this.pubsub.subscribe(this.topic, (data) => this.onNotification(data));
}
onNotification(data) {
if (!data || !data.message) return;
this.toast.show(data.message, {
type: "info",
ms: TOAST_MS,
url: this._openUrl(data),
});
}
_openUrl(data) {
if (data.uid) return `/notifications/open/${data.uid}`;
return data.target_url || "/notifications";
}
}
+69
View File
@@ -0,0 +1,69 @@
// retoor <retoor@molodetz.nl>
const REFRESH_MS = 60000;
export class LocalTime {
constructor() {
this.localize(document);
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) this.localize(node);
}
}
});
if (document.body) {
this.observer.observe(document.body, { childList: true, subtree: true });
}
window.setInterval(() => this.refreshRelative(), REFRESH_MS);
}
format(iso, mode) {
const date = new Date(iso);
if (isNaN(date.getTime())) return null;
if (mode === "ago") return this.relative(date);
if (mode === "date") return date.toLocaleDateString();
if (mode === "time") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
}
relative(date) {
const seconds = (Date.now() - date.getTime()) / 1000;
if (seconds < 0) return "just now";
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + "m ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + "h ago";
const days = Math.floor(hours / 24);
if (days <= 30) return days + "d ago";
return date.toLocaleDateString();
}
apply(el) {
const iso = el.getAttribute("datetime");
if (!iso) return;
const mode = el.dataset.dtMode || "datetime";
const text = this.format(iso, mode);
if (text === null) return;
el.textContent = text;
const full = new Date(iso);
if (!isNaN(full.getTime())) {
el.title = full.toLocaleString([], { dateStyle: "full", timeStyle: "long" });
}
}
localize(root) {
if (root.matches && root.matches("[data-dt]")) this.apply(root);
if (!root.querySelectorAll) return;
root.querySelectorAll("[data-dt]").forEach((el) => this.apply(el));
}
refreshRelative() {
document
.querySelectorAll('[data-dt][data-dt-mode="ago"]')
.forEach((el) => this.apply(el));
}
}
+31 -4
View File
@@ -183,6 +183,7 @@ export class MessagesLayout {
mine: true,
clientId,
time: "now",
iso: new Date().toISOString(),
attachmentCount,
});
bubble.classList.add("pending");
@@ -196,8 +197,25 @@ export class MessagesLayout {
if (pending) {
pending.classList.remove("pending");
pending.dataset.msgUid = frame.uid;
const oldBody = pending.querySelector(".rendered-content");
if (oldBody && frame.content !== undefined) {
const body = document.createElement("div");
body.className = "rendered-content";
body.setAttribute("data-render", "");
body.textContent = frame.content || "";
oldBody.replaceWith(body);
this.renderBody(body);
}
const time = pending.querySelector(".message-time");
if (time) time.textContent = frame.time_ago;
if (time && frame.created_at) {
time.setAttribute("datetime", frame.created_at);
time.dataset.dt = "";
time.dataset.dtMode = "ago";
if (window.app && window.app.localTime) window.app.localTime.apply(time);
else time.textContent = frame.time_ago;
} else if (time) {
time.textContent = frame.time_ago;
}
const placeholder = pending.querySelector(".attachment-pending");
if (placeholder) placeholder.remove();
const gallery = this.renderAttachments(frame.attachments);
@@ -216,6 +234,7 @@ export class MessagesLayout {
content: frame.content,
mine,
time: frame.time_ago,
iso: frame.created_at,
uid: frame.uid,
attachments: frame.attachments,
});
@@ -227,7 +246,7 @@ export class MessagesLayout {
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
}
buildBubble({ content, mine, time, uid, clientId, attachments, attachmentCount }) {
buildBubble({ content, mine, time, iso, uid, clientId, attachments, attachmentCount }) {
const bubble = document.createElement("div");
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
if (uid) bubble.dataset.msgUid = uid;
@@ -251,9 +270,17 @@ export class MessagesLayout {
bubble.appendChild(placeholder);
}
const timeEl = document.createElement("span");
const timeEl = document.createElement(iso ? "time" : "span");
timeEl.className = "message-time";
timeEl.textContent = time || "";
if (iso) {
timeEl.setAttribute("datetime", iso);
timeEl.dataset.dt = "";
timeEl.dataset.dtMode = "ago";
if (window.app && window.app.localTime) window.app.localTime.apply(timeEl);
else timeEl.textContent = time || "";
} else {
timeEl.textContent = time || "";
}
bubble.appendChild(timeEl);
if (mine) {
+20 -8
View File
@@ -18,7 +18,24 @@ class ServiceMonitor {
if (!container) return;
this.bindActions(container);
this.bindConfigForms(container);
this.poller = new Poller(() => this.poll(), this.pollInterval);
this.poller = new Poller(() => this.poll(), 20000);
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
const topic = this.detail ? `admin.services.${this.detailName}` : "admin.services";
pubsub.subscribe(topic, (data) => this.applyData(data));
}
}
applyData(data) {
if (!data) return;
if (this.detail) {
if (data.service) this.update(this.detail, data.service);
} else if (this.list && Array.isArray(data.services)) {
for (const svc of data.services) {
const row = this.list.querySelector(`[data-service="${svc.name}"]`);
if (row) this.update(row, svc);
}
}
}
bindActions(container) {
@@ -93,14 +110,9 @@ class ServiceMonitor {
async poll() {
try {
if (this.detail) {
const data = await Http.getJson(`/admin/services/${this.detailName}/data`);
if (data.service) this.update(this.detail, data.service);
this.applyData(await Http.getJson(`/admin/services/${this.detailName}/data`));
} else if (this.list) {
const data = await Http.getJson("/admin/services/data");
for (const svc of data.services) {
const row = this.list.querySelector(`[data-service="${svc.name}"]`);
if (row) this.update(row, svc);
}
this.applyData(await Http.getJson("/admin/services/data"));
}
} catch {
// silently ignore poll errors
@@ -13,6 +13,11 @@ export class AppToast extends Component {
const toast = document.createElement("div");
toast.className = `dp-toast dp-toast-${type}`;
toast.textContent = message;
const action = this._action(options);
if (action) {
toast.classList.add("dp-toast-link");
toast.addEventListener("click", action);
}
this.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("visible"));
setTimeout(() => {
@@ -21,6 +26,16 @@ export class AppToast extends Component {
}, ms);
return toast;
}
_action(options) {
if (typeof options.onClick === "function") return options.onClick;
if (options.url) {
return () => {
window.location.href = options.url;
};
}
return null;
}
}
customElements.define("dp-toast", AppToast);