Files

130 lines
14 KiB
PHP

<?php
require_once __DIR__.'/../../lib/parametercatalog.class.php';
require_once __DIR__.'/../../lib/experiment.class.php';
require_once __DIR__.'/../../lib/job.class.php';
require_once __DIR__.'/../../lib/results.class.php';
require_once __DIR__.'/../../lib/labdb.class.php';
require_once __DIR__.'/../../lib/maps.class.php';
function api_reply($ok,$data=null,$errors=null,$status=200){ http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($ok?['ok'=>true,'data'=>$data]:array_filter(['ok'=>false,'error'=>is_string($errors)?$errors:'Request failed.','errors'=>is_array($errors)?$errors:null]),JSON_INVALID_UTF8_SUBSTITUTE); }
$config=lab_config();
$owner=$config['workspace']??'';
if (!is_string($owner)||$owner==='') { api_reply(false,null,['workspace'=>'Workspace is not configured.'],503); exit; }
$method=$_SERVER['REQUEST_METHOD']??'GET'; $action=is_string($_GET['api']??null)?$_GET['api']:'';
$input=$_POST; if ($method==='POST' && str_contains($_SERVER['CONTENT_TYPE']??'','application/json')) { $decoded=json_decode((string)file_get_contents('php://input'),true); if (is_array($decoded)) $input=$decoded; }
$id=is_string($_GET['id']??null)?$_GET['id']:''; $result=null;
switch($action) {
case 'map':
if($method!=='GET'){api_reply(false,null,['method'=>'GET required.'],405);exit;}
$map=Maps::get($id);if(!$map){api_reply(false,null,['id'=>'Map version not found.'],404);exit;}
$catalog=MapCatalog::schema();$metadata=['id'=>$map['id'],'field'=>$map['field'],'resolution'=>$map['resolution'],'name'=>$map['name'],'digest'=>$map['digest'],'field_metadata'=>$catalog['fields'][$map['field']]];
header('Content-Type: application/octet-stream');header('X-Map-Shape: '.$map['rows'].','.$map['cols']);header('X-Map-Metadata: '.base64_encode(json_encode($metadata)));echo $map['data'];exit;
case 'maps':
if($method==='GET'){$result=Maps::all((string)($_GET['resolution']??''));if(!empty($result['ok'])){$result['data']['fields']=ParameterCatalog::schema()['map_fields']??$result['data']['fields'];$result['data']['grid']=ParameterCatalog::schema()['map_grids'][$_GET['resolution']]??$result['data']['grid'];}api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200);exit;}
if($method!=='POST'){api_reply(false,null,['method'=>'GET or POST required.'],405);exit;}
if(!str_starts_with($_SERVER['CONTENT_TYPE']??'','application/octet-stream')){api_reply(false,null,['content_type'=>'Use application/octet-stream.'],415);exit;}
$result=Maps::save((string)($_GET['field']??''),(string)($_GET['resolution']??''),(string)($_GET['name']??''),(string)file_get_contents('php://input'));api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:201);exit;
case 'schema': $result=ParameterCatalog::schema(); if(!$result) api_reply(false,null,['schema'=>'Parameter catalog unavailable.'],503); else api_reply(true,$result); exit;
case 'experiments': if($method==='GET') api_reply(true,Experiment::all($owner)); else { $result=Experiment::save($owner,$input); api_reply($result['ok'],$result['data']??null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); } exit;
case 'experiment_delete': $result=Experiment::delete($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit;
case 'jobs': if($method==='GET') api_reply(true,Job::all($owner)); else { $e=Experiment::get($input['experiment_id']??'',$owner); if(!$e) api_reply(false,null,['experiment'=>'Experiment not found.'],404); else { $result=Job::enqueue($owner,$e); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); } } exit;
case 'generate': $spec=$input['spec']??null; $result=Job::enqueueKind($owner,'planet',$input['name']??'Generated planet',$spec); api_reply($result['ok'],isset($result['data'])?['job'=>$result['data']]:null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); exit;
case 'cancel': $result=Job::cancel($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit;
case 'retry': $result=Job::retry($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); exit;
case 'log': $result=Job::log($id,$owner,$_GET['offset']??0); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit;
case 'results': case 'series': case 'frame': case 'download': api_artifacts($action,$id,$owner); exit;
default: api_reply(false,null,['route'=>'Not found.'],404); exit;
}
function owned_job($id,$owner){ return is_string($id)&&preg_match('/^[a-f0-9]{32}$/',$id)?Job::get($id,$owner):null; }
function api_artifact_path(array $job,string $path): ?string {
$root=realpath(Job::directory($job));$file=realpath($path);
return $root!==false&&$file!==false&&is_file($file)&&is_readable($file)&&str_starts_with($file,$root.DIRECTORY_SEPARATOR)?$file:null;
}
function api_artifact_limit(): int { return max(0,(int)(lab_config()['max_result_bytes']??0)); }
function api_artifact_available(array $job,string $path): bool {
$file=api_artifact_path($job,$path);$size=$file===null?false:filesize($file);
return $size!==false&&$size<=api_artifact_limit();
}
function api_send_artifact(array $job,string $path,string $name): void {
$file=api_artifact_path($job,$path);
if($file===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;}
$stream=@fopen($file,'rb');$stat=$stream===false?false:fstat($stream);$limit=api_artifact_limit();
if($stat===false||$stat['size']>$limit){if($stream!==false)fclose($stream);api_reply(false,null,['artifact'=>'Artifact exceeds the download size limit.'],413);return;}
$name=basename($name);header('Content-Type: application/octet-stream');header('Content-Disposition: attachment; filename="'.$name.'"');header('Content-Length: '.$stat['size']);
$remaining=$stat['size'];while($remaining>0&&!feof($stream)){$chunk=fread($stream,min(1048576,$remaining));if($chunk===false||$chunk==='')break;echo $chunk;$remaining-=strlen($chunk);}fclose($stream);
}
function api_downloads(array $job,array $manifest,array $selected): array {
$root=Job::directory($job);$artifacts=$root.'/artifacts';$links=[];
$add=static function(string $id,string $label,string $path)use(&$links,$job):void{if(api_artifact_available($job,$path))$links[]=['id'=>$id,'label'=>$label];};
$add('log','Run log',$root.'/worker.log');
foreach($job['spec']['maps']??[] as $field=>$version){$meta=ParameterCatalog::schema()['map_fields'][$field]??null;if(!$meta)continue;$add('input-map:'.$field,'Input map · '.$meta['label'],$root.'/inputs/maps/'.$field.'.f32');$add('native-input:'.$field,'Native input · '.$meta['label'],$root.'/inputs/native/'.$field.'.sra');}
if(($job['kind']??'model')==='model'){
$add('model-config','Model configuration',$root.'/runtime/GUI.cfg');
$add('restart:plasim_restart','Model restart',$root.'/runtime/plasim_restart');
foreach(glob($root.'/runtime/MOST_REST*')?:[] as $path){$name=basename($path);if(preg_match('/^MOST_REST(?:\\.[0-9]{5})?$/',$name))$add('restart:'.$name,$name,$path);}
}
if(($job['kind']??'')==='planet'){
$add('terrain-recipe','Terrain recipe',$artifacts.'/planet.json');
foreach(glob($artifacts.'/planet-work/N???_surf_*.sra')?:[] as $path){$name=basename($path);if(preg_match('/^N[0-9]{3}_surf_(0129|0172)\.sra$/',$name))$add('sra:'.$name,$name,$path);}
}
foreach($manifest['datasets']??[] as $position=>$item){
$datasetId=$item['id']??'';if($datasetId!==($selected['id']??null))continue;$index=$selected;
$add('dataset-index:'.$datasetId,'Dataset index · '.($item['label']??'Dataset'),$artifacts.'/datasets/'.$datasetId.'/index.json');
foreach($index['fields']??[] as $field){if(!is_array($field))continue;$name=$field['name']??'';if(preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$name))$add('field:'.$datasetId.':'.$name,($item['label']??'Dataset').' · '.($field['label']??$name),$artifacts.'/datasets/'.$datasetId.'/'.$name.'.f32');}
if(($job['kind']??'model')==='model'){
$raw='MOST.'.sprintf('%05d',$position);$add('raw:'.$datasetId,'Raw model output · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'.npz');
$add('raw-metadata:'.$datasetId,'Raw model metadata · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'_metadata.npz');
}
if(($job['kind']??'')==='planet')foreach(glob($artifacts.'/planet-work/surface-*.npz')?:[] as $path){$name=basename($path);if(preg_match('/^(surface-[a-f0-9]{32})\.npz$/',$name,$parts)){$add('raw:'.$datasetId.':'.$name,'Raw terrain preview',$path);$add('raw-metadata:'.$datasetId.':'.$parts[1],'Raw terrain metadata',$artifacts.'/planet-work/'.$parts[1].'_metadata.npz');}}
}
return $links;
}
function api_artifacts(string $route,$id,$owner): void {
$job=owned_job($id,$owner);if(!$job){api_reply(false,null,['id'=>'Job not found.'],404);return;}
$root=Job::directory($job);$base=$root.'/artifacts';$manifest=Results::manifest($job)??['datasets'=>[],'latest'=>null];
if($route==='download'){
$artifact=is_string($_GET['artifact']??null)?$_GET['artifact']:'';$path=null;$name='';
if($artifact==='log'){$path=$root.'/worker.log';$name='worker.log';}
elseif(preg_match('/^(input-map|native-input):([a-z][a-z0-9_]*)$/',$artifact,$match)&&isset($job['spec']['maps'][$match[2]],ParameterCatalog::schema()['map_fields'][$match[2]])){$native=$match[1]==='native-input';$name=$match[2].($native?'.sra':'.f32');$path=$root.'/inputs/'.($native?'native':'maps').'/'.$name;}
elseif($artifact==='model-config'&&($job['kind']??'model')==='model'){$path=$root.'/runtime/GUI.cfg';$name='GUI.cfg';}
elseif($artifact==='terrain-recipe'&&($job['kind']??'')==='planet'){$path=$base.'/planet.json';$name='planet.json';}
elseif(preg_match('/^dataset-index:([a-f0-9]{32})$/',$artifact,$match)&&Results::dataset($job,$match[1])){$path=$base.'/datasets/'.$match[1].'/index.json';$name='index.json';}
elseif(preg_match('/^field:([a-f0-9]{32}):([A-Za-z][A-Za-z0-9_]*)$/',$artifact,$match)){$index=Results::dataset($job,$match[1]);foreach($index['fields']??[] as $field)if(($field['name']??null)===$match[2]){$path=$base.'/datasets/'.$match[1].'/'.$match[2].'.f32';$name=$match[2].'.f32';break;}}
elseif(preg_match('/^raw:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}\.npz))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){
if(($job['kind']??'model')==='model'&&empty($match[2])){
foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$path=$root.'/runtime/'.sprintf('MOST.%05d.npz',$position);$name=basename($path);break;}
}elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2];$path=$base.'/planet-work/'.$name;}
}
elseif(preg_match('/^raw-metadata:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){
if(($job['kind']??'model')==='model'&&empty($match[2])){
foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$name='MOST.'.sprintf('%05d',$position).'_metadata.npz';$path=$root.'/runtime/'.$name;break;}
}elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2].'_metadata.npz';$path=$base.'/planet-work/'.$name;}
}
elseif(preg_match('/^restart:(plasim_restart|MOST_REST(?:\.[0-9]{5})?)$/',$artifact,$match)&&($job['kind']??'model')==='model'){$name=$match[1];$path=$root.'/runtime/'.$name;}
elseif(preg_match('/^sra:(N[0-9]{3}_surf_(?:0129|0172)\.sra)$/',$artifact,$match)&&($job['kind']??'')==='planet'){$name=$match[1];$path=$base.'/planet-work/'.$name;}
if($path===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;}
api_send_artifact($job,$path,$name);return;
}
if($route==='results'){
$dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset);
if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;}
api_reply(true,['index'=>$index,'dataset'=>$index,'datasets'=>$manifest['datasets'],'latest'=>$manifest['latest']??null,'downloads'=>api_downloads($job,$manifest,$index)]);return;
}
$dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset);
if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;}
$field=is_string($_GET['field']??null)?$_GET['field']:'';$meta=null;
foreach($index['fields']??[] as $candidate)if(($candidate['name']??null)===$field)$meta=$candidate;
if(!$meta||!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$field)){api_reply(false,null,['field'=>'Field not found.'],404);return;}
$shape=$meta['shape']??[];$level=filter_var($_GET['level']??0,FILTER_VALIDATE_INT);$time=filter_var($_GET['time']??0,FILTER_VALIDATE_INT);
if(!is_array($shape)||$level===false||$time===false||$level<0||$time<0||$level>=(int)($shape[1]??0)||$time>=(int)($shape[0]??0)){api_reply(false,null,['selection'=>'Selection out of range.'],416);return;}
$dir=$base.'/datasets/'.$dataset;
if($route==='series'){$stats=json_decode((string)@file_get_contents($dir.'/'.$field.'.json'),true)??[];api_reply(true,['time'=>$index['time']??[],'time_unit'=>$index['time_unit']??'','mean'=>array_column($stats['mean']??[],$level),'min'=>array_column($stats['min']??[],$level),'max'=>array_column($stats['max']??[],$level),'unit'=>$meta['unit']??'']);return;}
if($route==='frame'){
$lat=count($index['lat']??[]);$lon=count($index['lon']??[]);$count=$lat*$lon;$bytes=$count*4;$limit=max(0,(int)(lab_config()['max_field_memory_bytes']??8388608));$file=api_artifact_path($job,$dir.'/'.$field.'.f32');$stream=$file===null?false:@fopen($file,'rb');
$offset=(($time*(int)$shape[1]+$level)*$count)*4;
if(!$stream||$count<1||$bytes>$limit||fseek($stream,$offset)!==0){if($stream)fclose($stream);api_reply(false,null,['frame'=>'Frame unavailable.'],404);return;}
$data=fread($stream,$bytes);fclose($stream);if($data===false||strlen($data)!==$bytes){api_reply(false,null,['frame'=>'Frame is incomplete.'],404);return;}
header('Content-Type: application/octet-stream');header('X-Frame-Shape: '.$lat.','.$lon);echo $data;return;
}
api_reply(false,null,['route'=>'Not found.'],404);
}