forked from retoor/devplacepy
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,10 @@ dp-upload[hidden] {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dp-toast-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dp-toast-success {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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 ----------------
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user