forked from joseph/notebooklm-api
1217 lines
50 KiB
HTML
1217 lines
50 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="vi">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>NotebookLM Scheduler</title>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
|
||
:root{
|
||
--bg:#f4f5f7;
|
||
--surface:#fff;
|
||
--surface2:#f8f9fb;
|
||
--border:#e4e7ed;
|
||
--border2:#d1d5e0;
|
||
--primary:#5a57e8;
|
||
--primary-bg:#eeedfd;
|
||
--primary-hover:#4845d4;
|
||
--text:#1a1d27;
|
||
--text2:#5c6480;
|
||
--text3:#9399b0;
|
||
--green:#16a34a;
|
||
--green-bg:#dcfce7;
|
||
--red:#dc2626;
|
||
--red-bg:#fee2e2;
|
||
--amber:#d97706;
|
||
--amber-bg:#fef3c7;
|
||
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
||
--shadow-md:0 4px 12px rgba(0,0,0,.08),0 2px 4px rgba(0,0,0,.04);
|
||
--shadow-lg:0 12px 32px rgba(0,0,0,.12),0 4px 8px rgba(0,0,0,.06);
|
||
--radius:10px;
|
||
--radius-sm:6px;
|
||
--radius-lg:14px;
|
||
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||
--transition:120ms ease;
|
||
}
|
||
|
||
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}
|
||
|
||
/* ── Header ── */
|
||
.header{
|
||
background:var(--surface);
|
||
border-bottom:1px solid var(--border);
|
||
display:flex;align-items:center;gap:0;
|
||
padding:0 24px;height:56px;
|
||
position:sticky;top:0;z-index:100;
|
||
}
|
||
.logo{display:flex;align-items:center;gap:10px;margin-right:32px;text-decoration:none}
|
||
.logo-icon{
|
||
width:30px;height:30px;border-radius:8px;
|
||
background:var(--primary);display:flex;align-items:center;justify-content:center;
|
||
}
|
||
.logo-icon svg{width:16px;height:16px;fill:#fff}
|
||
.logo-text{font-weight:700;font-size:15px;color:var(--text)}
|
||
.nav{display:flex;gap:2px;flex:1}
|
||
.nav-btn{
|
||
padding:6px 14px;border-radius:var(--radius-sm);border:none;
|
||
background:none;cursor:pointer;font-size:13px;font-weight:500;
|
||
color:var(--text2);transition:background var(--transition),color var(--transition);
|
||
display:flex;align-items:center;gap:6px;
|
||
}
|
||
.nav-btn:hover{background:var(--bg);color:var(--text)}
|
||
.nav-btn.active{background:var(--primary-bg);color:var(--primary)}
|
||
.nav-btn .badge{
|
||
background:var(--border2);color:var(--text2);
|
||
font-size:11px;font-weight:600;padding:1px 6px;border-radius:20px;
|
||
min-width:20px;text-align:center;
|
||
}
|
||
.nav-btn.active .badge{background:var(--primary);color:#fff}
|
||
.header-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||
.auth-dot{
|
||
width:8px;height:8px;border-radius:50%;
|
||
background:var(--text3);flex-shrink:0;
|
||
}
|
||
.auth-dot.ok{background:var(--green)}
|
||
.auth-label{font-size:12px;color:var(--text3)}
|
||
|
||
/* ── Layout ── */
|
||
.main{padding:24px;max-width:1200px;margin:0 auto}
|
||
.tab-content{display:none}
|
||
.tab-content.active{display:block}
|
||
|
||
/* ── Two-column layout ── */
|
||
.cols{display:grid;grid-template-columns:280px 1fr;gap:20px;align-items:start}
|
||
|
||
/* ── Cards ── */
|
||
.card{
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;
|
||
}
|
||
.card-header{
|
||
padding:14px 16px;display:flex;align-items:center;justify-content:space-between;
|
||
border-bottom:1px solid var(--border);
|
||
}
|
||
.card-title{font-size:13px;font-weight:600;color:var(--text)}
|
||
.card-sub{font-size:12px;color:var(--text3)}
|
||
|
||
/* ── Notebook list ── */
|
||
.nb-list{list-style:none}
|
||
.nb-item{
|
||
display:flex;align-items:center;gap:10px;
|
||
padding:10px 16px;cursor:pointer;
|
||
border-bottom:1px solid var(--border);
|
||
transition:background var(--transition);
|
||
position:relative;
|
||
}
|
||
.nb-item:last-child{border-bottom:none}
|
||
.nb-item:hover{background:var(--surface2)}
|
||
.nb-item.selected{background:var(--primary-bg)}
|
||
.nb-item.selected::before{
|
||
content:'';position:absolute;left:0;top:8px;bottom:8px;
|
||
width:3px;border-radius:0 3px 3px 0;background:var(--primary);
|
||
}
|
||
.nb-icon{
|
||
width:32px;height:32px;border-radius:8px;
|
||
background:var(--surface2);border:1px solid var(--border);
|
||
display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
||
}
|
||
.nb-icon svg{width:15px;height:15px;stroke:var(--text2)}
|
||
.nb-item.selected .nb-icon{background:var(--primary-bg);border-color:var(--primary)}
|
||
.nb-item.selected .nb-icon svg{stroke:var(--primary)}
|
||
.nb-info{flex:1;min-width:0}
|
||
.nb-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.nb-item.selected .nb-name{color:var(--primary)}
|
||
.nb-count{font-size:11px;color:var(--text3);white-space:nowrap}
|
||
.nb-src-badge{
|
||
font-size:11px;font-weight:600;color:var(--text3);
|
||
background:var(--border);padding:2px 7px;border-radius:20px;
|
||
flex-shrink:0;
|
||
}
|
||
.nb-item.selected .nb-src-badge{background:var(--primary);color:#fff}
|
||
|
||
/* ── Right panel ── */
|
||
.panel-empty{
|
||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
padding:60px 24px;gap:12px;text-align:center;
|
||
}
|
||
.panel-empty svg{width:40px;height:40px;stroke:var(--text3);opacity:.5}
|
||
.panel-empty p{color:var(--text3);font-size:13px}
|
||
|
||
/* ── Sources list ── */
|
||
.src-list{list-style:none}
|
||
.src-item{
|
||
display:flex;align-items:center;gap:12px;
|
||
padding:11px 16px;border-bottom:1px solid var(--border);
|
||
}
|
||
.src-item:last-child{border-bottom:none}
|
||
.src-type-badge{
|
||
font-size:10px;font-weight:700;letter-spacing:.3px;
|
||
padding:2px 7px;border-radius:4px;flex-shrink:0;text-transform:uppercase;
|
||
}
|
||
.src-type-pdf{background:#fee2e2;color:#dc2626}
|
||
.src-type-url{background:#dbeafe;color:#2563eb}
|
||
.src-type-text{background:#dcfce7;color:#16a34a}
|
||
.src-type-audio,.src-type-mp3{background:#fce7f3;color:#db2777}
|
||
.src-type-video,.src-type-mp4{background:#f3e8ff;color:#9333ea}
|
||
.src-type-other{background:var(--border);color:var(--text2)}
|
||
.src-name{font-size:13px;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.src-loading{display:flex;align-items:center;gap:4px;color:var(--amber);font-size:12px;flex-shrink:0}
|
||
|
||
/* ── Cron Jobs ── */
|
||
.job-list{display:flex;flex-direction:column;gap:12px}
|
||
.job-card{
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:var(--radius);padding:16px;
|
||
display:grid;grid-template-columns:1fr auto;gap:12px;
|
||
box-shadow:var(--shadow);
|
||
}
|
||
.job-notebook{
|
||
font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
|
||
color:var(--primary);margin-bottom:4px;
|
||
}
|
||
.job-label{font-size:13px;font-weight:600;color:var(--text);margin-bottom:2px}
|
||
.job-message{
|
||
font-size:13px;color:var(--text2);
|
||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
|
||
margin-bottom:8px;
|
||
}
|
||
.job-meta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||
.job-meta-item{
|
||
display:flex;align-items:center;gap:4px;
|
||
font-size:12px;color:var(--text3);
|
||
}
|
||
.job-meta-item svg{width:13px;height:13px;stroke:currentColor;flex-shrink:0}
|
||
.job-actions{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||
.job-last-run{font-size:11px;color:var(--text3);text-align:right}
|
||
.job-last-status{font-size:11px;font-weight:600}
|
||
.job-last-status.ok{color:var(--green)}
|
||
.job-last-status.err{color:var(--red)}
|
||
.job-btns{display:flex;gap:6px;align-items:center}
|
||
|
||
/* ── Toggle switch ── */
|
||
.toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0}
|
||
.toggle input{opacity:0;width:0;height:0}
|
||
.toggle-track{
|
||
position:absolute;inset:0;border-radius:20px;
|
||
background:var(--border2);cursor:pointer;
|
||
transition:background .2s;
|
||
}
|
||
.toggle input:checked + .toggle-track{background:var(--primary)}
|
||
.toggle-track::after{
|
||
content:'';position:absolute;
|
||
width:14px;height:14px;border-radius:50%;
|
||
background:#fff;top:3px;left:3px;
|
||
transition:transform .2s;
|
||
box-shadow:0 1px 3px rgba(0,0,0,.2);
|
||
}
|
||
.toggle input:checked + .toggle-track::after{transform:translateX(16px)}
|
||
|
||
/* ── Buttons ── */
|
||
.btn{
|
||
display:inline-flex;align-items:center;gap:6px;
|
||
padding:6px 14px;border-radius:var(--radius-sm);border:none;
|
||
font-size:13px;font-weight:500;cursor:pointer;
|
||
transition:background var(--transition),opacity var(--transition);
|
||
white-space:nowrap;text-decoration:none;
|
||
}
|
||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||
.btn-primary{background:var(--primary);color:#fff}
|
||
.btn-primary:hover:not(:disabled){background:var(--primary-hover)}
|
||
.btn-ghost{background:none;color:var(--text2);border:1px solid var(--border)}
|
||
.btn-ghost:hover:not(:disabled){background:var(--surface2);border-color:var(--border2)}
|
||
.btn-danger{background:var(--red-bg);color:var(--red);border:1px solid #fca5a5}
|
||
.btn-danger:hover:not(:disabled){background:#fecaca}
|
||
.btn-sm{padding:4px 10px;font-size:12px}
|
||
.btn-icon{
|
||
width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;
|
||
background:none;border:1px solid var(--border);border-radius:var(--radius-sm);
|
||
cursor:pointer;color:var(--text2);transition:all var(--transition);
|
||
}
|
||
.btn-icon:hover{background:var(--surface2);border-color:var(--border2);color:var(--text)}
|
||
.btn-icon.danger:hover{background:var(--red-bg);border-color:#fca5a5;color:var(--red)}
|
||
.btn-icon svg{width:14px;height:14px;stroke:currentColor}
|
||
|
||
/* ── History table ── */
|
||
.history-filters{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--border)}
|
||
.filter-select{
|
||
padding:5px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);
|
||
background:var(--surface);font-size:12px;color:var(--text);cursor:pointer;
|
||
appearance:none;padding-right:24px;
|
||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239399b0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||
background-repeat:no-repeat;background-position:right 6px center;
|
||
}
|
||
.history-table{width:100%;border-collapse:collapse}
|
||
.history-table th{
|
||
text-align:left;padding:10px 14px;
|
||
font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
|
||
color:var(--text3);border-bottom:1px solid var(--border);
|
||
background:var(--surface2);
|
||
}
|
||
.history-table td{
|
||
padding:11px 14px;font-size:13px;border-bottom:1px solid var(--border);
|
||
vertical-align:top;
|
||
}
|
||
.history-table tr:last-child td{border-bottom:none}
|
||
.history-table tr.expandable{cursor:pointer}
|
||
.history-table tr.expandable:hover td{background:var(--surface2)}
|
||
.history-table tr.expanded td{background:var(--surface2)}
|
||
.history-answer{
|
||
grid-column:1/-1;padding:12px 14px;
|
||
background:var(--surface2);border-top:1px solid var(--border);
|
||
}
|
||
.history-answer-text{
|
||
font-size:13px;color:var(--text2);white-space:pre-wrap;
|
||
max-height:300px;overflow-y:auto;
|
||
padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);
|
||
line-height:1.6;
|
||
}
|
||
.status-badge{
|
||
display:inline-flex;align-items:center;gap:4px;
|
||
font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;
|
||
}
|
||
.status-ok{background:var(--green-bg);color:var(--green)}
|
||
.status-err{background:var(--red-bg);color:var(--red)}
|
||
.webhook-ok{color:var(--green)}
|
||
.webhook-err{color:var(--red)}
|
||
.webhook-none{color:var(--text3)}
|
||
|
||
/* ── Modal ── */
|
||
.modal-overlay{
|
||
position:fixed;inset:0;background:rgba(0,0,0,.4);
|
||
z-index:200;display:none;align-items:center;justify-content:center;
|
||
padding:20px;backdrop-filter:blur(2px);
|
||
}
|
||
.modal-overlay.open{display:flex}
|
||
.modal{
|
||
background:var(--surface);border-radius:var(--radius-lg);
|
||
width:100%;max-width:520px;max-height:90vh;overflow-y:auto;
|
||
box-shadow:var(--shadow-lg);
|
||
}
|
||
.modal-header{
|
||
padding:20px 24px 16px;display:flex;align-items:center;justify-content:space-between;
|
||
border-bottom:1px solid var(--border);position:sticky;top:0;
|
||
background:var(--surface);z-index:1;
|
||
}
|
||
.modal-title{font-size:16px;font-weight:700}
|
||
.modal-close{
|
||
width:28px;height:28px;border-radius:50%;border:none;
|
||
background:var(--surface2);cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||
color:var(--text2);transition:background var(--transition);
|
||
}
|
||
.modal-close:hover{background:var(--border)}
|
||
.modal-close svg{width:14px;height:14px;stroke:currentColor}
|
||
.modal-body{padding:20px 24px}
|
||
.modal-footer{
|
||
padding:16px 24px;border-top:1px solid var(--border);
|
||
display:flex;justify-content:flex-end;gap:8px;
|
||
position:sticky;bottom:0;background:var(--surface);
|
||
}
|
||
|
||
/* ── Form elements ── */
|
||
.form-group{margin-bottom:16px}
|
||
.form-label{display:block;font-size:12px;font-weight:600;color:var(--text2);margin-bottom:6px}
|
||
.form-label .optional{font-weight:400;color:var(--text3)}
|
||
.form-input,.form-select,.form-textarea{
|
||
width:100%;padding:8px 12px;
|
||
border:1px solid var(--border2);border-radius:var(--radius-sm);
|
||
background:var(--surface);font-size:13px;color:var(--text);
|
||
font-family:var(--font);
|
||
transition:border-color var(--transition),box-shadow var(--transition);
|
||
outline:none;
|
||
}
|
||
.form-input:focus,.form-select:focus,.form-textarea:focus{
|
||
border-color:var(--primary);
|
||
box-shadow:0 0 0 3px rgba(90,87,232,.1);
|
||
}
|
||
.form-textarea{resize:vertical;min-height:80px;line-height:1.5}
|
||
.form-hint{font-size:11px;color:var(--text3);margin-top:4px}
|
||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||
.form-select{appearance:none;padding-right:28px;cursor:pointer;
|
||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239399b0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||
background-repeat:no-repeat;background-position:right 8px center;
|
||
}
|
||
.days-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px}
|
||
.day-check{display:none}
|
||
.day-label{
|
||
display:flex;align-items:center;justify-content:center;
|
||
height:32px;border-radius:var(--radius-sm);border:1px solid var(--border2);
|
||
font-size:12px;font-weight:600;cursor:pointer;
|
||
color:var(--text2);transition:all var(--transition);user-select:none;
|
||
}
|
||
.day-check:checked + .day-label{background:var(--primary);border-color:var(--primary);color:#fff}
|
||
.days-row{display:flex;gap:6px;flex-wrap:wrap}
|
||
.days-row .day-label{min-width:36px;height:28px;border-radius:var(--radius-sm);font-size:11px}
|
||
|
||
/* ── Divider ── */
|
||
.divider{height:1px;background:var(--border);margin:16px 0}
|
||
|
||
/* ── Toast ── */
|
||
.toast{
|
||
position:fixed;bottom:24px;right:24px;z-index:300;
|
||
display:flex;flex-direction:column;gap:8px;
|
||
}
|
||
.toast-item{
|
||
background:var(--text);color:#fff;
|
||
padding:10px 16px;border-radius:var(--radius);
|
||
font-size:13px;box-shadow:var(--shadow-md);
|
||
display:flex;align-items:center;gap:10px;
|
||
animation:slideIn .2s ease;
|
||
min-width:240px;max-width:360px;
|
||
}
|
||
.toast-item.success{background:#166534}
|
||
.toast-item.error{background:#991b1b}
|
||
@keyframes slideIn{from{transform:translateY(10px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
|
||
/* ── Spinner ── */
|
||
.spinner{
|
||
width:16px;height:16px;border:2px solid var(--border);
|
||
border-top-color:var(--primary);border-radius:50%;
|
||
animation:spin .6s linear infinite;display:inline-block;
|
||
}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
||
/* ── Section header ── */
|
||
.section-hd{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
margin-bottom:14px;
|
||
}
|
||
.section-title{font-size:15px;font-weight:700}
|
||
.section-sub{font-size:12px;color:var(--text3);margin-left:6px}
|
||
|
||
/* ── Loading state ── */
|
||
.loading-rows{display:flex;flex-direction:column;gap:8px;padding:16px}
|
||
.skeleton{background:linear-gradient(90deg,var(--border) 25%,var(--surface2) 50%,var(--border) 75%);
|
||
background-size:200%;animation:shimmer 1.2s infinite;border-radius:6px;height:14px;}
|
||
@keyframes shimmer{0%{background-position:200%}100%{background-position:-200%}}
|
||
|
||
/* ── Misc ── */
|
||
.text-muted{color:var(--text3)}
|
||
.flex{display:flex}.items-center{align-items:center}.gap-8{gap:8px}.gap-6{gap:6px}
|
||
.mt-12{margin-top:12px}.mb-4{margin-bottom:4px}
|
||
.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Header -->
|
||
<header class="header">
|
||
<a class="logo" href="/">
|
||
<div class="logo-icon">
|
||
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||
</div>
|
||
<span class="logo-text">NotebookLM</span>
|
||
</a>
|
||
<nav class="nav">
|
||
<button class="nav-btn active" data-tab="notebooks" onclick="switchTab('notebooks')">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||
Notebooks
|
||
<span class="badge" id="badge-notebooks">—</span>
|
||
</button>
|
||
<button class="nav-btn" data-tab="cron" onclick="switchTab('cron')">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
Lịch chạy
|
||
<span class="badge" id="badge-cron">—</span>
|
||
</button>
|
||
<button class="nav-btn" data-tab="history" onclick="switchTab('history')">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 3h18v18H3z" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||
Lịch sử
|
||
<span class="badge" id="badge-history">—</span>
|
||
</button>
|
||
</nav>
|
||
<div class="header-right">
|
||
<span class="auth-dot" id="auth-dot"></span>
|
||
<span class="auth-label" id="auth-label">Đang kết nối...</span>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Main content -->
|
||
<main class="main">
|
||
|
||
<!-- Tab: Notebooks -->
|
||
<div class="tab-content active" id="tab-notebooks">
|
||
<div class="cols">
|
||
<!-- Left: Notebook list -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Notebooks</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="loadNotebooks()" title="Làm mới">
|
||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
|
||
Làm mới
|
||
</button>
|
||
</div>
|
||
<ul class="nb-list" id="nb-list">
|
||
<div class="loading-rows">
|
||
<div class="skeleton" style="width:80%"></div>
|
||
<div class="skeleton" style="width:65%"></div>
|
||
<div class="skeleton" style="width:75%"></div>
|
||
</div>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Right: Sources panel -->
|
||
<div id="nb-panel">
|
||
<div class="card">
|
||
<div class="panel-empty">
|
||
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||
<p>Chọn một notebook để xem sources</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: Cron Jobs -->
|
||
<div class="tab-content" id="tab-cron">
|
||
<div class="section-hd">
|
||
<div>
|
||
<span class="section-title">Lịch chạy tự động</span>
|
||
<span class="section-sub" id="cron-count"></span>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="openJobModal()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
Tạo lịch mới
|
||
</button>
|
||
</div>
|
||
<div class="job-list" id="job-list">
|
||
<div class="loading-rows">
|
||
<div class="skeleton"></div><div class="skeleton" style="width:80%"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab: History -->
|
||
<div class="tab-content" id="tab-history">
|
||
<div class="section-hd">
|
||
<div>
|
||
<span class="section-title">Lịch sử chạy</span>
|
||
<span class="section-sub" id="history-count"></span>
|
||
</div>
|
||
<button class="btn btn-ghost btn-sm" onclick="loadHistory()">
|
||
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
|
||
Làm mới
|
||
</button>
|
||
</div>
|
||
<div class="card" style="overflow:hidden">
|
||
<div class="history-filters">
|
||
<select class="filter-select" id="history-nb-filter" onchange="loadHistory()">
|
||
<option value="">Tất cả notebook</option>
|
||
</select>
|
||
<select class="filter-select" id="history-status-filter" onchange="renderHistory()">
|
||
<option value="">Tất cả trạng thái</option>
|
||
<option value="success">Thành công</option>
|
||
<option value="error">Lỗi</option>
|
||
</select>
|
||
</div>
|
||
<div style="overflow-x:auto">
|
||
<table class="history-table" id="history-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:140px">Thời gian</th>
|
||
<th>Notebook</th>
|
||
<th>Câu hỏi</th>
|
||
<th style="width:90px">Trạng thái</th>
|
||
<th style="width:80px">Webhook</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="history-body">
|
||
<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--text3)">Đang tải...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<!-- Modal: Create/Edit Job -->
|
||
<div class="modal-overlay" id="modal" onclick="closeJobModal(event)">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-header">
|
||
<span class="modal-title" id="modal-title">Tạo lịch chạy mới</span>
|
||
<button class="modal-close" onclick="closeJobModal()">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Notebook</label>
|
||
<select class="form-select" id="f-notebook" onchange="onNotebookSelectChange()">
|
||
<option value="">— Chọn notebook —</option>
|
||
</select>
|
||
<div class="form-hint">Chỉ chọn notebook do bạn sở hữu</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Nhãn <span class="optional">(tuỳ chọn)</span></label>
|
||
<input class="form-input" id="f-label" type="text" placeholder="Ví dụ: Tóm tắt buổi sáng">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Câu hỏi / Tin nhắn gửi đến AI</label>
|
||
<textarea class="form-textarea" id="f-message" placeholder="Ví dụ: Tóm tắt các nguồn trong notebook này thành 5 điểm chính" style="min-height:90px"></textarea>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Lịch chạy</label>
|
||
<div class="form-row" style="margin-bottom:10px">
|
||
<div>
|
||
<label class="form-label" style="margin-bottom:4px">Giờ</label>
|
||
<select class="form-select" id="f-hour"></select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label" style="margin-bottom:4px">Phút</label>
|
||
<select class="form-select" id="f-minute"></select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="form-label" style="margin-bottom:6px">Lặp lại</label>
|
||
<select class="form-select" id="f-repeat" onchange="onRepeatChange()">
|
||
<option value="daily">Hàng ngày</option>
|
||
<option value="weekdays">Các ngày trong tuần (T2–T6)</option>
|
||
<option value="weekends">Cuối tuần (T7–CN)</option>
|
||
<option value="custom">Tuỳ chọn ngày...</option>
|
||
</select>
|
||
</div>
|
||
<div id="f-custom-days" style="margin-top:10px;display:none">
|
||
<label class="form-label" style="margin-bottom:6px">Chọn ngày</label>
|
||
<div class="days-grid">
|
||
<div><input class="day-check" type="checkbox" id="d1" value="1"><label class="day-label" for="d1">T2</label></div>
|
||
<div><input class="day-check" type="checkbox" id="d2" value="2"><label class="day-label" for="d2">T3</label></div>
|
||
<div><input class="day-check" type="checkbox" id="d3" value="3"><label class="day-label" for="d3">T4</label></div>
|
||
<div><input class="day-check" type="checkbox" id="d4" value="4"><label class="day-label" for="d4">T5</label></div>
|
||
<div><input class="day-check" type="checkbox" id="d5" value="5"><label class="day-label" for="d5">T6</label></div>
|
||
<div><input class="day-check" type="checkbox" id="d6" value="6"><label class="day-label" for="d6">T7</label></div>
|
||
<div><input class="day-check" type="checkbox" id="d0" value="0"><label class="day-label" for="d0">CN</label></div>
|
||
</div>
|
||
</div>
|
||
<div class="form-hint mt-12" id="f-cron-preview"></div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Webhook URL <span class="optional">(tuỳ chọn)</span></label>
|
||
<input class="form-input" id="f-webhook" type="url" placeholder="https://n8n.example.com/webhook/abc123" oninput="onWebhookUrlChange()">
|
||
<div class="form-hint">Sau mỗi lần chạy, kết quả sẽ được POST đến URL này dưới dạng JSON</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="f-headers-group" style="display:none">
|
||
<label class="form-label">
|
||
Authorization Headers
|
||
<span class="optional">(tuỳ chọn)</span>
|
||
</label>
|
||
<textarea class="form-textarea" id="f-headers" rows="3"
|
||
placeholder='{"Authorization": "Bearer YOUR_TOKEN"}'
|
||
style="font-family:monospace;font-size:12px;min-height:70px"
|
||
oninput="validateHeadersJson()"></textarea>
|
||
<div class="form-hint" id="f-headers-hint">
|
||
Định dạng JSON. Ví dụ: <code style="background:var(--surface2);padding:1px 4px;border-radius:3px">{"Authorization":"Bearer xxx","X-Api-Key":"yyy"}</code>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||
<label class="toggle" style="margin:0">
|
||
<input type="checkbox" id="f-enabled" checked>
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
Kích hoạt ngay sau khi tạo
|
||
</label>
|
||
</div>
|
||
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="closeJobModal()">Huỷ</button>
|
||
<button class="btn btn-primary" id="modal-submit" onclick="submitJob()">
|
||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||
Lưu lịch chạy
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast container -->
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ── State ────────────────────────────────────────────────────────────────────
|
||
const S = {
|
||
notebooks: [],
|
||
selectedNb: null,
|
||
sources: [],
|
||
jobs: [],
|
||
history: [],
|
||
historyAll: [],
|
||
editingJobId: null,
|
||
};
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
const $ = id => document.getElementById(id);
|
||
|
||
async function api(path, opts = {}) {
|
||
const r = await fetch(path, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...opts,
|
||
});
|
||
return r.json();
|
||
}
|
||
|
||
function toast(msg, type = '') {
|
||
const el = document.createElement('div');
|
||
el.className = 'toast-item' + (type ? ' ' + type : '');
|
||
el.textContent = msg;
|
||
$('toast').appendChild(el);
|
||
setTimeout(() => el.remove(), 3500);
|
||
}
|
||
|
||
function fmtDate(str) {
|
||
if (!str) return '—';
|
||
const d = new Date(str);
|
||
return d.toLocaleDateString('vi-VN', { day:'2-digit', month:'2-digit' }) + ' ' +
|
||
d.toLocaleTimeString('vi-VN', { hour:'2-digit', minute:'2-digit' });
|
||
}
|
||
|
||
function srcTypeBadge(type) {
|
||
const t = (type || 'other').toLowerCase();
|
||
const cls = ['pdf','url','text','audio','mp3','video','mp4'].includes(t) ? t : 'other';
|
||
const labels = { pdf:'PDF', url:'URL', text:'TEXT', audio:'Audio', mp3:'MP3', video:'Video', mp4:'MP4', other:'FILE' };
|
||
return `<span class="src-type-badge src-type-${cls}">${labels[cls]||cls.toUpperCase()}</span>`;
|
||
}
|
||
|
||
// ── Auth status ──────────────────────────────────────────────────────────────
|
||
async function checkAuth() {
|
||
try {
|
||
const r = await api('/api/auth/status');
|
||
const dot = $('auth-dot'), lbl = $('auth-label');
|
||
if (r.authenticated) {
|
||
dot.className = 'auth-dot ok';
|
||
lbl.textContent = 'Đã đăng nhập';
|
||
} else {
|
||
dot.className = 'auth-dot';
|
||
lbl.textContent = 'Chưa đăng nhập';
|
||
}
|
||
} catch { $('auth-label').textContent = 'Mất kết nối'; }
|
||
}
|
||
|
||
// ── Tab switching ────────────────────────────────────────────────────────────
|
||
function switchTab(tab) {
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||
document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
|
||
$('tab-' + tab).classList.add('active');
|
||
document.querySelector(`.nav-btn[data-tab="${tab}"]`).classList.add('active');
|
||
if (tab === 'history') loadHistory();
|
||
if (tab === 'cron') loadJobs();
|
||
}
|
||
|
||
// ── Notebooks ────────────────────────────────────────────────────────────────
|
||
async function loadNotebooks() {
|
||
$('nb-list').innerHTML = '<div class="loading-rows"><div class="skeleton" style="width:80%"></div><div class="skeleton" style="width:65%"></div><div class="skeleton" style="width:75%"></div></div>';
|
||
try {
|
||
const r = await api('/api/notebooks');
|
||
if (!r.ok) throw new Error(r.error);
|
||
S.notebooks = r.notebooks || [];
|
||
$('badge-notebooks').textContent = S.notebooks.length;
|
||
renderNotebookList();
|
||
populateNbSelects();
|
||
} catch (e) {
|
||
$('nb-list').innerHTML = `<div class="panel-empty"><p>Lỗi: ${e.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function renderNotebookList() {
|
||
if (!S.notebooks.length) {
|
||
$('nb-list').innerHTML = '<div class="panel-empty"><p>Không có notebook nào</p></div>';
|
||
return;
|
||
}
|
||
$('nb-list').innerHTML = S.notebooks.map(nb => `
|
||
<li class="nb-item${S.selectedNb?.id === nb.id ? ' selected' : ''}"
|
||
onclick="selectNotebook('${nb.id}')">
|
||
<div class="nb-icon">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/>
|
||
</svg>
|
||
</div>
|
||
<div class="nb-info">
|
||
<div class="nb-name" title="${nb.title || 'Không có tiêu đề'}">${nb.title || '(Không có tiêu đề)'}</div>
|
||
</div>
|
||
<span class="nb-src-badge">—</span>
|
||
</li>
|
||
`).join('');
|
||
}
|
||
|
||
async function selectNotebook(id) {
|
||
S.selectedNb = S.notebooks.find(n => n.id === id);
|
||
renderNotebookList();
|
||
renderSourcePanel('loading');
|
||
try {
|
||
const r = await api(`/api/notebooks/${id}/sources?timeout=30`);
|
||
if (!r.ok) throw new Error(r.error);
|
||
S.sources = r.sources || [];
|
||
// Update source badge
|
||
const item = document.querySelector(`.nb-item.selected .nb-src-badge`);
|
||
if (item) item.textContent = r.total || '0';
|
||
renderSourcePanel();
|
||
} catch (e) {
|
||
renderSourcePanel('error', e.message);
|
||
}
|
||
}
|
||
|
||
function renderSourcePanel(state, errMsg) {
|
||
const nb = S.selectedNb;
|
||
if (!nb) return;
|
||
|
||
if (state === 'loading') {
|
||
$('nb-panel').innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">${nb.title || 'Notebook'}</span>
|
||
<span class="card-sub">Đang tải sources...</span>
|
||
</div>
|
||
<div class="loading-rows">
|
||
<div class="skeleton"></div><div class="skeleton" style="width:75%"></div><div class="skeleton" style="width:85%"></div>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
if (state === 'error') {
|
||
$('nb-panel').innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">${nb.title}</span></div>
|
||
<div class="panel-empty"><p>Lỗi tải sources: ${errMsg}</p></div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const srcHtml = S.sources.length
|
||
? `<ul class="src-list">${S.sources.map(s => `
|
||
<li class="src-item">
|
||
${srcTypeBadge(s.type)}
|
||
<span class="src-name" title="${s.title || ''}">${s.title || '(Không có tiêu đề)'}</span>
|
||
${s.loading ? '<span class="src-loading"><span class="spinner" style="width:12px;height:12px"></span> Đang xử lý...</span>' : ''}
|
||
</li>`).join('')}
|
||
</ul>`
|
||
: '<div class="panel-empty"><p>Notebook này chưa có source nào</p></div>';
|
||
|
||
$('nb-panel').innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div>
|
||
<div class="card-title">${nb.title || 'Notebook'}</div>
|
||
<div class="card-sub">${S.sources.length} nguồn tài liệu${S.sources.some(s=>s.loading) ? ' · Có source đang xử lý...' : ''}</div>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="prefillJobForNb('${nb.id}','${(nb.title||'').replace(/'/g,"\\'")}');switchTab('cron')">
|
||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
Tạo lịch chạy
|
||
</button>
|
||
</div>
|
||
${srcHtml}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Cron Jobs ────────────────────────────────────────────────────────────────
|
||
async function loadJobs() {
|
||
$('job-list').innerHTML = '<div class="loading-rows"><div class="skeleton"></div><div class="skeleton" style="width:80%"></div></div>';
|
||
try {
|
||
const r = await api('/api/cron');
|
||
if (!r.ok) throw new Error(r.error);
|
||
S.jobs = r.jobs || [];
|
||
$('badge-cron').textContent = S.jobs.length;
|
||
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
|
||
renderJobList();
|
||
} catch (e) {
|
||
$('job-list').innerHTML = `<div class="panel-empty"><p>Lỗi: ${e.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function renderJobList() {
|
||
if (!S.jobs.length) {
|
||
$('job-list').innerHTML = `
|
||
<div class="card">
|
||
<div class="panel-empty">
|
||
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
<p>Chưa có lịch chạy nào. Tạo mới để bắt đầu.</p>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
$('job-list').innerHTML = S.jobs.map(job => `
|
||
<div class="job-card" id="job-${job.id}">
|
||
<div>
|
||
<div class="job-notebook">${job.notebook_title || job.notebook_id}</div>
|
||
${job.label ? `<div class="job-label">${job.label}</div>` : ''}
|
||
<div class="job-message">${job.message}</div>
|
||
<div class="job-meta">
|
||
<span class="job-meta-item">
|
||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
${job.schedule_display || job.cron_expr}
|
||
</span>
|
||
${job.webhook_url ? `
|
||
<span class="job-meta-item" title="${job.webhook_url}">
|
||
<svg viewBox="0 0 24 24" fill="none"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||
Webhook
|
||
</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="job-actions">
|
||
<label class="toggle" title="${job.enabled ? 'Tắt' : 'Bật'}" onclick="toggleJob(${job.id})">
|
||
<input type="checkbox" ${job.enabled ? 'checked' : ''} onclick="event.preventDefault()">
|
||
<span class="toggle-track"></span>
|
||
</label>
|
||
<div class="job-btns">
|
||
<button class="btn-icon" onclick="runJobNow(${job.id})" title="Chạy ngay">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
</button>
|
||
<button class="btn-icon" onclick="openJobModal(${job.id})" title="Sửa">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
</button>
|
||
<button class="btn-icon danger" onclick="deleteJob(${job.id})" title="Xoá">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function toggleJob(id) {
|
||
try {
|
||
const r = await api(`/api/cron/${id}/toggle`, { method: 'PATCH' });
|
||
if (!r.ok) throw new Error(r.error);
|
||
const idx = S.jobs.findIndex(j => j.id === id);
|
||
if (idx >= 0) S.jobs[idx] = r.job;
|
||
renderJobList();
|
||
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
|
||
toast(r.job.enabled ? 'Đã bật lịch chạy' : 'Đã tắt lịch chạy', r.job.enabled ? 'success' : '');
|
||
} catch (e) { toast('Lỗi: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function runJobNow(id) {
|
||
try {
|
||
const r = await api(`/api/cron/${id}/run`, { method: 'POST' });
|
||
if (!r.ok) throw new Error(r.error);
|
||
toast('Đang chạy... Kết quả sẽ lưu vào Lịch sử', 'success');
|
||
} catch (e) { toast('Lỗi: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function deleteJob(id) {
|
||
if (!confirm('Xoá lịch chạy này?')) return;
|
||
try {
|
||
const r = await api(`/api/cron/${id}`, { method: 'DELETE' });
|
||
if (!r.ok) throw new Error(r.error);
|
||
S.jobs = S.jobs.filter(j => j.id !== id);
|
||
renderJobList();
|
||
$('badge-cron').textContent = S.jobs.length;
|
||
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
|
||
toast('Đã xoá lịch chạy', 'success');
|
||
} catch (e) { toast('Lỗi: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ── History ──────────────────────────────────────────────────────────────────
|
||
async function loadHistory() {
|
||
$('history-body').innerHTML = '<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--text3)">Đang tải...</td></tr>';
|
||
try {
|
||
const nbId = $('history-nb-filter').value;
|
||
const r = await api(`/api/history?limit=200${nbId ? '¬ebook_id=' + nbId : ''}`);
|
||
if (!r.ok) throw new Error(r.error);
|
||
S.historyAll = r.history || [];
|
||
$('badge-history').textContent = S.historyAll.length;
|
||
$('history-count').textContent = `${S.historyAll.length} lần chạy gần nhất`;
|
||
renderHistory();
|
||
} catch (e) {
|
||
$('history-body').innerHTML = `<tr><td colspan="5" style="text-align:center;padding:24px;color:var(--red)">Lỗi: ${e.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderHistory() {
|
||
const statusFilter = $('history-status-filter').value;
|
||
const items = statusFilter
|
||
? S.historyAll.filter(h => h.status === statusFilter)
|
||
: S.historyAll;
|
||
|
||
if (!items.length) {
|
||
$('history-body').innerHTML = '<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--text3)">Chưa có lịch sử nào</td></tr>';
|
||
return;
|
||
}
|
||
|
||
$('history-body').innerHTML = items.map(h => `
|
||
<tr class="expandable" onclick="toggleHistoryRow(${h.id}, this)">
|
||
<td style="color:var(--text2);white-space:nowrap;font-size:12px">${fmtDate(h.ran_at)}</td>
|
||
<td><span class="truncate" style="max-width:160px;display:block" title="${h.notebook_title||h.notebook_id}">${h.notebook_title||h.notebook_id}</span></td>
|
||
<td><span class="truncate" style="max-width:240px;display:block;color:var(--text2)" title="${h.message}">${h.message}</span></td>
|
||
<td>
|
||
<span class="status-badge ${h.status==='success'?'status-ok':'status-err'}">
|
||
${h.status==='success'?'✓ OK':'✗ Lỗi'}
|
||
</span>
|
||
</td>
|
||
<td style="text-align:center">
|
||
${h.webhook_url
|
||
? (h.webhook_ok ? '<span class="webhook-ok" title="Webhook thành công">✓</span>' : '<span class="webhook-err" title="Webhook thất bại">✗</span>')
|
||
: '<span class="webhook-none">—</span>'}
|
||
</td>
|
||
</tr>
|
||
<tr id="hist-detail-${h.id}" style="display:none">
|
||
<td colspan="5" style="padding:0;background:var(--surface2)">
|
||
<div style="padding:12px 14px">
|
||
${h.error ? `<div style="color:var(--red);font-size:12px;margin-bottom:8px">Lỗi: ${h.error}</div>` : ''}
|
||
${h.answer ? `
|
||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:6px">Câu trả lời</div>
|
||
<div class="history-answer-text">${h.answer}</div>` : '<div style="color:var(--text3);font-size:12px">Không có câu trả lời</div>'}
|
||
${h.webhook_url ? `<div style="margin-top:8px;font-size:11px;color:var(--text3)">Webhook: ${h.webhook_url} · HTTP ${h.webhook_status||'—'}</div>` : ''}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function toggleHistoryRow(id, tr) {
|
||
const detail = $('hist-detail-' + id);
|
||
if (!detail) return;
|
||
const isOpen = detail.style.display !== 'none';
|
||
detail.style.display = isOpen ? 'none' : 'table-row';
|
||
tr.classList.toggle('expanded', !isOpen);
|
||
}
|
||
|
||
// ── Modal: Create/Edit Job ────────────────────────────────────────────────────
|
||
function buildHourOptions() {
|
||
const sel = $('f-hour');
|
||
sel.innerHTML = Array.from({length:24}, (_,i) =>
|
||
`<option value="${i}">${String(i).padStart(2,'0')}</option>`
|
||
).join('');
|
||
sel.value = 8;
|
||
sel.addEventListener('change', updateCronPreview);
|
||
}
|
||
|
||
function buildMinuteOptions() {
|
||
const sel = $('f-minute');
|
||
sel.innerHTML = [0,5,10,15,20,25,30,35,40,45,50,55].map(m =>
|
||
`<option value="${m}">${String(m).padStart(2,'0')}</option>`
|
||
).join('');
|
||
sel.value = 0;
|
||
sel.addEventListener('change', updateCronPreview);
|
||
}
|
||
|
||
function populateNbSelects() {
|
||
const opts = S.notebooks.map(n => `<option value="${n.id}" data-title="${n.title||''}">${n.title||n.id}</option>`).join('');
|
||
$('f-notebook').innerHTML = '<option value="">— Chọn notebook —</option>' + opts;
|
||
|
||
// History filter
|
||
const histSel = $('history-nb-filter');
|
||
const current = histSel.value;
|
||
histSel.innerHTML = '<option value="">Tất cả notebook</option>' + opts;
|
||
histSel.value = current;
|
||
}
|
||
|
||
function onNotebookSelectChange() { updateCronPreview(); }
|
||
|
||
function onWebhookUrlChange() {
|
||
const hasUrl = $('f-webhook').value.trim().length > 0;
|
||
$('f-headers-group').style.display = hasUrl ? 'block' : 'none';
|
||
if (!hasUrl) $('f-headers').value = '';
|
||
}
|
||
|
||
function validateHeadersJson() {
|
||
const val = $('f-headers').value.trim();
|
||
const hint = $('f-headers-hint');
|
||
if (!val) { hint.style.color = ''; return true; }
|
||
try {
|
||
const parsed = JSON.parse(val);
|
||
if (typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Phải là object');
|
||
hint.style.color = 'var(--green)';
|
||
hint.textContent = `✓ Hợp lệ — ${Object.keys(parsed).length} header(s): ${Object.keys(parsed).join(', ')}`;
|
||
return true;
|
||
} catch (e) {
|
||
hint.style.color = 'var(--red)';
|
||
hint.textContent = `✗ JSON không hợp lệ: ${e.message}`;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function onRepeatChange() {
|
||
const val = $('f-repeat').value;
|
||
$('f-custom-days').style.display = val === 'custom' ? 'block' : 'none';
|
||
// Uncheck all when switching away from custom
|
||
if (val !== 'custom') {
|
||
document.querySelectorAll('.day-check').forEach(c => c.checked = false);
|
||
}
|
||
updateCronPreview();
|
||
}
|
||
|
||
function buildCronExpr() {
|
||
const h = $('f-hour').value;
|
||
const m = $('f-minute').value;
|
||
const rep = $('f-repeat').value;
|
||
if (rep === 'daily') return { expr: `${m} ${h} * * *`, display: `${h.padStart?h:String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} hàng ngày` };
|
||
if (rep === 'weekdays') return { expr: `${m} ${h} * * 1-5`, display: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} T2–T6` };
|
||
if (rep === 'weekends') return { expr: `${m} ${h} * * 0,6`, display: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} T7–CN` };
|
||
// custom
|
||
const checked = [...document.querySelectorAll('.day-check:checked')].map(c => c.value);
|
||
if (!checked.length) return { expr: '', display: '' };
|
||
const dayNames = {0:'CN',1:'T2',2:'T3',3:'T4',4:'T5',5:'T6',6:'T7'};
|
||
const daysLabel = checked.sort().map(d => dayNames[d]).join(', ');
|
||
return { expr: `${m} ${h} * * ${checked.sort().join(',')}`, display: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} – ${daysLabel}` };
|
||
}
|
||
|
||
function updateCronPreview() {
|
||
const { expr, display } = buildCronExpr();
|
||
$('f-cron-preview').textContent = expr
|
||
? `Cron: ${expr} → Chạy lúc ${display}`
|
||
: 'Vui lòng chọn ít nhất 1 ngày';
|
||
}
|
||
|
||
function parseCronToForm(expr) {
|
||
// Parse simple patterns: "M H * * DAYS"
|
||
const parts = (expr || '').trim().split(/\s+/);
|
||
if (parts.length !== 5) return;
|
||
const [m, h, , , days] = parts;
|
||
$('f-minute').value = m;
|
||
$('f-hour').value = h;
|
||
if (days === '*') { $('f-repeat').value = 'daily'; }
|
||
else if (days === '1-5') { $('f-repeat').value = 'weekdays'; }
|
||
else if (days === '0,6') { $('f-repeat').value = 'weekends'; }
|
||
else {
|
||
$('f-repeat').value = 'custom';
|
||
$('f-custom-days').style.display = 'block';
|
||
days.split(',').forEach(d => {
|
||
const cb = document.getElementById('d' + d);
|
||
if (cb) cb.checked = true;
|
||
});
|
||
}
|
||
onRepeatChange();
|
||
}
|
||
|
||
function openJobModal(jobId) {
|
||
S.editingJobId = jobId || null;
|
||
$('modal-title').textContent = jobId ? 'Chỉnh sửa lịch chạy' : 'Tạo lịch chạy mới';
|
||
|
||
// Reset form
|
||
$('f-notebook').value = '';
|
||
$('f-label').value = '';
|
||
$('f-message').value = '';
|
||
$('f-webhook').value = '';
|
||
$('f-headers').value = '';
|
||
$('f-headers-group').style.display = 'none';
|
||
$('f-enabled').checked = true;
|
||
$('f-repeat').value = 'daily';
|
||
$('f-hour').value = 8;
|
||
$('f-minute').value = 0;
|
||
$('f-custom-days').style.display = 'none';
|
||
document.querySelectorAll('.day-check').forEach(c => c.checked = false);
|
||
// Reset headers hint
|
||
const hint = $('f-headers-hint');
|
||
hint.style.color = '';
|
||
hint.innerHTML = 'Định dạng JSON. Ví dụ: <code style="background:var(--surface2);padding:1px 4px;border-radius:3px">{"Authorization":"Bearer xxx","X-Api-Key":"yyy"}</code>';
|
||
|
||
if (jobId) {
|
||
const job = S.jobs.find(j => j.id === jobId);
|
||
if (job) {
|
||
$('f-notebook').value = job.notebook_id;
|
||
$('f-label').value = job.label || '';
|
||
$('f-message').value = job.message;
|
||
$('f-webhook').value = job.webhook_url || '';
|
||
$('f-enabled').checked = !!job.enabled;
|
||
// Load headers
|
||
try {
|
||
const h = JSON.parse(job.webhook_headers || '{}');
|
||
if (Object.keys(h).length > 0) {
|
||
$('f-headers').value = JSON.stringify(h, null, 2);
|
||
$('f-headers-group').style.display = 'block';
|
||
validateHeadersJson();
|
||
}
|
||
} catch {}
|
||
parseCronToForm(job.cron_expr);
|
||
}
|
||
}
|
||
|
||
updateCronPreview();
|
||
$('modal').classList.add('open');
|
||
setTimeout(() => $('f-message').focus(), 100);
|
||
}
|
||
|
||
function prefillJobForNb(nbId, nbTitle) {
|
||
openJobModal();
|
||
$('f-notebook').value = nbId;
|
||
}
|
||
|
||
function closeJobModal(e) {
|
||
if (e && e.target !== $('modal')) return;
|
||
$('modal').classList.remove('open');
|
||
S.editingJobId = null;
|
||
}
|
||
|
||
async function submitJob() {
|
||
const nbSel = $('f-notebook');
|
||
const notebook_id = nbSel.value;
|
||
const notebook_title = nbSel.selectedOptions[0]?.dataset.title || '';
|
||
const label = $('f-label').value.trim();
|
||
const message = $('f-message').value.trim();
|
||
const webhook_url = $('f-webhook').value.trim() || null;
|
||
const enabled = $('f-enabled').checked;
|
||
const { expr: cron_expr, display: schedule_display } = buildCronExpr();
|
||
|
||
// Parse headers
|
||
let webhook_headers = '{}';
|
||
const rawHeaders = $('f-headers').value.trim();
|
||
if (rawHeaders) {
|
||
if (!validateHeadersJson()) return toast('Headers JSON không hợp lệ', 'error');
|
||
webhook_headers = rawHeaders;
|
||
}
|
||
|
||
if (!notebook_id) return toast('Vui lòng chọn notebook', 'error');
|
||
if (!message) return toast('Vui lòng nhập câu hỏi', 'error');
|
||
if (!cron_expr) return toast('Vui lòng chọn ít nhất 1 ngày chạy', 'error');
|
||
|
||
const btn = $('modal-submit');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Đang lưu...';
|
||
|
||
try {
|
||
const body = { notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers, enabled };
|
||
const isEdit = !!S.editingJobId;
|
||
const r = await api(isEdit ? `/api/cron/${S.editingJobId}` : '/api/cron', {
|
||
method: isEdit ? 'PUT' : 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!r.ok) throw new Error(r.error);
|
||
|
||
if (isEdit) {
|
||
const idx = S.jobs.findIndex(j => j.id === S.editingJobId);
|
||
if (idx >= 0) S.jobs[idx] = r.job;
|
||
} else {
|
||
S.jobs.unshift(r.job);
|
||
}
|
||
|
||
renderJobList();
|
||
$('badge-cron').textContent = S.jobs.length;
|
||
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
|
||
$('modal').classList.remove('open');
|
||
toast(isEdit ? 'Đã cập nhật lịch chạy' : 'Đã tạo lịch chạy mới', 'success');
|
||
} catch (e) {
|
||
toast('Lỗi: ' + e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Lưu lịch chạy';
|
||
}
|
||
}
|
||
|
||
// Keyboard shortcut: Escape to close modal
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closeJobModal();
|
||
});
|
||
|
||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||
buildHourOptions();
|
||
buildMinuteOptions();
|
||
checkAuth();
|
||
loadNotebooks();
|
||
loadJobs();
|
||
|
||
// Auto-refresh auth every 60s
|
||
setInterval(checkAuth, 60_000);
|
||
</script>
|
||
</body>
|
||
</html>
|