Skip to content

Commit fd1faef

Browse files
committed
Checkpoint admin dashboard browser integration on main
Preserve all current authorized source changes; qualification runs in GitHub Actions.
1 parent d8dcf22 commit fd1faef

11 files changed

Lines changed: 644 additions & 76 deletions

File tree

Lines changed: 154 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,161 @@
1-
import {Api,Config,Dom,Id,Text,View} from './constants.js';
2-
import {el,status,write} from './dom.js';
3-
import {hasSelectedResource,initializeBrowsing,loadData,loadResources,resetBrowsing,viewChanged} from './browsing.js';
4-
import {clearObservations,physicalFiles,renderSnapshot,selectView} from './rendering.js';
5-
import {resetMetrics} from './metrics.js';
1+
import {
2+
Api,
3+
Config,
4+
Dom,
5+
Id,
6+
Text,
7+
View
8+
} from './constants.js';
9+
import {
10+
el,
11+
status,
12+
write
13+
} from './dom.js';
14+
import {
15+
hasSelectedResource,
16+
initializeBrowsing,
17+
loadData,
18+
loadResources,
19+
resetBrowsing,
20+
viewChanged
21+
} from './browsing.js';
22+
import {
23+
clearObservations,
24+
physicalFiles,
25+
renderSnapshot,
26+
selectView
27+
} from './rendering.js';
28+
import {
29+
resetMetrics
30+
} from './metrics.js';
631
let credential=Text.empty;
732
let epoch=Config.zero;
833
let controller=null;
934
let inFlight=false;
1035
let poll=null;
1136
let view=View.overview;
12-
function connection(active){document.body.classList.toggle(Dom.connected,active);write(Id.state,active?Text.connected:Text.disconnected);el(Id.disconnect).disabled=!active;el(Id.refresh).disabled=!active;}
13-
function stopPolling(){if(poll!==null)clearTimeout(poll);poll=null;}
14-
function schedule(){stopPolling();if(credential&&!document.hidden)poll=setTimeout(refresh,Config.pollMs);}
15-
function cancelPending(){epoch+=Config.one;controller?.abort();controller=null;inFlight=false;stopPolling();}
16-
function disconnect(message=Text.disconnectedHint){cancelPending();credential=Text.empty;el(Id.key).value=Text.empty;el(Id.tenant).value=Text.defaultTenant;el(Id.database).value=Text.defaultDatabase;el(Id.partition).value=Text.defaultPartition;connection(false);clearObservations();resetBrowsing();status(message,message===Text.unauthorized);}
17-
async function request(path,body=null){if(!credential||inFlight)return null;inFlight=true;const started=epoch;const current=new AbortController();controller=current;stopPolling();try{const headers={[Api.authorization]:Api.bearer+credential};if(body!==null)headers[Api.contentType]=Api.json;const response=await fetch(path,{method:body===null?Api.get:Api.post,headers,body:body===null?undefined:JSON.stringify(body),credentials:Api.sameOrigin,cache:Api.noStore,signal:current.signal});if(started!==epoch)return null;if(response.status===Config.unauthenticated||response.status===Config.forbidden){disconnect(Text.unauthorized);return null;}if(!response.ok)throw new Error(Text.httpError+response.status+Text.closeParen);const result=await response.json();return started===epoch?result:null;}catch(error){if(started!==epoch||error.name===Api.abort)return null;resetMetrics();write(Id.state,Text.observationUnavailable);el(Id.refresh).disabled=false;status(Text.failed,true);return null;}finally{if(started===epoch){inFlight=false;controller=null;schedule();}}}
18-
async function refresh(){if(!credential||inFlight||document.hidden)return;status(Text.loading);const result=await request(Api.snapshot);if(!result)return;renderSnapshot(result);connection(true);status(Text.ready);if(view===View.files&&!hasSelectedResource())physicalFiles();}
19-
async function connect(event){event.preventDefault();const key=el(Id.key).value.trim();if(!key)return;disconnect();credential=key;el(Id.key).value=Text.empty;write(Id.state,Text.connecting);el(Id.disconnect).disabled=false;await refresh();}
20-
async function navigate(next){cancelPending();view=next;selectView(view);viewChanged(view);schedule();if(credential&&view!==View.overview&&view!==View.cluster)await loadResources();}
21-
function wireBrowsing(){el(Id.scope).addEventListener(Dom.submit,event=>{event.preventDefault();loadResources();});el(Id.resourcesFirst).addEventListener(Dom.click,()=>loadResources());el(Id.resourcesNext).addEventListener(Dom.click,()=>loadResources(true));el(Id.dataFirst).addEventListener(Dom.click,()=>loadData());el(Id.dataNext).addEventListener(Dom.click,()=>loadData(true));}
22-
function start(){initializeBrowsing(request,cancelPending);el(Id.form).addEventListener(Dom.submit,connect);el(Id.disconnect).addEventListener(Dom.click,()=>disconnect());el(Id.refresh).addEventListener(Dom.click,refresh);document.querySelectorAll(Dom.nav).forEach(button=>button.addEventListener(Dom.click,()=>navigate(button.dataset.view)));el(Id.closeDetails).addEventListener(Dom.click,()=>el(Id.dialog).close());document.addEventListener(Dom.visibility,()=>{if(document.hidden)stopPolling();else refresh();});wireBrowsing();}
37+
function connection(active){
38+
document.body.classList.toggle(Dom.connected,active);
39+
write(Id.state,active?Text.connected:Text.disconnected);
40+
el(Id.disconnect).disabled=!active;
41+
el(Id.refresh).disabled=!active;
42+
}
43+
function stopPolling(){
44+
if(poll!==null)clearTimeout(poll);
45+
poll=null;
46+
}
47+
function schedule(){
48+
stopPolling();
49+
if(credential&&!document.hidden)poll=setTimeout(refresh,Config.pollMs);
50+
}
51+
function cancelPending(){
52+
epoch+=Config.one;
53+
controller?.abort();
54+
controller=null;
55+
inFlight=false;
56+
stopPolling();
57+
}
58+
function disconnect(message=Text.disconnectedHint,resetScope=true){
59+
cancelPending();
60+
credential=Text.empty;
61+
el(Id.key).value=Text.empty;
62+
if(resetScope){
63+
el(Id.tenant).value=Text.defaultTenant;
64+
el(Id.database).value=Text.defaultDatabase;
65+
el(Id.partition).value=Text.defaultPartition;
66+
}connection(false);
67+
clearObservations();
68+
resetBrowsing();
69+
status(message,message===Text.unauthorized);
70+
}
71+
async function request(path,body=null){
72+
if(!credential||inFlight)return null;
73+
inFlight=true;
74+
const started=epoch;
75+
const current=new AbortController();
76+
controller=current;
77+
stopPolling();
78+
try{
79+
const headers={
80+
[Api.authorization]:Api.bearer+credential
81+
};
82+
if(body!==null)headers[Api.contentType]=Api.json;
83+
const response=await fetch(path,{
84+
method:body===null?Api.get:Api.post,headers,body:body===null?undefined:JSON.stringify(body),credentials:Api.sameOrigin,cache:Api.noStore,signal:current.signal
85+
});
86+
if(started!==epoch)return null;
87+
if(response.status===Config.unauthenticated||response.status===Config.forbidden){
88+
disconnect(Text.unauthorized);
89+
return null;
90+
}if(!response.ok)throw new Error(Text.httpError+response.status+Text.closeParen);
91+
const result=await response.json();
92+
return started===epoch?result:null;
93+
}catch(error){
94+
if(started!==epoch||error.name===Api.abort)return null;
95+
resetMetrics();
96+
write(Id.state,Text.observationUnavailable);
97+
write(Id.captured,Text.stale);
98+
el(Id.refresh).disabled=false;
99+
status(Text.failed,true);
100+
return null;
101+
}finally{
102+
if(started===epoch){
103+
inFlight=false;
104+
controller=null;
105+
schedule();
106+
}
107+
}
108+
}
109+
async function refresh(){
110+
if(!credential||inFlight||document.hidden)return;
111+
status(Text.loading);
112+
const result=await request(Api.snapshot);
113+
if(!result)return;
114+
renderSnapshot(result);
115+
connection(true);
116+
status(Text.ready);
117+
if(view===View.files&&!hasSelectedResource())physicalFiles();
118+
}
119+
async function connect(event){
120+
event.preventDefault();
121+
const key=el(Id.key).value.trim();
122+
if(!key)return;
123+
disconnect(Text.disconnectedHint,false);
124+
credential=key;
125+
el(Id.key).value=Text.empty;
126+
write(Id.state,Text.connecting);
127+
el(Id.disconnect).disabled=false;
128+
await refresh();
129+
}
130+
async function navigate(next){
131+
cancelPending();
132+
view=next;
133+
selectView(view);
134+
viewChanged(view);
135+
schedule();
136+
if(credential&&view!==View.overview&&view!==View.cluster)await loadResources();
137+
}
138+
function wireBrowsing(){
139+
el(Id.scope).addEventListener(Dom.submit,event=>{
140+
event.preventDefault();
141+
loadResources();
142+
});
143+
el(Id.resourcesFirst).addEventListener(Dom.click,()=>loadResources());
144+
el(Id.resourcesNext).addEventListener(Dom.click,()=>loadResources(true));
145+
el(Id.dataFirst).addEventListener(Dom.click,()=>loadData());
146+
el(Id.dataNext).addEventListener(Dom.click,()=>loadData(true));
147+
}
148+
function start(){
149+
initializeBrowsing(request,cancelPending);
150+
el(Id.form).addEventListener(Dom.submit,connect);
151+
el(Id.disconnect).addEventListener(Dom.click,()=>disconnect());
152+
el(Id.refresh).addEventListener(Dom.click,refresh);
153+
document.querySelectorAll(Dom.nav).forEach(button=>button.addEventListener(Dom.click,()=>navigate(button.dataset.view)));
154+
el(Id.closeDetails).addEventListener(Dom.click,()=>el(Id.dialog).close());
155+
document.addEventListener(Dom.visibility,()=>{
156+
if(document.hidden)stopPolling();
157+
else refresh();
158+
});
159+
wireBrowsing();
160+
}
23161
start();
Lines changed: 167 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,174 @@
1-
import {Api,Config,Dom,Headers,Id,Kind,Text,View} from './constants.js';
2-
import {bytes,date,detailButton,el,make,resetTable,status,table,write} from './dom.js';
3-
import {physicalFiles} from './rendering.js';
1+
import {
2+
Api,
3+
Config,
4+
Dom,
5+
Headers,
6+
Id,
7+
Kind,
8+
Text,
9+
View
10+
} from './constants.js';
11+
import {
12+
bytes,
13+
date,
14+
detailButton,
15+
el,
16+
make,
17+
resetTable,
18+
status,
19+
table,
20+
write
21+
} from './dom.js';
22+
import {
23+
physicalFiles
24+
} from './rendering.js';
425
let resource=null;
526
let resourceAfter=null;
627
let dataAfter=null;
728
let activeView=View.overview;
829
let call=null;
930
let cancel=null;
1031
const kindName=value=>typeof value===typeof Config.zero?Kind.names[value]:value;
11-
export function initializeBrowsing(request,cancelPending){call=request;cancel=cancelPending;}
12-
export function hasSelectedResource(){return resource!==null;}
13-
export function viewChanged(view){activeView=view;resetBrowsing();if(view===View.files)physicalFiles();}
14-
export function resetBrowsing(){resource=null;resourceAfter=null;dataAfter=null;el(Id.resourceList).replaceChildren(make(Dom.p,Text.loadResources));write(Id.resourceCount,Text.dash);el(Id.resourcesFirst).disabled=true;el(Id.resourcesNext).disabled=true;resetTable();}
15-
export function scope(){return {tenantId:el(Id.tenant).value.trim(),databaseId:el(Id.database).value.trim()};}
16-
function partition(){return {...scope(),transactionDomainId:resource.transactionDomainId,partitionKey:el(Id.partition).value.trim()};}
17-
function expectedKind(){if(activeView===View.queues)return Kind.queue;if(activeView===View.files)return Kind.blob;return Kind.collection;}
18-
function validScope(){const value=scope();if(!value.tenantId||!value.databaseId||!el(Id.partition).value.trim()){status(Text.scopeRequired,true);return false;}return true;}
19-
export async function loadResources(next=false){if(!validScope())return;cancel();const afterName=next?resourceAfter:null;const result=await call(Api.resources,{...scope(),afterName,limit:Config.pageSize});if(!result)return;resourceAfter=result.nextAfterName;resource=null;dataAfter=null;resetTable();if(activeView===View.files)physicalFiles();const items=result.items.filter(item=>kindName(item.kind)===expectedKind());renderResources(items);el(Id.resourcesNext).disabled=!resourceAfter;el(Id.resourcesFirst).disabled=afterName===null;}
20-
function renderResources(items){const list=el(Id.resourceList);list.replaceChildren();write(Id.resourceCount,`${items.length}${Text.resourceSuffix}`);if(!items.length){const empty=make(Dom.p,Text.noResources);empty.className=Dom.emptyClass;list.append(empty);return;}items.forEach(item=>{const button=make(Dom.button,item.name);button.type=Dom.buttonType;button.append(make(Dom.small,`${kindName(item.kind)}${Text.dot}${item.transactionDomainId}`));button.addEventListener(Dom.click,()=>chooseResource(item,button));list.append(button);});}
21-
async function chooseResource(item,button){resource=item;dataAfter=null;el(Id.resourceList).querySelectorAll(Dom.button).forEach(node=>node.removeAttribute(Dom.pressed));button.setAttribute(Dom.pressed,Dom.true);await loadData();}
22-
export async function loadData(next=false){if(!resource||!validScope())return;cancel();const selected=resource;const kind=kindName(selected.kind);const cursor=next?dataAfter:null;const result=await fetchData(kind,cursor);if(!result)return;write(Id.dataTitle,selected.name);if(kind===Kind.collection)documents(result);else if(kind===Kind.queue)queue(result);else if(kind===Kind.blob)blobs(result);else status(Text.unsupported,true);el(Id.dataNext).disabled=!dataAfter;el(Id.dataFirst).disabled=cursor===null;}
23-
function fetchData(kind,cursor){const scopePartition=partition();if(kind===Kind.collection)return call(Api.query,{partition:scopePartition,query:{collection:resource.name,alias:null,projection:[],filter:null,order:[],limit:Config.rows,explain:false},parameters:null,allowFullScan:true,cursor,astVersion:Config.astVersion});if(kind===Kind.queue)return call(Api.queue,{lane:{partition:scopePartition,queue:resource.name},afterId:cursor,limit:Config.pageSize});if(kind===Kind.blob)return call(Api.blobs,{partition:scopePartition,resource:resource.name,limit:Config.pageSize,afterId:cursor});return null;}
24-
function documents(page){dataAfter=page.cursor;write(Id.dataDescription,Text.documentHint);table(Headers.documents,page.rows.map(row=>[row.entityId,row.revision,row.json.slice(Config.zero,Config.preview),detailButton(row.json)]));write(Id.dataEmpty,Text.noRows);write(Id.pageDescription,`${Text.cut}${Text.space}${page.cutPosition}`);}
25-
function queue(page){dataAfter=page.nextAfterId;write(Id.dataDescription,`${page.counters.storedMessages}${Text.space}${Text.stored}${Text.dot}${page.counters.inFlightMessages}${Text.space}${Text.inFlight}${Text.dot}${Text.queueHint}`);table(Headers.queues,page.items.map(row=>[row.id,row.state,row.attempts,row.stateVersion,date(row.notBefore),date(row.leaseUntil),date(row.expiresAt)]));write(Id.dataEmpty,Text.noRows);write(Id.pageDescription,`${Text.cut}${Text.space}${page.cutPosition}`);}
26-
function blobs(page){dataAfter=page.nextAfterId;write(Id.dataDescription,Text.blobHint);table(Headers.blobs,page.items.map(row=>[row.blob.id,bytes(row.length),row.revision,row.partCount,date(row.updatedAt)]));write(Id.dataEmpty,Text.noRows);write(Id.pageDescription,Text.page);}
32+
export function initializeBrowsing(request,cancelPending){
33+
call=request;
34+
cancel=cancelPending;
35+
}
36+
export function hasSelectedResource(){
37+
return resource!==null;
38+
}
39+
export function viewChanged(view){
40+
activeView=view;
41+
resetBrowsing();
42+
if(view===View.files)physicalFiles();
43+
}
44+
export function resetBrowsing(){
45+
resource=null;
46+
resourceAfter=null;
47+
dataAfter=null;
48+
el(Id.resourceList).replaceChildren(make(Dom.p,Text.loadResources));
49+
write(Id.resourceCount,Text.dash);
50+
el(Id.resourcesFirst).disabled=true;
51+
el(Id.resourcesNext).disabled=true;
52+
resetTable();
53+
}
54+
export function scope(){
55+
return {
56+
tenantId:el(Id.tenant).value.trim(),
57+
databaseId:el(Id.database).value.trim()
58+
};
59+
}
60+
function partition(){
61+
return {
62+
...scope(),
63+
transactionDomainId:resource.transactionDomainId,
64+
partitionKey:el(Id.partition).value.trim()
65+
};
66+
}
67+
function expectedKind(){
68+
if(activeView===View.queues)return Kind.queue;
69+
if(activeView===View.files)return Kind.blob;
70+
return Kind.collection;
71+
}
72+
function validScope(){
73+
const value=scope();
74+
if(!value.tenantId||!value.databaseId||!el(Id.partition).value.trim()){
75+
status(Text.scopeRequired,true);
76+
return false;
77+
}return true;
78+
}
79+
export async function loadResources(next=false){
80+
if(!validScope())return;
81+
cancel();
82+
const afterName=next?resourceAfter:null;
83+
const result=await call(Api.resources,{
84+
...scope(),afterName,limit:Config.pageSize
85+
});
86+
if(!result)return;
87+
resourceAfter=result.nextAfterName;
88+
resource=null;
89+
dataAfter=null;
90+
resetTable();
91+
if(activeView===View.files)physicalFiles();
92+
const items=result.items.filter(item=>kindName(item.kind)===expectedKind());
93+
renderResources(items);
94+
el(Id.resourcesNext).disabled=!resourceAfter;
95+
el(Id.resourcesFirst).disabled=afterName===null;
96+
}
97+
function renderResources(items){
98+
const list=el(Id.resourceList);
99+
list.replaceChildren();
100+
write(Id.resourceCount,`${items.length}${Text.resourceSuffix}`);
101+
if(!items.length){
102+
const empty=make(Dom.p,Text.noResources);
103+
empty.className=Dom.emptyClass;
104+
list.append(empty);
105+
return;
106+
}items.forEach(item=>{
107+
const button=make(Dom.button,item.name);
108+
button.type=Dom.buttonType;
109+
button.append(make(Dom.small,`${kindName(item.kind)}${Text.dot}${item.transactionDomainId}`));
110+
button.addEventListener(Dom.click,()=>chooseResource(item,button));
111+
list.append(button);
112+
});
113+
}
114+
async function chooseResource(item,button){
115+
resource=item;
116+
dataAfter=null;
117+
el(Id.resourceList).querySelectorAll(Dom.button).forEach(node=>node.removeAttribute(Dom.pressed));
118+
button.setAttribute(Dom.pressed,Dom.true);
119+
await loadData();
120+
}
121+
export async function loadData(next=false){
122+
if(!resource||!validScope())return;
123+
cancel();
124+
const selected=resource;
125+
const kind=kindName(selected.kind);
126+
const cursor=next?dataAfter:null;
127+
const result=await fetchData(kind,cursor);
128+
if(!result)return;
129+
write(Id.dataTitle,selected.name);
130+
if(kind===Kind.collection)documents(result);
131+
else if(kind===Kind.queue)queue(result);
132+
else if(kind===Kind.blob)blobs(result);
133+
else status(Text.unsupported,true);
134+
el(Id.dataNext).disabled=!dataAfter;
135+
el(Id.dataFirst).disabled=cursor===null;
136+
}
137+
function fetchData(kind,cursor){
138+
const scopePartition=partition();
139+
if(kind===Kind.collection)return call(Api.query,{
140+
partition:scopePartition,query:{
141+
collection:resource.name,alias:null,projection:[],filter:null,order:[],limit:Config.rows,explain:false
142+
},parameters:null,allowFullScan:true,cursor,astVersion:Config.astVersion
143+
});
144+
if(kind===Kind.queue)return call(Api.queue,{
145+
lane:{
146+
partition:scopePartition,queue:resource.name
147+
},afterId:cursor,limit:Config.pageSize
148+
});
149+
if(kind===Kind.blob)return call(Api.blobs,{
150+
partition:scopePartition,resource:resource.name,limit:Config.pageSize,afterId:cursor
151+
});
152+
return null;
153+
}
154+
function documents(page){
155+
dataAfter=page.cursor;
156+
write(Id.dataDescription,Text.documentHint);
157+
table(Headers.documents,page.rows.map(row=>[row.entityId,row.revision,row.json.slice(Config.zero,Config.preview),detailButton(row.json)]));
158+
write(Id.dataEmpty,Text.noRows);
159+
write(Id.pageDescription,`${Text.cut}${Text.space}${page.cutPosition}`);
160+
}
161+
function queue(page){
162+
dataAfter=page.nextAfterId;
163+
write(Id.dataDescription,`${page.counters.storedMessages}${Text.space}${Text.stored}${Text.dot}${page.counters.inFlightMessages}${Text.space}${Text.inFlight}${Text.dot}${Text.queueHint}`);
164+
table(Headers.queues,page.items.map(row=>[row.id,row.state,row.attempts,row.stateVersion,date(row.notBefore),date(row.leaseUntil),date(row.expiresAt)]));
165+
write(Id.dataEmpty,Text.noRows);
166+
write(Id.pageDescription,`${Text.cut}${Text.space}${page.cutPosition}`);
167+
}
168+
function blobs(page){
169+
dataAfter=page.nextAfterId;
170+
write(Id.dataDescription,Text.blobHint);
171+
table(Headers.blobs,page.items.map(row=>[row.blob.id,bytes(row.length),row.revision,row.partCount,date(row.updatedAt)]));
172+
write(Id.dataEmpty,Text.noRows);
173+
write(Id.pageDescription,Text.page);
174+
}

0 commit comments

Comments
 (0)